diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 000000000..45dd97a4b --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,8 @@ +--- +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: daily + time: "06:00" diff --git a/.github/workflows/esphome-dummy.yaml b/.github/workflows/esphome-dummy.yaml new file mode 100644 index 000000000..081c0ccc4 --- /dev/null +++ b/.github/workflows/esphome-dummy.yaml @@ -0,0 +1,26 @@ +--- +name: ESPHome + +## This workflow exists to keep branch protection happy in the event of no changes being made to the esphome directory. + +# yamllint disable-line rule:truthy +on: + pull_request: + paths-ignore: + - 'esphome/**' + - '.github/workflows/esphome**' + push: + branches: [main] + paths-ignore: + - 'esphome/**' + - '.github/workflows/esphome**' + schedule: + - cron: 0 12 * * * + +jobs: + # This is used by branch protections + final: + name: Final ESPHome check + runs-on: ubuntu-latest + steps: + - run: 'echo "No build required"' diff --git a/.github/workflows/esphome-parallel.yaml b/.github/workflows/esphome-parallel.yaml index 8193bba8f..32be7d32c 100644 --- a/.github/workflows/esphome-parallel.yaml +++ b/.github/workflows/esphome-parallel.yaml @@ -1,10 +1,19 @@ -name: ESPHome Parallel +--- +name: ESPHome # yamllint disable-line rule:truthy on: pull_request: + paths: + - 'esphome/**' + - '.github/workflows/esphome**' push: branches: [main] + paths: + - 'esphome/**' + - '.github/workflows/esphome**' + schedule: + - cron: 0 12 * * * jobs: files: # Job that lists files @@ -14,14 +23,14 @@ jobs: file: ${{ steps.set-files.outputs.file }} # generate output name file by using inner step output steps: - name: Getting your configuration from GitHub - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Get list of files id: set-files # Give it an id to handle to get step outputs in the outputs key above run: echo "::set-output name=file::$(ls esphome/*.yaml | jq -R -s -c 'split("\n")[:-1]')" # Define step output named file base on ls command transformed to JSON thanks to jq - loop: - name: Test ESPHome firmware + loop-stable: + name: Test ESPHome Stable firmware runs-on: ubuntu-latest needs: [files] # Depends on previous job strategy: @@ -29,9 +38,9 @@ jobs: file: ${{fromJson(needs.files.outputs.file)}} # List matrix strategy from files dynamically steps: - name: Getting your configuration from GitHub - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: ESPHome Version - uses: "docker://esphome/esphome:latest" + uses: "docker://esphome/esphome:stable" with: args: "version" - name: Copy stub files into configuration folder @@ -39,13 +48,67 @@ jobs: run: | cp -R esphome/travis_secrets.yaml.txt esphome/common/secrets.yaml cp -R esphome/travis_secrets.yaml.txt esphome/secrets.yaml - - run: echo Compiling ${{matrix.file}} - - run: docker run --rm -v "${PWD}":/config esphome/esphome:latest compile ${{matrix.file}} + - name: Compile all ESPHome ${{matrix.file}} + uses: esphome/build-action@v1 + with: + version: stable + yaml_file: ${{matrix.file}} + + loop-beta: + name: Test ESPHome Beta firmware + runs-on: ubuntu-latest + needs: [files] # Depends on previous job + strategy: + matrix: + file: ${{fromJson(needs.files.outputs.file)}} # List matrix strategy from files dynamically + steps: + - name: Getting your configuration from GitHub + uses: actions/checkout@v4 + - name: ESPHome Version + uses: "docker://esphome/esphome:beta" + with: + args: "version" + - name: Copy stub files into configuration folder + if: ${{ steps.esphome-test-required.outputs.ESPHOME_CHANGES != 'false' }} + run: | + cp -R esphome/travis_secrets.yaml.txt esphome/common/secrets.yaml + cp -R esphome/travis_secrets.yaml.txt esphome/secrets.yaml + - name: Compile all ESPHome ${{matrix.file}} + uses: esphome/build-action@v1 + with: + version: beta + yaml_file: ${{matrix.file}} + + loop-dev: + name: Test ESPHome Dev firmware + runs-on: ubuntu-latest + needs: [files] # Depends on previous job + strategy: + matrix: + file: ${{fromJson(needs.files.outputs.file)}} # List matrix strategy from files dynamically + steps: + - name: Getting your configuration from GitHub + uses: actions/checkout@v4 + - name: ESPHome Version + uses: "docker://esphome/esphome:dev" + with: + args: "version" + - name: Copy stub files into configuration folder + if: ${{ steps.esphome-test-required.outputs.ESPHOME_CHANGES != 'false' }} + run: | + cp -R esphome/travis_secrets.yaml.txt esphome/common/secrets.yaml + cp -R esphome/travis_secrets.yaml.txt esphome/secrets.yaml + - name: Compile all ESPHome ${{matrix.file}} + uses: esphome/build-action@v1 + with: + version: dev + yaml_file: ${{matrix.file}} + # This is used by branch protections final: name: Final ESPHome check runs-on: ubuntu-latest - needs: [loop] + needs: [loop-stable, loop-beta, loop-dev] steps: - name: Getting your configuration from GitHub - uses: actions/checkout@v2 + uses: actions/checkout@v4 diff --git a/.github/workflows/esphome.yaml.disabled b/.github/workflows/esphome.yaml.disabled deleted file mode 100644 index d30e08121..000000000 --- a/.github/workflows/esphome.yaml.disabled +++ /dev/null @@ -1,118 +0,0 @@ ---- -name: ESPHome CI - -# yamllint disable-line rule:truthy -on: - push: - pull_request: - schedule: - - cron: 0 12 * * * - -jobs: - esphome-test: - runs-on: ubuntu-latest - steps: - - name: Getting your configuration from GitHub - uses: actions/checkout@v1 - - name: Check for esphome tests requirement - id: esphome-test-required - run: | - CHANGED_FILES=$(git diff --name-status HEAD~1...HEAD . | grep -i esphome || true) - if [ -z "$CHANGED_FILES" ] ; then - echo "No changes to ESPHome files" - echo ::set-output name=ESPHOME_CHANGES::false - exit 0 - fi - - esphome_stable: - runs-on: ubuntu-latest - needs: esphome-test - steps: - - name: Getting your configuration from GitHub - uses: actions/checkout@v1 - - name: ESPHome Version - uses: "docker://esphome/esphome:latest" - with: - args: "version" - - name: Copy stub files into configuration folder - if: ${{ steps.esphome-test-required.outputs.ESPHOME_CHANGES != 'false' }} - run: | - cp -R esphome/travis_secrets.yaml.txt esphome/common/secrets.yaml - cp -R esphome/travis_secrets.yaml.txt esphome/secrets.yaml - - name: Run esphome on all files - if: ${{ steps.esphome-test-required.outputs.ESPHOME_CHANGES != 'false' }} - # yamllint disable rule:line-length - run: | - for file in $(find \ - ${PWD}/esphome/ \ - -type f \ - -maxdepth 1\ - -name "*.yaml"\ - -not -name "secrets.yaml"\ - -printf "%f\n" - ); do - set -o errexit - echo $file - done | parallel --max-args=1 docker run --rm -v '${PWD}':/config esphome/esphome:latest compile esphome/{} - - esphome_beta: - runs-on: ubuntu-latest - needs: esphome-test - steps: - - name: Getting your configuration from GitHub - uses: actions/checkout@v1 - - name: ESPHome Version - uses: "docker://esphome/esphome:beta" - with: - args: "version" - - name: Copy stub files into configuration folder - if: ${{ steps.esphome-test-required.outputs.ESPHOME_CHANGES != 'false' }} - run: | - cp -R esphome/travis_secrets.yaml.txt esphome/common/secrets.yaml - cp -R esphome/travis_secrets.yaml.txt esphome/secrets.yaml - - name: Run esphome on all files - if: ${{ steps.esphome-test-required.outputs.ESPHOME_CHANGES != 'false' }} - # yamllint disable rule:line-length - run: | - for file in $(find \ - ${PWD}/esphome/ \ - -type f \ - -maxdepth 1\ - -name "*.yaml"\ - -not -name "secrets.yaml"\ - -printf "%f\n" - ); do - set -o errexit - echo $file | parallel --max-args=1 docker run --rm -v '${PWD}':/config esphome/esphome:beta compile esphome/{} - done - - esphome_dev: - runs-on: ubuntu-latest - needs: esphome-test - steps: - - name: Getting your configuration from GitHub - uses: actions/checkout@v1 - - name: ESPHome Version - uses: "docker://esphome/esphome:dev" - with: - args: "version" - - name: Copy stub files into configuration folder - if: ${{ steps.esphome-test-required.outputs.ESPHOME_CHANGES != 'false' }} - run: | - cp -R esphome/travis_secrets.yaml.txt esphome/common/secrets.yaml - cp -R esphome/travis_secrets.yaml.txt esphome/secrets.yaml - - name: Run esphome on all files - if: ${{ steps.esphome-test-required.outputs.ESPHOME_CHANGES != 'false' }} - # yamllint disable rule:line-length - run: | - for file in $(find \ - ${PWD}/esphome/ \ - -type f \ - -maxdepth 1\ - -name "*.yaml"\ - -not -name "secrets.yaml"\ - -printf "%f\n" - ); do - set -o errexit - echo $file | parallel --max-args=1 docker run --rm -v '${PWD}':/config esphome/esphome:dev compile esphome/{} - done diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index a90335239..7fd5d3992 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -1,5 +1,5 @@ --- -name: Home Assistant CI +name: Home Assistant # yamllint disable-line rule:truthy on: @@ -13,17 +13,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Getting your configuration from GitHub - uses: actions/checkout@v1 + uses: actions/checkout@v4 - name: Running YAMLlint - uses: ibiqlik/action-yamllint@v1 + uses: ibiqlik/action-yamllint@v3 continue-on-error: false with: config_file: .github/yamllint-config.yml remarklint: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest steps: - name: Getting your configuration from GitHub - uses: actions/checkout@v1 + uses: actions/checkout@v4 - name: Running Remark lint uses: "docker://pipelinecomponents/remark-lint:latest" continue-on-error: false @@ -34,7 +34,7 @@ jobs: needs: [yamllint, remarklint] steps: - name: Getting your configuration from GitHub - uses: actions/checkout@v1 + uses: actions/checkout@v4 - name: Copy stub files into configuration folder run: | cp -R travis_secrets.yaml secrets.yaml @@ -55,7 +55,7 @@ jobs: needs: [yamllint, remarklint] steps: - name: Getting your configuration from GitHub - uses: actions/checkout@v1 + uses: actions/checkout@v4 - name: Copy stub files into configuration folder run: | cp -R travis_secrets.yaml secrets.yaml @@ -76,7 +76,7 @@ jobs: needs: [yamllint, remarklint] steps: - name: Getting your configuration from GitHub - uses: actions/checkout@v1 + uses: actions/checkout@v4 - name: Copy stub files into configuration folder run: | cp -R travis_secrets.yaml secrets.yaml diff --git a/README.md b/README.md index 97bf04d56..0660639ef 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -[![Build Status](https://travis-ci.org/kylegordon/home-assistant-config.svg?branch=master)](https://travis-ci.org/kylegordon/home-assistant-config) +![Home-Assistant](https://github.com/kylegordon/home-assistant-config/actions/workflows/main.yaml/badge.svg) +![ESPHome](https://github.com/kylegordon/home-assistant-config/actions/workflows/esphome-parallel.yaml/badge.svg) My Home-Assistant configuration. diff --git a/automation/TimedTriggers/alarm_clock_morning.yaml b/automation/TimedTriggers/alarm_clock_morning.yaml index cdeb2d02b..2d1fca0e0 100644 --- a/automation/TimedTriggers/alarm_clock_morning.yaml +++ b/automation/TimedTriggers/alarm_clock_morning.yaml @@ -1,10 +1,11 @@ +--- alias: "Wakeup transition" trigger: - platform: time - at: '06:45:00' + at: '07:15:00' condition: - condition: state - entity_id: calendar.holiday_mode + entity_id: binary_sensor.holiday_mode state: 'off' - condition: or conditions: @@ -21,6 +22,9 @@ condition: - wed - thu - fri + - condition: numeric_state + entity_id: sensor.average_external_light_level + below: 500 action: - service: logbook.log data_template: diff --git a/automation/TimedTriggers/bedtime_on.yaml b/automation/TimedTriggers/bedtime_on.yaml deleted file mode 100644 index fa3632c95..000000000 --- a/automation/TimedTriggers/bedtime_on.yaml +++ /dev/null @@ -1,15 +0,0 @@ -alias: "Bedtime lights on" -trigger: - - platform: time - at: '21:00:00' -condition: - - condition: state - entity_id: binary_sensor.home_occupied - state: "on" -action: - - service: light.turn_on - entity_id: group.bedside_lights - data: - brightness_pct: 75 - color_temp: 400 - transition: 30 diff --git a/automation/TimedTriggers/turn_off_bedside_lights.yaml b/automation/TimedTriggers/turn_off_bedside_lights.yaml deleted file mode 100644 index ac9672015..000000000 --- a/automation/TimedTriggers/turn_off_bedside_lights.yaml +++ /dev/null @@ -1,9 +0,0 @@ -alias: "Off wakup lights" -trigger: - - platform: numeric_state - entity_id: sensor.average_external_light_level - above: 500 -action: - - service: light.turn_off - data: - entity_id: light.master_bedroom, light.bedside_charlotte, light.bedside_kyle diff --git a/automation/aurora_alert.yaml b/automation/aurora_alert.yaml index 3ace050a2..c6b2662eb 100644 --- a/automation/aurora_alert.yaml +++ b/automation/aurora_alert.yaml @@ -1,10 +1,9 @@ --- alias: Aurora Alert trigger: - platform: state - entity_id: binary_sensor.aurora_visibility_aurora_visibility_alert - from: "off" - to: "on" + platform: numeric_state + entity_id: sensor.aurora_visibility + above: 1 action: - service: notify.alexa_media_everywhere data: @@ -16,7 +15,7 @@ action: data: title: "Aurora Alert" message: "Alert! The Aurora Borealis might be visible right now!" - - service: notify.mobile_app + - service: notify.mobile_app_nothing_phone_1 data: title: "Aurora Alert" message: "Alert! The Aurora Borealis might be visible right now!" diff --git a/automation/back_motion_night.yaml b/automation/back_motion_night.yaml deleted file mode 100644 index 9a9dae384..000000000 --- a/automation/back_motion_night.yaml +++ /dev/null @@ -1,25 +0,0 @@ -alias: Back outdoor lights on -trigger: - - platform: state - entity_id: - - binary_sensor.outside_driveway_motion - - binary_sensor.outside_utility_room_motion - to: 'on' -condition: - - condition: state - entity_id: input_boolean.night_view - state: "off" - - condition: state - entity_id: sun.sun - state: "below_horizon" -action: - - service: script.tweet_engine - data_template: - tweet: > - {{ [ - "Dark outside and motion detected! Scared! Turning on my outside lights!", - "Woooo, it's dark and scary. Something moved! Turning on the outside lights!", - "Did you hear that? I can't see a thing. Turning on the outside lights" - ] | random + " https://amzn.to/2CR5mbQ" }} - - service: light.turn_on - entity_id: light.driveway_floodlights, light.back_floodlights, light.mini_deck_floodlight diff --git a/automation/back_motion_night_off.yaml b/automation/back_motion_night_off.yaml deleted file mode 100644 index b1d7b089e..000000000 --- a/automation/back_motion_night_off.yaml +++ /dev/null @@ -1,15 +0,0 @@ -alias: Back outdoor lights off -trigger: - - platform: state - entity_id: binary_sensor.outside_driveway_motion - to: 'off' - for: - seconds: 300 - - platform: state - entity_id: binary_sensor.outside_utility_room_motion - to: 'off' - for: - seconds: 300 -action: - - service: light.turn_off - entity_id: light.driveway_floodlights, light.back_floodlights, light.mini_deck_floodlight diff --git a/automation/bedside_light_toggle.yaml b/automation/bedside_light_toggle.yaml deleted file mode 100644 index b74c98859..000000000 --- a/automation/bedside_light_toggle.yaml +++ /dev/null @@ -1,14 +0,0 @@ -alias: Bedside light toggle -trigger: - platform: mqtt - topic: devices/60019485376d/button/event - # Optional - payload: 'DOUBLEHELD' - -action: - - service: light.toggle - entity_id: group.bedside_lights - - service: logbook.log - data_template: - name: EVENT - message: "Toggling bedside lights" diff --git a/automation/bedtime_off.yaml b/automation/bedtime_off.yaml deleted file mode 100644 index f6ad7faba..000000000 --- a/automation/bedtime_off.yaml +++ /dev/null @@ -1,13 +0,0 @@ -alias: "Bedtime lights off" -trigger: - - platform: time - at: '01:00:00' -condition: - - condition: state - entity_id: binary_sensor.home_occupied - state: "on" -action: - - service: light.turn_off - entity_id: - - group.bedside_lights - - light.twig_lights diff --git a/automation/bin_notification.yaml b/automation/bin_notification.yaml index a96d3ea98..99a5ab6a0 100644 --- a/automation/bin_notification.yaml +++ b/automation/bin_notification.yaml @@ -1,7 +1,7 @@ alias: 'Bin notification' trigger: - platform: template - value_template: '{{ states.sensor.date.state == strptime(states.calendar.next_bin.attributes.start_time, "%Y-%m-%d %H:%M:%S").strftime("%Y-%m-%d")}}' + value_template: '{{ states.sensor.date.state == strptime(states.calendar.garbage_collection.attributes.start_time, "%Y-%m-%d %H:%M:%S").strftime("%Y-%m-%d")}}' - platform: time at: '17:00:00' condition: @@ -19,5 +19,5 @@ condition: action: - service: persistent_notification.create data: - message: "Next bin: {{ states.calendar.next_bin.attributes.message }}. " + message: "Next bin: {{ states.calendar.garbage_collection.attributes.message }}. " title: "Bin Reminder" diff --git a/automation/classicfm.yaml b/automation/classicfm.yaml deleted file mode 100644 index b871e11fa..000000000 --- a/automation/classicfm.yaml +++ /dev/null @@ -1,14 +0,0 @@ -alias: "Play Classic FM" -trigger: - platform: state - entity_id: input_boolean.classic_fm - from: 'off' - to: 'on' -action: - - service: media_player.play_media - data: - entity_id: media_player.openhome_uuid_4c494e4e_0026_0f22_3637_01475230013f - media_content_id: "http://media-ice.musicradio.com:80/ClassicFMMP3" - media_content_type: music - - service: homeassistant.turn_off - entity_id: input_boolean.classic_fm diff --git a/automation/craftroom_touch_toggle.yaml b/automation/craftroom_touch_toggle.yaml deleted file mode 100644 index 4711b8c2e..000000000 --- a/automation/craftroom_touch_toggle.yaml +++ /dev/null @@ -1,15 +0,0 @@ -alias: Craft Room light toggle -trigger: - - platform: state - entity_id: binary_sensor.craft_room_switch_touch_pad - to: 'on' - -action: - - service: switch.turn_on - entity_id: switch.craft_room_switch_relay - - service: light.toggle - entity_id: group.craft_room_lighting - - service: logbook.log - data_template: - name: EVENT - message: "Toggling craft room light" diff --git a/automation/front_motion_night.yaml b/automation/front_motion_night.yaml deleted file mode 100644 index 45b7830f1..000000000 --- a/automation/front_motion_night.yaml +++ /dev/null @@ -1,34 +0,0 @@ -alias: Front outdoor lights on -trigger: - - platform: state - entity_id: binary_sensor.outside_front_motion - to: 'on' -condition: - - condition: state - entity_id: input_boolean.night_view - state: "off" - - condition: state - entity_id: sun.sun - state: "below_horizon" -action: - - service: script.tweet_engine - data_template: - tweet: > - {{ [ - "Dark outside and motion detected! Scared! Turning on my outside lights!", - "Woooo, it's dark and scary. Something moved! Turning on the outside lights!", - "Did you hear that? I can't see a thing. Turning on the outside lights" - ] | random + " https://amzn.to/2CR5mbQ" }} - - service: light.turn_on - entity_id: light.driveway_floodlights, light.mini_deck_floodlight - - service: light.turn_on - data: - entity_id: light.decking_lights - brightness_pct: 100 - white_value: 255 - color_temp: 153 - # If this evaluates to false, the action will stop here. - - condition: template - value_template: "{{ not is_state('binary_sensor.home_occupied') }}" - - service: light.turn_on - entity_id: light.front_door_floodlights diff --git a/automation/front_motion_night_off.yaml b/automation/front_motion_night_off.yaml deleted file mode 100644 index 4db4c2347..000000000 --- a/automation/front_motion_night_off.yaml +++ /dev/null @@ -1,15 +0,0 @@ -alias: Front outdoor lights off -trigger: - - platform: state - entity_id: binary_sensor.outside_front_motion - to: 'off' - for: - seconds: 600 -action: - - service: light.turn_off - entity_id: light.driveway_floodlights, light.mini_deck_floodlight, light.front_door_floodlights - - service: light.turn_on - data: - entity_id: light.decking_lights - brightness_pct: 0 - white_value: 0 diff --git a/automation/going_to_bed.yaml b/automation/going_to_bed.yaml index 67780f3b1..296ac3e54 100644 --- a/automation/going_to_bed.yaml +++ b/automation/going_to_bed.yaml @@ -32,9 +32,9 @@ action: entity_id: - group.living_room - group.outside_lights - - group.utility_room + - group.front_hall - group.kitchen - - group.hall + - light.hall - light.twig_lights - group.bedroom_2 - group.bedroom_3 diff --git a/automation/guest_bedroom_touch_toggle.yaml b/automation/guest_bedroom_touch_toggle.yaml index 8a0f7297c..70a632036 100644 --- a/automation/guest_bedroom_touch_toggle.yaml +++ b/automation/guest_bedroom_touch_toggle.yaml @@ -1,9 +1,12 @@ +--- alias: Guest Bedroom light toggle +# Can be improved, examples at https://github.com/TheFes/HA-configuration/blob/main/include/automation/01_first_floor/floris/shelly_floris.yaml trigger: - - platform: state - entity_id: binary_sensor.guest_bedroom_switch_touch_pad - to: 'on' - + - platform: event + event_type: esphome.button_pressed + event_data: + device_name: guestbedroom_switch + click_type: single action: - service: switch.turn_on entity_id: switch.guest_bedroom_switch_relay diff --git a/automation/home_dark.yaml b/automation/home_dark.yaml index f2ae947df..d8c16544d 100644 --- a/automation/home_dark.yaml +++ b/automation/home_dark.yaml @@ -20,33 +20,6 @@ action: - service: notify.email_kyle data: message: "Home in the dark." - - service: light.turn_on - data: - entity_id: group.hall - brightness_pct: > - {% if states('sensor.time_of_day') == "Morning" %} - 100 - {% elif states('sensor.time_of_day') == "Day" %} - 100 - {% elif states('sensor.time_of_day') == "Night" %} - 30 - {% endif %} - transition: > - {% if states('sensor.time_of_day') == "Morning" %} - 10 - {% elif states('sensor.time_of_day') == "Day" %} - 2 - {% elif states('sensor.time_of_day') == "Night" %} - 5 - {% endif %} - color_temp: > - {% if states('sensor.time_of_day') == "Morning" %} - 200 - {% elif states('sensor.time_of_day') == "Day" %} - 300 - {% elif states('sensor.time_of_day') == "Night" %} - 375 - {% endif %} - service: light.turn_on data: entity_id: light.sideboard diff --git a/automation/home_late_dark.yaml b/automation/home_late_dark.yaml index 814bae94e..eea599205 100644 --- a/automation/home_late_dark.yaml +++ b/automation/home_late_dark.yaml @@ -23,7 +23,7 @@ action: message: "Home late..." - service: light.turn_on data: - entity_id: group.hall + entity_id: light.hall brightness_pct: > {% if states('sensor.time_of_day') == "Morning" %} 100 diff --git a/automation/kitchen_motion.yaml b/automation/kitchen_motion.yaml deleted file mode 100644 index 1935f0fb1..000000000 --- a/automation/kitchen_motion.yaml +++ /dev/null @@ -1,40 +0,0 @@ -alias: Kitchen motion -trigger: - platform: state - entity_id: binary_sensor.kitchen_motion - to: 'on' -condition: - - condition: numeric_state - entity_id: sensor.average_external_light_level - below: 1000 -action: - - service: homeassistant.turn_on - data_template: - entity_id: - - light.kitchen_cabinets - - light.kitchen - brightness_pct: > - {% if states('sensor.time_of_day') == "Morning" %} - 100 - {% elif states('sensor.time_of_day') == "Day" %} - 100 - {% elif states('sensor.time_of_day') == "Night" %} - 30 - {% endif %} - transition: > - {% if states('sensor.time_of_day') == "Morning" %} - 10 - {% elif states('sensor.time_of_day') == "Day" %} - 2 - {% elif states('sensor.time_of_day') == "Night" %} - 5 - {% endif %} - color_temp: > - {% if states('sensor.time_of_day') == "Morning" %} - 200 - {% elif states('sensor.time_of_day') == "Day" %} - 300 - {% elif states('sensor.time_of_day') == "Night" %} - 375 - {% endif %} - # rgb_color: [255,255,255] diff --git a/automation/kitchen_motion_off.yaml b/automation/kitchen_motion_off.yaml deleted file mode 100644 index e85175304..000000000 --- a/automation/kitchen_motion_off.yaml +++ /dev/null @@ -1,12 +0,0 @@ -alias: Kitchen motion - 10 minute timeout -trigger: - platform: state - entity_id: binary_sensor.kitchen_motion - to: 'off' - for: - minutes: 10 -action: - service: homeassistant.turn_off - entity_id: - - light.kitchen_cabinets - - light.kitchen diff --git a/automation/living_room_sofa_dim.yaml b/automation/living_room_sofa_dim.yaml index ad14440cd..175af9cf4 100644 --- a/automation/living_room_sofa_dim.yaml +++ b/automation/living_room_sofa_dim.yaml @@ -1,4 +1,6 @@ +--- alias: Living Room TV dimmer +id: living_room_tv_dimmer trigger: - platform: state entity_id: media_player.living_room_kodi @@ -7,18 +9,16 @@ condition: - condition: or conditions: - condition: state - entity_id: light.sofa_overhead + entity_id: light.living_room_1 state: 'on' - condition: state - entity_id: light.dining_table_overhead + entity_id: light.living_room_2 state: 'on' - condition: state - entity_id: light.uplighter + entity_id: light.dining_nook state: 'on' action: - service: light.turn_off entity_id: - - light.sofa_overhead - - light.dining_table_overhead - - light.uplighter - - light.sideboard + - light.living_room + - light.dining_nook diff --git a/automation/mark_person_as_arrived.yaml b/automation/mark_person_as_arrived.yaml index cc4ede3b5..23f3aa12f 100644 --- a/automation/mark_person_as_arrived.yaml +++ b/automation/mark_person_as_arrived.yaml @@ -33,6 +33,6 @@ action: "A resident has just arrived!" ] | random + " http://amzn.to/2D3J8jW" }} - - service: notify.twitter_thegordonhome + - service: notify.mastodon_viewpoint data: message: "A resident has just arrived!" diff --git a/automation/master_bedroom_double_toggle.yaml b/automation/master_bedroom_double_toggle.yaml deleted file mode 100644 index 81cf83f67..000000000 --- a/automation/master_bedroom_double_toggle.yaml +++ /dev/null @@ -1,18 +0,0 @@ -alias: Master Bedroom light double toggle -trigger: - platform: mqtt - topic: devices/60019485376d/button/event - # Optional - payload: 'DOUBLE' - -action: - - service: switch.turn_on - entity_id: switch.master_bedroom_lightswitch_relay - - service: light.toggle - entity_id: light.master_bedroom - - service: light.toggle - entity_id: light.ensuite - - service: logbook.log - data_template: - name: EVENT - message: "Double master bedroom light" diff --git a/automation/master_bedroom_touch_toggle.yaml b/automation/master_bedroom_touch_toggle.yaml deleted file mode 100644 index 1e23e063b..000000000 --- a/automation/master_bedroom_touch_toggle.yaml +++ /dev/null @@ -1,15 +0,0 @@ -alias: Master Bedroom light toggle -trigger: - - platform: state - entity_id: binary_sensor.master_bedroom_switch_touch_pad - to: 'on' - -action: - - service: switch.turn_on - entity_id: switch.master_bedroom_switch_relay - - service: light.toggle - entity_id: light.master_bedroom - - service: logbook.log - data_template: - name: EVENT - message: "Toggling master bedroom light" diff --git a/automation/media_playing_night.yaml b/automation/media_playing_night.yaml index 9345ad21b..4cdc003a7 100644 --- a/automation/media_playing_night.yaml +++ b/automation/media_playing_night.yaml @@ -1,4 +1,6 @@ +--- alias: "Media playing at night" +id: media_playing_night trigger: - platform: state entity_id: media_player.living_room_kodi @@ -8,7 +10,7 @@ condition: entity_id: sensor.average_external_light_level below: 1000 - condition: state - entity_id: binary_sensor.kitchen_motion + entity_id: binary_sensor.kitchen_motion_occupancy state: "off" action: - service: light.turn_off diff --git a/automation/media_stopped_night.yaml b/automation/media_stopped_night.yaml index 0a2e38436..cd0f5c732 100644 --- a/automation/media_stopped_night.yaml +++ b/automation/media_stopped_night.yaml @@ -1,4 +1,6 @@ +--- alias: "Media stopped at night" +id: media_stopped_night trigger: - platform: state entity_id: media_player.living_room_kodi diff --git a/binary_sensors.yaml b/binary_sensors.yaml index a04f71fe1..cc15190ed 100644 --- a/binary_sensors.yaml +++ b/binary_sensors.yaml @@ -1,3 +1,4 @@ +--- # - platform: iss # - platform: aurora @@ -25,7 +26,7 @@ living_room_tv_power: friendly_name: Living Room TV Power device_class: power - value_template: "{{ states('sensor.tv_power_plug_power') | float > 100 }}" + value_template: "{{ states('sensor.tv_power_plug_power') | float(0) > 100 }}" home_occupied: friendly_name: Home Occupied device_class: occupancy @@ -36,29 +37,7 @@ mdi:home-outline {% endif %} value_template: "{{ is_state('person.kyle','home') or is_state('person.charlotte','home') or is_state('person.ronnie','home') }}" - -- platform: mqtt - name: Charlotte Bed Sensor - device_class: occupancy - state_topic: "devices/5ccf7fd8d915/Contact1/state" - payload_on: "1" - payload_off: "0" - -- platform: mqtt - name: Kyle Bed Sensor - device_class: occupancy - state_topic: "devices/5ccf7fd8d915/Contact2/state" - payload_on: "1" - payload_off: "0" - -- platform: mqtt - name: "Kyle Driving" - state_topic: "driving/mode/kyle" - payload_on: "true" - payload_off: "false" - -- platform: mqtt - name: "Charlotte Driving" - state_topic: "driving/mode/charlotte" - payload_on: "true" - payload_off: "false" + holiday_mode: + value_template: "{{ states.calendar.family_calendar.attributes.message == 'Holiday mode' }}" + friendly_name: Holiday mode + unique_id: binary_sensor.holiday_mode diff --git a/cameras.yaml b/cameras.yaml deleted file mode 100644 index 66db0a676..000000000 --- a/cameras.yaml +++ /dev/null @@ -1,42 +0,0 @@ -- platform: generic - name: Kyle - still_image_url: https://maps.googleapis.com/maps/api/staticmap?center={{ states.device_tracker.bagpuss_a0001.attributes.latitude }},{{ states.device_tracker.bagpuss_a0001.attributes.longitude }}&zoom=13&size=500x500&maptype=roadmap&markers=color:blue%7Clabel:P%7C{{ states.device_tracker.bagpuss_a0001.attributes.latitude }},{{ states.device_tracker.bagpuss_a0001.attributes.longitude }} - limit_refetch_to_url_change: true - -- platform: generic - name: Charlotte - still_image_url: https://maps.googleapis.com/maps/api/staticmap?center={{ states.device_tracker.charlotte_thea.attributes.latitude }},{{ states.device_tracker.charlotte_thea.attributes.longitude }}&zoom=13&size=500x500&maptype=roadmap&markers=color:blue%7Clabel:P%7C{{ states.device_tracker.charlotte_thea.attributes.latitude }},{{ states.device_tracker.charlotte_thea.attributes.longitude }} - limit_refetch_to_url_change: true - -# - platform: generic -# name: ISS -# still_image_url: https://maps.googleapis.com/maps/api/staticmap?center={{ states.binary_sensor.iss.attributes.lat }},{{ states.binary_sensor.iss.attributes.long }}&zoom=5&size=500x500&maptype=roadmap&markers=color:blue%7Clabel:P%7C{{ states.binary_sensor.iss.attributes.lat }},{{ states.binary_sensor.iss.attributes.long }} -# limit_refetch_to_url_change: true - -- platform: generic - name: BackDoor - still_image_url: http://viewpoint.house:4999/api/back_door/latest.jpg?h=400&motion=1 - stream_source: rtmp://viewpoint.house/live/back_door - -- platform: generic - name: Driveway - still_image_url: http://viewpoint.house:4999/api/driveway/latest.jpg?h=400&motion=1 - stream_source: rtmp://viewpoint.house/live/driveway - -- platform: generic - name: FrontDoor - still_image_url: http://viewpoint.house:4999/api/front_door/latest.jpg?h=400&motion=1 - stream_source: rtmp://viewpoint.house/live/front_door - -- name: Back Door Last Person - platform: mqtt - topic: frigate/back_door/person/snapshot -- name: Front Door Last Person - platform: mqtt - topic: frigate/front_door/person/snapshot -- name: Driveway Last Person - platform: mqtt - topic: frigate/driveway/person/snapshot -- name: Driveway Last Car - platform: mqtt - topic: frigate/driveway/car/snapshot diff --git a/climate.yaml b/climate.yaml index e14bcccc3..c1ccc5a67 100644 --- a/climate.yaml +++ b/climate.yaml @@ -5,6 +5,8 @@ min_temp: 10 max_temp: 25 away_temp: 15 + cold_tolerance: 0.2 + hot_tolerance: 0.2 - platform: generic_thermostat name: Hot Water diff --git a/configuration.yaml b/configuration.yaml index dca899f97..904c845fa 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -1,3 +1,4 @@ +--- # Inspired by https://github.com/bruhautomation/BRUH2-Home-Assistant-Configuration homeassistant: @@ -8,6 +9,7 @@ homeassistant: trusted_networks: - 172.24.32.0/24 - 172.24.34.0/24 + - 172.18.0.0/24 - 10.8.0.18/32 - 127.0.0.1 - type: legacy_api_password @@ -27,6 +29,8 @@ homeassistant: unit_system: metric # Pick yours from here: http://en.wikipedia.org/wiki/List_of_tz_database_time_zones time_zone: Europe/London + country: GB + currency: GBP # Includes packages: !include_dir_named packages @@ -62,7 +66,7 @@ homeassistant: assumed_state: false light.arc_lamp: assumed_state: false - light.utility_room: + light.front_hall: assumed_state: false group.bedside_lights: icon: mdi:hotel @@ -87,62 +91,22 @@ config: recorder: !include recorder.yaml http: + use_x_forwarded_for: true + trusted_proxies: + - 172.18.0.0/24 + - 127.0.0.1 + - ::1 cors_allowed_origins: - http://homeauto.vpn.glasgownet.com - http://viewpoint.house stream: -konnected: - access_token: !secret konnected_key - devices: - - id: bcddc2fdd1b2 - - id: dc4f22199839 - binary_sensors: - - zone: 1 - type: window - name: 'Ensuite window' - - zone: 2 - type: window - name: 'Craft room window' - - zone: 3 - type: window - name: 'Master bedroom window' - - zone: 4 - type: window - name: 'Guest bedroom window' - - zone: 5 - type: window - name: 'Study window' - - id: dc4f22195dec - binary_sensors: - - zone: 1 - type: window - name: 'Kitchen window' - - zone: 2 - type: door - name: 'Back door' - - zone: 3 - type: door - name: 'Patio door' - - zone: 4 - type: window - name: 'Bathroom window' - - zone: 5 - type: window - name: 'Dining table window' - alarm_control_panel: - platform: manual - -deconz: - host: 172.24.32.13 - port: 8100 - -google: - client_id: !secret google_oauth_client_id - client_secret: !secret google_oauth_client_secret - track_new_calendar: false + name: Home Alarm + code_arm_required: false + code: !secret alarm_code influxdb: host: 172.24.32.13 @@ -153,67 +117,6 @@ influxdb: bucket: !secret influxdb_bucket token: !secret influxdb_token -# Discover some devices automatically -discovery: - -spotify: - client_id: !secret spotify_client_id - client_secret: !secret spotify_client_secret - -rfxtrx: - # Remember this is mapped in the docker-compose file - device: /dev/ttyUSB0 - devices: - ### Lights ### - # 0a140029f0dd3c0e010060: - # name: Living Room - # 0a140017f090a201010070: - # # Dimmable CFL Remote Left - # name: Utility Room - 0a140001f0dd3c01010070: - # White Remote A1 - # name: Christmas Star - 0a140075f0dd3c10000060: - # Black Remote D4 - 0a14000bf0dd3c0f010070: - # name: Lava Lamp - 0a140037f0dd3c0b010060: - # name: Christmas Tree - 0a14003cf0dd3c0a010060: - # name: Craft room fairy lights - 0710080041020000: - # name: Star Burst - 0710080041030000: - # name: Energenie 4 way - 0710080041040000: - # name: Energenie 4 way - 0a140041f0dd3c09010070: - # name: Arc Lamp - 0a14005ff0dd3c05010060: - # name: Energenie 4 way - ### Switches ### - 0710080041010000: - # name: Cordless phone - ### Binary Sensors ### - 0b1100d800b8196e0a010f70: - # name: Outside Driveway Motion - device_class: motion - 0b11000000b82b560a000060: - # name: Outside Front Motion - device_class: motion - # 0a1400002c22a70a010060: - # name: Utility Room Door - # device_class: opening - ### Sensors ### - ## RFXcom ## - # Old 115a0100e452000000046600006462cb3e79: - 115a0102e20200000003e500000000d02979: - # name: Electricity - -# zwave: -# usb_path: /dev/zwave -# debug: false - # Allows you to issue voice commands from the frontend in enabled browsers conversation: @@ -244,12 +147,12 @@ emulated_hue: # https://www.home-assistant.io/components/emulated_hue/ listen_port: 80 expose_by_default: false - exposed_domains: - - switch - - light - - group - - input_boolean - - media_player + # exposed_domains: + # - switch + # - light + # - group + # - input_boolean + # - media_player entities: group.lightswitch_relays: hidden: false @@ -259,7 +162,7 @@ emulated_hue: hidden: false group.outside_lights: hidden: false - group.hall: + light.hall: hidden: false group.living_room_lights: hidden: false @@ -283,11 +186,7 @@ emulated_hue: hidden: false light.sideboard: hidden: false - light.uplighter: - hidden: false - light.sofa_overhead: - hidden: false - light.dining_table_overhead: + light.living_room: hidden: false light.star_burst: hidden: false @@ -305,14 +204,23 @@ emulated_hue: hidden: false input_boolean.goodnight_process: hidden: false + input_boolean.lockup_process: + hidden: false + input_boolean.open_tin_hut_process: + hidden: false input_boolean.night_view: hidden: false + script.alexa_turn_on_the_lights: + hidden: false + script.alexa_turn_off_the_lights: + hidden: false ############ # Includes # ############ # automation: !include automation.yaml # script: !include scripts.yaml +mqtt: !include mqtt.yaml sensor: !include sensors.yaml binary_sensor: !include binary_sensors.yaml light: !include lights.yaml @@ -320,11 +228,9 @@ switch: !include switches.yaml media_player: !include media_players.yaml device_tracker: !include device_trackers.yaml group: !include groups.yaml -camera: !include cameras.yaml zone: !include zones/places.yaml shell_command: !include shell_commands.yaml climate: !include climate.yaml -# image_processing: !include openalpr.yaml # Merged Includes automation: !include_dir_list automation @@ -335,68 +241,60 @@ input_select: !include_dir_named input_select input_boolean: !include_dir_named input_boolean script: !include_dir_merge_named scripts/ -mqtt: - broker: 172.24.32.13 - port: 1883 - client_id: home-assistant-1 - discovery: true - discovery_prefix: "homeassistant" - keepalive: 60 - protocol: 3.1 - birth_message: - topic: 'hass/status' - payload: 'online' - qos: 1 - retain: true - will_message: - topic: 'hass/status' - payload: 'offline' - qos: 1 - retain: true - utility_meter: + quarter_hourly_energy: + source: sensor.energy_spent + cycle: quarter-hourly + hourly_energy: + source: sensor.energy_spent + cycle: hourly daily_energy: source: sensor.energy_spent cycle: daily monthly_energy: source: sensor.energy_spent cycle: monthly + quarter_hourly_energy_tv: + source: sensor.energy_spent_tv + cycle: quarter-hourly + hourly_energy_tv: + source: sensor.energy_spent_tv + cycle: hourly + daily_energy_tv: + source: sensor.energy_spent_tv + cycle: daily + monthly_energy_tv: + source: sensor.energy_spent_tv + cycle: monthly smartir: -octoprint: - host: !secret octoprint_host - port: 88 - api_key: !secret octoprint_key - bed: true - number_of_tools: 1 - panel_iframe: esphome: title: "ESPHome" - url: "http://viewpoint.house:6052" + url: "https://esphome.viewpoint.house" icon: mdi:car-esp - unifi: - title: "Unifi CCTV" - url: "http://viewpoint.house:7080" + zigbee2mqtt: + title: "Z2M" + url: "https://z2m.viewpoint.house" + icon: mdi:antenna + cctv: + title: "CCTV" + url: "https://cctv.viewpoint.house" icon: mdi:cctv sonarr: - title: "Sonarr TV" - url: "http://viewpoint.house:8989/sonarr" + title: "TV Shows" + url: "https://tv.viewpoint.house" icon: mdi:television-classic radarr: - title: "Radar Movies" - url: "http://viewpoint.house:7878/radarr" + title: "Movies" + url: "https://movies.viewpoint.house" icon: mdi:movie-open lazylibrarian: - title: "LazyLibrarian Books" - url: "http://viewpoint.house:5299" + title: "Books" + url: "https://books.viewpoint.house" icon: mdi:book-open-page-variant - airsonic: - title: "AirSonic Music" - url: "http://viewpoint.house:4040/index" - icon: mdi:music-circle-outline - nzbget: - title: "NZBGet Downloader" - url: "http://viewpoint.house:6789" - icon: mdi:cloud-download-outline + homepage: + title: "Home Page" + url: "https://home.viewpoint.house" + icon: mdi:home diff --git a/custom_components/adaptive_lighting/__init__.py b/custom_components/adaptive_lighting/__init__.py new file mode 100644 index 000000000..98c2e94f9 --- /dev/null +++ b/custom_components/adaptive_lighting/__init__.py @@ -0,0 +1,99 @@ +"""Adaptive Lighting integration in Home-Assistant.""" +import logging +from typing import Any + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_SOURCE +from homeassistant.core import HomeAssistant + +from .const import ( + _DOMAIN_SCHEMA, + ATTR_ADAPTIVE_LIGHTING_MANAGER, + CONF_NAME, + DOMAIN, + UNDO_UPDATE_LISTENER, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["switch"] + + +def _all_unique_names(value): + """Validate that all entities have a unique profile name.""" + hosts = [device[CONF_NAME] for device in value] + schema = vol.Schema(vol.Unique()) + schema(hosts) + return value + + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [_DOMAIN_SCHEMA], _all_unique_names)}, + extra=vol.ALLOW_EXTRA, +) + + +async def reload_configuration_yaml(event: dict, hass: HomeAssistant): # noqa: ARG001 + """Reload configuration.yaml.""" + await hass.services.async_call("homeassistant", "check_config", {}) + + +async def async_setup(hass: HomeAssistant, config: dict[str, Any]): + """Import integration from config.""" + if DOMAIN in config: + for entry in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=entry, + ), + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Set up the component.""" + data = hass.data.setdefault(DOMAIN, {}) + + # This will reload any changes the user made to any YAML configurations. + # Called during 'quick reload' or hass.reload_config_entry + hass.bus.async_listen("hass.config.entry_updated", reload_configuration_yaml) + + undo_listener = config_entry.add_update_listener(async_update_options) + data[config_entry.entry_id] = {UNDO_UPDATE_LISTENER: undo_listener} + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, platform), + ) + + return True + + +async def async_update_options(hass, config_entry: ConfigEntry): + """Update options.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_unload_entry(hass, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_forward_entry_unload( + config_entry, + "switch", + ) + data = hass.data[DOMAIN] + data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() + if unload_ok: + data.pop(config_entry.entry_id) + + if len(data) == 1 and ATTR_ADAPTIVE_LIGHTING_MANAGER in data: + # no more config_entries + manager = data.pop(ATTR_ADAPTIVE_LIGHTING_MANAGER) + manager.disable() + + if not data: + hass.data.pop(DOMAIN) + + return unload_ok diff --git a/custom_components/adaptive_lighting/_docs_helpers.py b/custom_components/adaptive_lighting/_docs_helpers.py new file mode 100644 index 000000000..31225a6c9 --- /dev/null +++ b/custom_components/adaptive_lighting/_docs_helpers.py @@ -0,0 +1,117 @@ +from typing import Any + +import homeassistant.helpers.config_validation as cv +import pandas as pd +import voluptuous as vol +from homeassistant.helpers import selector + +from .const import ( + DOCS, + DOCS_APPLY, + DOCS_MANUAL_CONTROL, + SET_MANUAL_CONTROL_SCHEMA, + VALIDATION_TUPLES, + apply_service_schema, +) + + +def _format_voluptuous_instance(instance): + coerce_type = None + min_val = None + max_val = None + + for validator in instance.validators: + if isinstance(validator, vol.Coerce): + coerce_type = validator.type.__name__ + elif isinstance(validator, vol.Clamp | vol.Range): + min_val = validator.min + max_val = validator.max + + if min_val is not None and max_val is not None: + return f"`{coerce_type}` {min_val}-{max_val}" + if min_val is not None: + return f"`{coerce_type} > {min_val}`" + if max_val is not None: + return f"`{coerce_type} < {max_val}`" + return f"`{coerce_type}`" + + +def _type_to_str(type_: Any) -> str: # noqa: PLR0911 + """Convert a (voluptuous) type to a string.""" + if type_ == cv.entity_ids: + return "list of `entity_id`s" + if type_ in (bool, int, float, str): + return f"`{type_.__name__}`" + if type_ == cv.boolean: + return "bool" + if isinstance(type_, vol.All): + return _format_voluptuous_instance(type_) + if isinstance(type_, vol.In): + return f"one of `{type_.container}`" + if isinstance(type_, selector.SelectSelector): + return f"one of `{type_.config['options']}`" + if isinstance(type_, selector.ColorRGBSelector): + return "RGB color" + msg = f"Unknown type: {type_}" + raise ValueError(msg) + + +def generate_config_markdown_table(): + import pandas as pd + + rows = [] + for k, default, type_ in VALIDATION_TUPLES: + description = DOCS[k] + row = { + "Variable name": f"`{k}`", + "Description": description, + "Default": f"`{default}`", + "Type": _type_to_str(type_), + } + rows.append(row) + + df = pd.DataFrame(rows) + return df.to_markdown(index=False) + + +def _schema_to_dict(schema: vol.Schema) -> dict[str, tuple[Any, Any]]: + result = {} + for key, value in schema.schema.items(): + if isinstance(key, vol.Optional): + default_value = key.default + result[key.schema] = (default_value, value) + return result + + +def _generate_service_markdown_table( + schema: dict[str, tuple[Any, Any]], + alternative_docs: dict[str, str] | None = None, +): + schema = _schema_to_dict(schema) + rows = [] + for k, (default, type_) in schema.items(): + if alternative_docs is not None and k in alternative_docs: + description = alternative_docs[k] + else: + description = DOCS[k] + row = { + "Service data attribute": f"`{k}`", + "Description": description, + "Required": "✅" if default == vol.UNDEFINED else "❌", + "Type": _type_to_str(type_), + } + rows.append(row) + + df = pd.DataFrame(rows) + return df.to_markdown(index=False) + + +def generate_apply_markdown_table(): + return _generate_service_markdown_table(apply_service_schema(), DOCS_APPLY) + + +def generate_set_manual_control_markdown_table(): + return _generate_service_markdown_table( + SET_MANUAL_CONTROL_SCHEMA, + DOCS_MANUAL_CONTROL, + ) diff --git a/custom_components/adaptive_lighting/adaptation_utils.py b/custom_components/adaptive_lighting/adaptation_utils.py new file mode 100644 index 000000000..593a746b0 --- /dev/null +++ b/custom_components/adaptive_lighting/adaptation_utils.py @@ -0,0 +1,231 @@ +"""Utility functions for adaptation commands.""" +import logging +from collections.abc import AsyncGenerator +from dataclasses import dataclass +from typing import Any, Literal + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, + ATTR_COLOR_NAME, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, + ATTR_TRANSITION, + ATTR_XY_COLOR, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import Context, HomeAssistant, State + +_LOGGER = logging.getLogger(__name__) + +COLOR_ATTRS = { # Should ATTR_PROFILE be in here? + ATTR_COLOR_NAME, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_XY_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, +} + + +BRIGHTNESS_ATTRS = { + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, +} + +ServiceData = dict[str, Any] + + +def _split_service_call_data(service_data: ServiceData) -> list[ServiceData]: + """Splits the service data by the adapted attributes. + + i.e., into separate data items for brightness and color. + """ + common_attrs = {ATTR_ENTITY_ID} + common_data = {k: service_data[k] for k in common_attrs if k in service_data} + + attributes_split_sequence = [BRIGHTNESS_ATTRS, COLOR_ATTRS] + service_datas = [] + + for attributes in attributes_split_sequence: + split_data = { + attribute: service_data[attribute] + for attribute in attributes + if service_data.get(attribute) + } + if split_data: + service_datas.append(common_data | split_data) + + # Distribute the transition duration across all service calls + if service_datas and (transition := service_data.get(ATTR_TRANSITION)) is not None: + transition /= len(service_datas) + + for service_data in service_datas: + service_data[ATTR_TRANSITION] = transition + + return service_datas + + +def _remove_redundant_attributes( + service_data: ServiceData, + state: State, +) -> ServiceData: + """Filter service data by removing attributes that already equal the given state. + + Removes all attributes from service call data whose values are already present + in the target entity's state. + """ + return { + k: v + for k, v in service_data.items() + if k not in state.attributes or v != state.attributes[k] + } + + +def _has_relevant_service_data_attributes(service_data: ServiceData) -> bool: + """Determines whether the service data justifies an adaptation service call. + + A service call is not justified for data which does not contain any entries that + change relevant attributes of an adapting entity, e.g., brightness or color. + """ + common_attrs = {ATTR_ENTITY_ID, ATTR_TRANSITION} + + return any(attr not in common_attrs for attr in service_data) + + +async def _create_service_call_data_iterator( + hass: HomeAssistant, + service_datas: list[ServiceData], + filter_by_state: bool, +) -> AsyncGenerator[ServiceData, None]: + """Enumerates and filters a list of service datas on the fly. + + If filtering is enabled, every service data is filtered by the current state of + the related entity and only returned if it contains relevant data that justifies + a service call. + The main advantage of this generator over a list is that it applies the filter + at the time when the service data is read instead of up front. This gives greater + flexibility because entity states can change while the items are iterated. + """ + for service_data in service_datas: + if filter_by_state and (entity_id := service_data.get(ATTR_ENTITY_ID)): + current_entity_state = hass.states.get(entity_id) + + # Filter data to remove attributes that equal the current state + if current_entity_state is not None: + service_data = _remove_redundant_attributes( # noqa: PLW2901 + service_data, + state=current_entity_state, + ) + + # Emit service data if it still contains relevant attributes (else try next) + if _has_relevant_service_data_attributes(service_data): + yield service_data + else: + yield service_data + + +@dataclass +class AdaptationData: + """Holds all data required to execute an adaptation.""" + + entity_id: str + context: Context + sleep_time: float + service_call_datas: AsyncGenerator[ServiceData, None] + force: bool + max_length: int + which: Literal["brightness", "color", "both"] + initial_sleep: bool = False + + async def next_service_call_data(self) -> ServiceData | None: + """Return data for the next service call, or none if no more data exists.""" + return await anext(self.service_call_datas, None) + + def __str__(self) -> str: + """Return a string representation of the data.""" + return ( + f"{self.__class__.__name__}(" + f"entity_id={self.entity_id}, " + f"context_id={self.context.id}, " + f"sleep_time={self.sleep_time}, " + f"force={self.force}, " + f"max_length={self.max_length}, " + f"which={self.which}, " + f"initial_sleep={self.initial_sleep}" + ")" + ) + + +class NoColorOrBrightnessInServiceDataError(Exception): + """Exception raised when no color or brightness attributes are found in service data.""" + + +def _identify_lighting_type( + service_data: ServiceData, +) -> Literal["brightness", "color", "both"]: + """Extract the 'which' attribute from the service data.""" + has_brightness = ATTR_BRIGHTNESS in service_data + has_color = any(attr in service_data for attr in COLOR_ATTRS) + if has_brightness and has_color: + return "both" + if has_brightness: + return "brightness" + if has_color: + return "color" + msg = f"Invalid service_data, no brightness or color attributes found: {service_data=}" + raise NoColorOrBrightnessInServiceDataError(msg) + + +def prepare_adaptation_data( + hass: HomeAssistant, + entity_id: str, + context: Context, + transition: float | None, + split_delay: float, + service_data: ServiceData, + split: bool, + filter_by_state: bool, + force: bool, +) -> AdaptationData: + """Prepares a data object carrying all data required to execute an adaptation.""" + _LOGGER.debug( + "Preparing adaptation data for %s with service data %s", + entity_id, + service_data, + ) + service_datas = _split_service_call_data(service_data) if split else [service_data] + + service_datas_length = len(service_datas) + + if transition is not None: + transition_duration_per_data = transition / max(1, service_datas_length) + sleep_time = transition_duration_per_data + split_delay + else: + sleep_time = split_delay + + service_data_iterator = _create_service_call_data_iterator( + hass, + service_datas, + filter_by_state, + ) + + lighting_type = _identify_lighting_type(service_data) + + return AdaptationData( + entity_id=entity_id, + context=context, + sleep_time=sleep_time, + service_call_datas=service_data_iterator, + force=force, + max_length=service_datas_length, + which=lighting_type, + ) diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py new file mode 100644 index 000000000..2968fcc0b --- /dev/null +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -0,0 +1,518 @@ +"""Switch for the Adaptive Lighting integration.""" +from __future__ import annotations + +import bisect +import colorsys +import datetime +import logging +import math +from dataclasses import dataclass +from datetime import timedelta +from functools import cached_property, partial +from typing import TYPE_CHECKING, Any, Literal, cast + +from homeassistant.util.color import ( + color_RGB_to_xy, + color_temperature_to_rgb, + color_xy_to_hs, +) + +if TYPE_CHECKING: + import astral + +# Same as homeassistant.const.SUN_EVENT_SUNRISE and homeassistant.const.SUN_EVENT_SUNSET +# We re-define them here to not depend on homeassistant in this file. +SUN_EVENT_SUNRISE = "sunrise" +SUN_EVENT_SUNSET = "sunset" + +SUN_EVENT_NOON = "solar_noon" +SUN_EVENT_MIDNIGHT = "solar_midnight" + +_ORDER = (SUN_EVENT_SUNRISE, SUN_EVENT_NOON, SUN_EVENT_SUNSET, SUN_EVENT_MIDNIGHT) +_ALLOWED_ORDERS = {_ORDER[i:] + _ORDER[:i] for i in range(len(_ORDER))} + +UTC = datetime.timezone.utc +utcnow: partial[datetime.datetime] = partial(datetime.datetime.now, UTC) +utcnow.__doc__ = "Get now in UTC time." + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class SunEvents: + """Track the state of the sun and associated light settings.""" + + name: str + astral_location: astral.Location + sunrise_time: datetime.time | None + min_sunrise_time: datetime.time | None + max_sunrise_time: datetime.time | None + sunset_time: datetime.time | None + min_sunset_time: datetime.time | None + max_sunset_time: datetime.time | None + sunrise_offset: datetime.timedelta = datetime.timedelta() + sunset_offset: datetime.timedelta = datetime.timedelta() + timezone: datetime.tzinfo = UTC + + def sunrise(self, dt: datetime.date) -> datetime.datetime: + """Return the (adjusted) sunrise time for the given datetime.""" + sunrise = ( + self.astral_location.sunrise(dt, local=False) + if self.sunrise_time is None + else self._replace_time(dt, self.sunrise_time) + ) + self.sunrise_offset + if self.min_sunrise_time is not None: + min_sunrise = self._replace_time(dt, self.min_sunrise_time) + if min_sunrise > sunrise: + sunrise = min_sunrise + if self.max_sunrise_time is not None: + max_sunrise = self._replace_time(dt, self.max_sunrise_time) + if max_sunrise < sunrise: + sunrise = max_sunrise + return sunrise + + def sunset(self, dt: datetime.date) -> datetime.datetime: + """Return the (adjusted) sunset time for the given datetime.""" + sunset = ( + self.astral_location.sunset(dt, local=False) + if self.sunset_time is None + else self._replace_time(dt, self.sunset_time) + ) + self.sunset_offset + if self.min_sunset_time is not None: + min_sunset = self._replace_time(dt, self.min_sunset_time) + if min_sunset > sunset: + sunset = min_sunset + if self.max_sunset_time is not None: + max_sunset = self._replace_time(dt, self.max_sunset_time) + if max_sunset < sunset: + sunset = max_sunset + return sunset + + def _replace_time( + self, + dt: datetime.date, + time: datetime.time, + ) -> datetime.datetime: + date_time = datetime.datetime.combine(dt, time) + dt_with_tz = date_time.replace(tzinfo=self.timezone) + return dt_with_tz.astimezone(UTC) + + def noon_and_midnight( + self, + dt: datetime.datetime, + sunset: datetime.datetime | None = None, + sunrise: datetime.datetime | None = None, + ) -> tuple[datetime.datetime, datetime.datetime]: + """Return the (adjusted) noon and midnight times for the given datetime.""" + if ( + self.sunrise_time is None + and self.sunset_time is None + and self.min_sunrise_time is None + and self.max_sunrise_time is None + and self.min_sunset_time is None + and self.max_sunset_time is None + ): + solar_noon = self.astral_location.noon(dt, local=False) + solar_midnight = self.astral_location.midnight(dt, local=False) + return solar_noon, solar_midnight + + if sunset is None: + sunset = self.sunset(dt) + if sunrise is None: + sunrise = self.sunrise(dt) + + middle = abs(sunset - sunrise) / 2 + if sunset > sunrise: + noon = sunrise + middle + midnight = noon + timedelta(hours=12) * (1 if noon.hour < 12 else -1) + else: + midnight = sunset + middle + noon = midnight + timedelta(hours=12) * (1 if midnight.hour < 12 else -1) + return noon, midnight + + def sun_events(self, dt: datetime.datetime) -> list[tuple[str, float]]: + """Get the four sun event's timestamps at 'dt'.""" + sunrise = self.sunrise(dt) + sunset = self.sunset(dt) + solar_noon, solar_midnight = self.noon_and_midnight(dt, sunset, sunrise) + events = [ + (SUN_EVENT_SUNRISE, sunrise.timestamp()), + (SUN_EVENT_SUNSET, sunset.timestamp()), + (SUN_EVENT_NOON, solar_noon.timestamp()), + (SUN_EVENT_MIDNIGHT, solar_midnight.timestamp()), + ] + self._validate_sun_event_order(events) + return events + + def _validate_sun_event_order(self, events: list[tuple[str, float]]) -> None: + """Check if the sun events are in the expected order.""" + events = sorted(events, key=lambda x: x[1]) + events_names, _ = zip(*events, strict=True) + if events_names not in _ALLOWED_ORDERS: + msg = ( + f"{self.name}: The sun events {events_names} are not in the expected" + " order. The Adaptive Lighting integration will not work!" + " This might happen if your sunrise/sunset offset is too large or" + " your manually set sunrise/sunset time is past/before noon/midnight." + ) + _LOGGER.error(msg) + raise ValueError(msg) + + def prev_and_next_events(self, dt: datetime.datetime) -> list[tuple[str, float]]: + """Get the previous and next sun event.""" + events = [ + event + for days in [-1, 0, 1] + for event in self.sun_events(dt + timedelta(days=days)) + ] + events = sorted(events, key=lambda x: x[1]) + i_now = bisect.bisect([ts for _, ts in events], dt.timestamp()) + return events[i_now - 1 : i_now + 1] + + def sun_position(self, dt: datetime.datetime) -> float: + """Calculate the position of the sun, between [-1, 1].""" + target_ts = dt.timestamp() + (_, prev_ts), (next_event, next_ts) = self.prev_and_next_events(dt) + h, x = ( + (prev_ts, next_ts) + if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE) + else (next_ts, prev_ts) + ) + # k = -1 between sunset and sunrise (sun below horizon) + # k = 1 between sunrise and sunset (sun above horizon) + k = 1 if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_NOON) else -1 + return k * (1 - ((target_ts - h) / (h - x)) ** 2) + + def closest_event(self, dt: datetime.datetime) -> tuple[str, float]: + """Get the closest sunset or sunrise event.""" + (prev_event, prev_ts), (next_event, next_ts) = self.prev_and_next_events(dt) + if prev_event == SUN_EVENT_SUNRISE or next_event == SUN_EVENT_SUNRISE: + ts_event = prev_ts if prev_event == SUN_EVENT_SUNRISE else next_ts + return SUN_EVENT_SUNRISE, ts_event + if prev_event == SUN_EVENT_SUNSET or next_event == SUN_EVENT_SUNSET: + ts_event = prev_ts if prev_event == SUN_EVENT_SUNSET else next_ts + return SUN_EVENT_SUNSET, ts_event + msg = "No sunrise or sunset event found." + raise ValueError(msg) + + +@dataclass(frozen=True) +class SunLightSettings: + """Track the state of the sun and associated light settings.""" + + name: str + astral_location: astral.Location + adapt_until_sleep: bool + max_brightness: int + max_color_temp: int + min_brightness: int + min_color_temp: int + sleep_brightness: int + sleep_rgb_or_color_temp: Literal["color_temp", "rgb_color"] + sleep_color_temp: int + sleep_rgb_color: tuple[int, int, int] + sunrise_time: datetime.time | None + min_sunrise_time: datetime.time | None + max_sunrise_time: datetime.time | None + sunset_time: datetime.time | None + min_sunset_time: datetime.time | None + max_sunset_time: datetime.time | None + brightness_mode_time_dark: datetime.timedelta + brightness_mode_time_light: datetime.timedelta + brightness_mode: Literal["default", "linear", "tanh"] = "default" + sunrise_offset: datetime.timedelta = datetime.timedelta() + sunset_offset: datetime.timedelta = datetime.timedelta() + timezone: datetime.tzinfo = UTC + + @cached_property + def sun(self) -> SunEvents: + """Return the SunEvents object.""" + return SunEvents( + name=self.name, + astral_location=self.astral_location, + sunrise_time=self.sunrise_time, + sunrise_offset=self.sunrise_offset, + min_sunrise_time=self.min_sunrise_time, + max_sunrise_time=self.max_sunrise_time, + sunset_time=self.sunset_time, + sunset_offset=self.sunset_offset, + min_sunset_time=self.min_sunset_time, + max_sunset_time=self.max_sunset_time, + timezone=self.timezone, + ) + + def _brightness_pct_default(self, dt: datetime.datetime) -> float: + """Calculate the brightness percentage using the default method.""" + sun_position = self.sun.sun_position(dt) + if sun_position > 0: + return self.max_brightness + delta_brightness = self.max_brightness - self.min_brightness + return (delta_brightness * (1 + sun_position)) + self.min_brightness + + def _brightness_pct_tanh(self, dt: datetime.datetime) -> float: + event, ts_event = self.sun.closest_event(dt) + dark = self.brightness_mode_time_dark.total_seconds() + light = self.brightness_mode_time_light.total_seconds() + if event == SUN_EVENT_SUNRISE: + brightness = scaled_tanh( + dt.timestamp() - ts_event, + x1=-dark, + x2=+light, + y1=0.05, # be at 5% of range at x1 + y2=0.95, # be at 95% of range at x2 + y_min=self.min_brightness, + y_max=self.max_brightness, + ) + elif event == SUN_EVENT_SUNSET: + brightness = scaled_tanh( + dt.timestamp() - ts_event, + x1=-light, # shifted timestamp for the start of sunset + x2=+dark, # shifted timestamp for the end of sunset + y1=0.95, # be at 95% of range at the start of sunset + y2=0.05, # be at 5% of range at the end of sunset + y_min=self.min_brightness, + y_max=self.max_brightness, + ) + return clamp(brightness, self.min_brightness, self.max_brightness) + + def _brightness_pct_linear(self, dt: datetime.datetime) -> float: + event, ts_event = self.sun.closest_event(dt) + # at ts_event - dt_start, brightness == start_brightness + # at ts_event + dt_end, brightness == end_brightness + dark = self.brightness_mode_time_dark.total_seconds() + light = self.brightness_mode_time_light.total_seconds() + if event == SUN_EVENT_SUNRISE: + brightness = lerp( + dt.timestamp() - ts_event, + x1=-dark, + x2=+light, + y1=self.min_brightness, + y2=self.max_brightness, + ) + elif event == SUN_EVENT_SUNSET: + brightness = lerp( + dt.timestamp() - ts_event, + x1=-light, + x2=+dark, + y1=self.max_brightness, + y2=self.min_brightness, + ) + return clamp(brightness, self.min_brightness, self.max_brightness) + + def brightness_pct(self, dt: datetime.datetime, is_sleep: bool) -> float: + """Calculate the brightness in %.""" + if is_sleep: + return self.sleep_brightness + assert self.brightness_mode in ("default", "linear", "tanh") + if self.brightness_mode == "default": + return self._brightness_pct_default(dt) + if self.brightness_mode == "linear": + return self._brightness_pct_linear(dt) + if self.brightness_mode == "tanh": + return self._brightness_pct_tanh(dt) + return None + + def color_temp_kelvin(self, sun_position: float) -> int: + """Calculate the color temperature in Kelvin.""" + if sun_position > 0: + delta = self.max_color_temp - self.min_color_temp + ct = (delta * sun_position) + self.min_color_temp + return 5 * round(ct / 5) # round to nearest 5 + if sun_position == 0 or not self.adapt_until_sleep: + return self.min_color_temp + if self.adapt_until_sleep and sun_position < 0: + delta = abs(self.min_color_temp - self.sleep_color_temp) + ct = (delta * abs(1 + sun_position)) + self.sleep_color_temp + return 5 * round(ct / 5) # round to nearest 5 + msg = "Should not happen" + raise ValueError(msg) + + def brightness_and_color( + self, + dt: datetime.datetime, + is_sleep: bool, + ) -> dict[str, Any]: + """Calculate the brightness and color.""" + sun_position = self.sun.sun_position(dt) + rgb_color: tuple[float, float, float] + # Variable `force_rgb_color` is needed for RGB color after sunset (if enabled) + force_rgb_color = False + brightness_pct = self.brightness_pct(dt, is_sleep) + if is_sleep: + color_temp_kelvin = self.sleep_color_temp + rgb_color = self.sleep_rgb_color + elif ( + self.sleep_rgb_or_color_temp == "rgb_color" + and self.adapt_until_sleep + and sun_position < 0 + ): + # Feature requested in + # https://github.com/basnijholt/adaptive-lighting/issues/624 + # This will result in a perceptible jump in color at sunset and sunrise + # because the `color_temperature_to_rgb` function is not 100% accurate. + min_color_rgb = color_temperature_to_rgb(self.min_color_temp) + rgb_color = lerp_color_hsv( + min_color_rgb, + self.sleep_rgb_color, + sun_position, + ) + color_temp_kelvin = self.color_temp_kelvin(sun_position) + force_rgb_color = True + else: + color_temp_kelvin = self.color_temp_kelvin(sun_position) + rgb_color = color_temperature_to_rgb(color_temp_kelvin) + # backwards compatibility for versions < 1.3.1 - see #403 + color_temp_mired: float = math.floor(1000000 / color_temp_kelvin) + xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color) + hs_color: tuple[float, float] = color_xy_to_hs(*xy_color) + return { + "brightness_pct": brightness_pct, + "color_temp_kelvin": color_temp_kelvin, + "color_temp_mired": color_temp_mired, + "rgb_color": rgb_color, + "xy_color": xy_color, + "hs_color": hs_color, + "sun_position": sun_position, + "force_rgb_color": force_rgb_color, + } + + def get_settings( + self, + is_sleep, + transition, + ) -> dict[str, float | int | tuple[float, float] | tuple[float, float, float]]: + """Get all light settings. + + Calculating all values takes <0.5ms. + """ + dt = utcnow() + timedelta(seconds=transition or 0) + return self.brightness_and_color(dt, is_sleep) + + +def find_a_b(x1: float, x2: float, y1: float, y2: float) -> tuple[float, float]: + """Compute the values of 'a' and 'b' for a scaled and shifted tanh function. + + Given two points (x1, y1) and (x2, y2), this function calculates the coefficients 'a' and 'b' + for a tanh function of the form y = 0.5 * (tanh(a * (x - b)) + 1) that passes through these points. + + The derivation is as follows: + + 1. Start with the equation of the tanh function: + y = 0.5 * (tanh(a * (x - b)) + 1) + + 2. Rearrange the equation to isolate tanh: + tanh(a * (x - b)) = 2*y - 1 + + 3. Take the inverse tanh (or artanh) on both sides to solve for 'a' and 'b': + a * (x - b) = artanh(2*y - 1) + + 4. Plug in the points (x1, y1) and (x2, y2) to get two equations. + Using these, we can solve for 'a' and 'b' as: + a = (artanh(2*y2 - 1) - artanh(2*y1 - 1)) / (x2 - x1) + b = x1 - (artanh(2*y1 - 1) / a) + + Parameters + ---------- + x1 + x-coordinate of the first point. + x2 + x-coordinate of the second point. + y1 + y-coordinate of the first point (should be between 0 and 1). + y2 + y-coordinate of the second point (should be between 0 and 1). + + Returns + ------- + a + Coefficient 'a' for the tanh function. + b + Coefficient 'b' for the tanh function. + + Notes + ----- + The values of y1 and y2 should lie between 0 and 1, inclusive. + """ + a = (math.atanh(2 * y2 - 1) - math.atanh(2 * y1 - 1)) / (x2 - x1) + b = x1 - (math.atanh(2 * y1 - 1) / a) + return a, b + + +def scaled_tanh( + x: float, + x1: float, + x2: float, + y1: float = 0.05, + y2: float = 0.95, + y_min: float = 0.0, + y_max: float = 100.0, +) -> float: + """Apply a scaled and shifted tanh function to a given input. + + This function represents a transformation of the tanh function that scales and shifts + the output to lie between y_min and y_max. For values of 'x' close to 'x1' and 'x2' + (used to calculate 'a' and 'b'), the output of this function will be close to 'y_min' + and 'y_max', respectively. + + The equation of the function is as follows: + y = y_min + (y_max - y_min) * 0.5 * (tanh(a * (x - b)) + 1) + + Parameters + ---------- + x + The input to the function. + x1 + x-coordinate of the first point. + x2 + x-coordinate of the second point. + y1 + y-coordinate of the first point (should be between 0 and 1). Defaults to 0.05. + y2 + y-coordinate of the second point (should be between 0 and 1). Defaults to 0.95. + y_min + The minimum value of the output range. Defaults to 0. + y_max + The maximum value of the output range. Defaults to 100. + + Returns + ------- + float: The output of the function, which lies in the range [y_min, y_max]. + """ + a, b = find_a_b(x1, x2, y1, y2) + return y_min + (y_max - y_min) * 0.5 * (math.tanh(a * (x - b)) + 1) + + +def lerp_color_hsv( + rgb1: tuple[float, float, float], + rgb2: tuple[float, float, float], + t: float, +) -> tuple[int, int, int]: + """Linearly interpolate between two RGB colors in HSV color space.""" + t = abs(t) + assert 0 <= t <= 1 + + # Convert RGB to HSV + hsv1 = colorsys.rgb_to_hsv(*[x / 255.0 for x in rgb1]) + hsv2 = colorsys.rgb_to_hsv(*[x / 255.0 for x in rgb2]) + + # Linear interpolation in HSV space + hsv = ( + hsv1[0] + t * (hsv2[0] - hsv1[0]), + hsv1[1] + t * (hsv2[1] - hsv1[1]), + hsv1[2] + t * (hsv2[2] - hsv1[2]), + ) + + # Convert back to RGB + rgb = tuple(int(round(x * 255)) for x in colorsys.hsv_to_rgb(*hsv)) + assert all(0 <= x <= 255 for x in rgb), f"Invalid RGB color: {rgb}" + return cast(tuple[int, int, int], rgb) + + +def lerp(x, x1, x2, y1, y2): + """Linearly interpolate between two values.""" + return y1 + (x - x1) * (y2 - y1) / (x2 - x1) + + +def clamp(value: float, minimum: float, maximum: float) -> float: + """Clamp value between minimum and maximum.""" + return max(minimum, min(value, maximum)) diff --git a/custom_components/adaptive_lighting/config_flow.py b/custom_components/adaptive_lighting/config_flow.py new file mode 100644 index 000000000..8f82582a0 --- /dev/null +++ b/custom_components/adaptive_lighting/config_flow.py @@ -0,0 +1,125 @@ +"""Config flow for Adaptive Lighting integration.""" +import logging + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.core import callback + +from .const import ( # pylint: disable=unused-import + CONF_LIGHTS, + DOMAIN, + EXTRA_VALIDATION, + NONE_STR, + VALIDATION_TUPLES, +) +from .switch import _supported_features, validate + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Adaptive Lighting.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_NAME]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_NAME): str}), + errors=errors, + ) + + async def async_step_import(self, user_input=None): + """Handle configuration by YAML file.""" + await self.async_set_unique_id(user_input[CONF_NAME]) + # Keep a list of switches that are configured via YAML + data = self.hass.data.setdefault(DOMAIN, {}) + data.setdefault("__yaml__", set()).add(self.unique_id) + + for entry in self._async_current_entries(): + if entry.unique_id == self.unique_id: + self.hass.config_entries.async_update_entry(entry, data=user_input) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +def validate_options(user_input, errors): + """Validate the options in the OptionsFlow. + + This is an extra validation step because the validators + in `EXTRA_VALIDATION` cannot be serialized to json. + """ + for key, (_validate, _) in EXTRA_VALIDATION.items(): + # these are unserializable validators + value = user_input.get(key) + try: + if value is not None and value != NONE_STR: + _validate(value) + except vol.Invalid: + _LOGGER.exception("Configuration option %s=%s is incorrect", key, value) + errors["base"] = "option_error" + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for Adaptive Lighting.""" + + 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.""" + conf = self.config_entry + data = validate(conf) + if conf.source == config_entries.SOURCE_IMPORT: + return self.async_show_form(step_id="init", data_schema=None) + errors = {} + if user_input is not None: + validate_options(user_input, errors) + if not errors: + return self.async_create_entry(title="", data=user_input) + + all_lights = [ + light + for light in self.hass.states.async_entity_ids("light") + if _supported_features(self.hass, light) + ] + for configured_light in data[CONF_LIGHTS]: + if configured_light not in all_lights: + errors = {CONF_LIGHTS: "entity_missing"} + _LOGGER.error( + "%s: light entity %s is configured, but was not found", + data[CONF_NAME], + configured_light, + ) + all_lights.append(configured_light) + to_replace = {CONF_LIGHTS: cv.multi_select(sorted(all_lights))} + + options_schema = {} + for name, default, validation in VALIDATION_TUPLES: + key = vol.Optional(name, default=conf.options.get(name, default)) + value = to_replace.get(name, validation) + options_schema[key] = value + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(options_schema), + errors=errors, + ) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py new file mode 100644 index 000000000..cf93cee65 --- /dev/null +++ b/custom_components/adaptive_lighting/const.py @@ -0,0 +1,448 @@ +"""Constants for the Adaptive Lighting integration.""" + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.components.light import VALID_TRANSITION +from homeassistant.const import CONF_ENTITY_ID +from homeassistant.helpers import selector + +ICON_MAIN = "mdi:theme-light-dark" +ICON_BRIGHTNESS = "mdi:brightness-4" +ICON_COLOR_TEMP = "mdi:sun-thermometer" +ICON_SLEEP = "mdi:sleep" + +DOMAIN = "adaptive_lighting" + +DOCS = {CONF_ENTITY_ID: "Entity ID of the switch. 📝"} + + +CONF_NAME, DEFAULT_NAME = "name", "default" +DOCS[CONF_NAME] = "Display name for this switch. 📝" + +CONF_LIGHTS, DEFAULT_LIGHTS = "lights", [] +DOCS[CONF_LIGHTS] = "List of light entity_ids to be controlled (may be empty). 🌟" + +CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES = ( + "detect_non_ha_changes", + False, +) +DOCS[CONF_DETECT_NON_HA_CHANGES] = ( + "Detects and halts adaptations for non-`light.turn_on` state changes. " + "Needs `take_over_control` enabled. 🕵️ " + "Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result " + "in lights turning on unexpectedly. " + "Disable this feature if you encounter such issues." +) + +CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES = ( + "include_config_in_attributes", + False, +) +DOCS[CONF_INCLUDE_CONFIG_IN_ATTRIBUTES] = ( + "Show all options as attributes on the switch in " + "Home Assistant when set to `true`. 📝" +) + +CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1 +DOCS[CONF_INITIAL_TRANSITION] = ( + "Duration of the first transition when lights turn " + "from `off` to `on` in seconds. ⏲️" +) + +CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION = "sleep_transition", 1 +DOCS[ + CONF_SLEEP_TRANSITION +] = 'Duration of transition when "sleep mode" is toggled in seconds. 😴' + +CONF_INTERVAL, DEFAULT_INTERVAL = "interval", 90 +DOCS[CONF_INTERVAL] = "Frequency to adapt the lights, in seconds. 🔄" + +CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS = "max_brightness", 100 +DOCS[CONF_MAX_BRIGHTNESS] = "Maximum brightness percentage. 💡" + +CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP = "max_color_temp", 5500 +DOCS[CONF_MAX_COLOR_TEMP] = "Coldest color temperature in Kelvin. ❄️" + +CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS = "min_brightness", 1 +DOCS[CONF_MIN_BRIGHTNESS] = "Minimum brightness percentage. 💡" + +CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP = "min_color_temp", 2000 +DOCS[CONF_MIN_COLOR_TEMP] = "Warmest color temperature in Kelvin. 🔥" + +CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE = "only_once", False +DOCS[CONF_ONLY_ONCE] = ( + "Adapt lights only when they are turned on (`true`) or keep adapting them " + "(`false`). 🔄" +) + +CONF_ADAPT_ONLY_ON_BARE_TURN_ON, DEFAULT_ADAPT_ONLY_ON_BARE_TURN_ON = ( + "adapt_only_on_bare_turn_on", + False, +) +DOCS[CONF_ADAPT_ONLY_ON_BARE_TURN_ON] = ( + "When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is " + "invoked without specifying color or brightness. ❌🌈 " + "This e.g., prevents adaptation when activating a scene. " + "If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. " + "Needs `take_over_control` enabled. 🕵️ " +) + +CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR = "prefer_rgb_color", False +DOCS[CONF_PREFER_RGB_COLOR] = ( + "Whether to prefer RGB color adjustment over " + "light color temperature when possible. 🌈" +) + +CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS = ( + "separate_turn_on_commands", + False, +) +DOCS[CONF_SEPARATE_TURN_ON_COMMANDS] = ( + "Use separate `light.turn_on` calls for color and brightness, needed for " + "some light types. 🔀" +) + +CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS = "sleep_brightness", 1 +DOCS[CONF_SLEEP_BRIGHTNESS] = "Brightness percentage of lights in sleep mode. 😴" + +CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP = "sleep_color_temp", 1000 +DOCS[CONF_SLEEP_COLOR_TEMP] = ( + "Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is " + "`color_temp`) in Kelvin. 😴" +) + +CONF_SLEEP_RGB_COLOR, DEFAULT_SLEEP_RGB_COLOR = "sleep_rgb_color", [255, 56, 0] +DOCS[ + CONF_SLEEP_RGB_COLOR +] = 'RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈' + +CONF_SLEEP_RGB_OR_COLOR_TEMP, DEFAULT_SLEEP_RGB_OR_COLOR_TEMP = ( + "sleep_rgb_or_color_temp", + "color_temp", +) +DOCS[ + CONF_SLEEP_RGB_OR_COLOR_TEMP +] = 'Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙' + +CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET = "sunrise_offset", 0 +DOCS[ + CONF_SUNRISE_OFFSET +] = "Adjust sunrise time with a positive or negative offset in seconds. ⏰" + +CONF_SUNRISE_TIME = "sunrise_time" +DOCS[CONF_SUNRISE_TIME] = "Set a fixed time (HH:MM:SS) for sunrise. 🌅" + +CONF_MIN_SUNRISE_TIME = "min_sunrise_time" +DOCS[ + CONF_MIN_SUNRISE_TIME +] = "Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅" + +CONF_MAX_SUNRISE_TIME = "max_sunrise_time" +DOCS[CONF_MAX_SUNRISE_TIME] = ( + "Set the latest virtual sunrise time (HH:MM:SS), allowing" + " for earlier sunrises. 🌅" +) + +CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0 +DOCS[ + CONF_SUNSET_OFFSET +] = "Adjust sunset time with a positive or negative offset in seconds. ⏰" + +CONF_SUNSET_TIME = "sunset_time" +DOCS[CONF_SUNSET_TIME] = "Set a fixed time (HH:MM:SS) for sunset. 🌇" + +CONF_MIN_SUNSET_TIME = "min_sunset_time" +DOCS[ + CONF_MIN_SUNSET_TIME +] = "Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇" + +CONF_MAX_SUNSET_TIME = "max_sunset_time" +DOCS[ + CONF_MAX_SUNSET_TIME +] = "Set the latest virtual sunset time (HH:MM:SS), allowing for earlier sunsets. 🌇" + +CONF_BRIGHTNESS_MODE, DEFAULT_BRIGHTNESS_MODE = "brightness_mode", "default" +DOCS[CONF_BRIGHTNESS_MODE] = ( + "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` " + "(uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈" +) +CONF_BRIGHTNESS_MODE_TIME_DARK, DEFAULT_BRIGHTNESS_MODE_TIME_DARK = ( + "brightness_mode_time_dark", + 900, +) +DOCS[CONF_BRIGHTNESS_MODE_TIME_DARK] = ( + "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down " + "the brightness before/after sunrise/sunset. 📈📉" +) +CONF_BRIGHTNESS_MODE_TIME_LIGHT, DEFAULT_BRIGHTNESS_MODE_TIME_LIGHT = ( + "brightness_mode_time_light", + 3600, +) +DOCS[CONF_BRIGHTNESS_MODE_TIME_LIGHT] = ( + "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down " + "the brightness after/before sunrise/sunset. 📈📉." +) + +CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True +DOCS[CONF_TAKE_OVER_CONTROL] = ( + "Disable Adaptive Lighting if another source calls `light.turn_on` while lights " + "are on and being adapted. Note that this calls `homeassistant.update_entity` " + "every `interval`! 🔒" +) + +CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 45 +DOCS[CONF_TRANSITION] = "Duration of transition when lights change, in seconds. 🕑" + +CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP = ( + "transition_until_sleep", + False, +) +DOCS[CONF_ADAPT_UNTIL_SLEEP] = ( + "When enabled, Adaptive Lighting will treat sleep settings as the minimum, " + "transitioning to these values after sunset. 🌙" +) + +CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0 +DOCS[CONF_ADAPT_DELAY] = ( + "Wait time (seconds) between light turn on and Adaptive Lighting applying " + "changes. Might help to avoid flickering. ⏲️" +) + +CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY = "send_split_delay", 0 +DOCS[CONF_SEND_SPLIT_DELAY] = ( + "Delay (ms) between `separate_turn_on_commands` for lights that don't support " + "simultaneous brightness and color setting. ⏲️" +) + +CONF_AUTORESET_CONTROL, DEFAULT_AUTORESET_CONTROL = "autoreset_control_seconds", 0 +DOCS[CONF_AUTORESET_CONTROL] = ( + "Automatically reset the manual control after a number of seconds. " + "Set to 0 to disable. ⏲️" +) + +CONF_SKIP_REDUNDANT_COMMANDS, DEFAULT_SKIP_REDUNDANT_COMMANDS = ( + "skip_redundant_commands", + False, +) +DOCS[CONF_SKIP_REDUNDANT_COMMANDS] = ( + "Skip sending adaptation commands whose target state already " + "equals the light's known state. Minimizes network traffic and improves the " + "adaptation responsivity in some situations. 📉" + "Disable if physical light states get out of sync with HA's recorded state." +) + +CONF_INTERCEPT, DEFAULT_INTERCEPT = "intercept", True +DOCS[CONF_INTERCEPT] = ( + "Intercept and adapt `light.turn_on` calls to enabling instantaneous color " + "and brightness adaptation. 🏎️ Disable for lights that do not " + "support `light.turn_on` with color and brightness." +) + +CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT = ( + "multi_light_intercept", + True, +) +DOCS[CONF_MULTI_LIGHT_INTERCEPT] = ( + "Intercept and adapt `light.turn_on` calls that target multiple lights. ➗" + "⚠️ This might result in splitting up a single `light.turn_on` call " + "into multiple calls, e.g., when lights are in different switches. " + "Requires `intercept` to be enabled." +) + +SLEEP_MODE_SWITCH = "sleep_mode_switch" +ADAPT_COLOR_SWITCH = "adapt_color_switch" +ADAPT_BRIGHTNESS_SWITCH = "adapt_brightness_switch" +ATTR_ADAPTIVE_LIGHTING_MANAGER = "manager" +UNDO_UPDATE_LISTENER = "undo_update_listener" +NONE_STR = "None" +ATTR_ADAPT_COLOR = "adapt_color" +DOCS[ATTR_ADAPT_COLOR] = "Whether to adapt the color on supporting lights. 🌈" +ATTR_ADAPT_BRIGHTNESS = "adapt_brightness" +DOCS[ATTR_ADAPT_BRIGHTNESS] = "Whether to adapt the brightness of the light. 🌞" + +SERVICE_SET_MANUAL_CONTROL = "set_manual_control" +CONF_MANUAL_CONTROL = "manual_control" +DOCS[CONF_MANUAL_CONTROL] = "Whether to manually control the lights. 🔒" +SERVICE_APPLY = "apply" +CONF_TURN_ON_LIGHTS = "turn_on_lights" +DOCS[CONF_TURN_ON_LIGHTS] = "Whether to turn on lights that are currently off. 🔆" +SERVICE_CHANGE_SWITCH_SETTINGS = "change_switch_settings" +CONF_USE_DEFAULTS = "use_defaults" +DOCS[CONF_USE_DEFAULTS] = ( + "Sets the default values not specified in this service call. Options: " + '"current" (default, retains current values), "factory" (resets to ' + 'documented defaults), or "configuration" (reverts to switch config defaults). ⚙️' +) + +TURNING_OFF_DELAY = 5 + +DOCS_MANUAL_CONTROL = { + CONF_ENTITY_ID: "The `entity_id` of the switch in which to (un)mark the " + "light as being `manually controlled`. 📝", + CONF_LIGHTS: "entity_id(s) of lights, if not specified, all lights in the " + "switch are selected. 💡", + CONF_MANUAL_CONTROL: 'Whether to add ("true") or remove ("false") the ' + 'light from the "manual_control" list. 🔒', +} + +DOCS_APPLY = { + CONF_ENTITY_ID: "The `entity_id` of the switch with the settings to apply. 📝", + CONF_LIGHTS: "A light (or list of lights) to apply the settings to. 💡", +} + + +def int_between(min_int, max_int): + """Return an integer between 'min_int' and 'max_int'.""" + return vol.All(vol.Coerce(int), vol.Range(min=min_int, max=max_int)) + + +VALIDATION_TUPLES = [ + (CONF_LIGHTS, DEFAULT_LIGHTS, cv.entity_ids), + (CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int), + (CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION), + (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), + (CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS, int_between(1, 100)), + (CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS, int_between(1, 100)), + (CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP, int_between(1000, 10000)), + (CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP, int_between(1000, 10000)), + (CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR, bool), + (CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS, int_between(1, 100)), + ( + CONF_SLEEP_RGB_OR_COLOR_TEMP, + DEFAULT_SLEEP_RGB_OR_COLOR_TEMP, + selector.SelectSelector( + selector.SelectSelectorConfig( + options=["color_temp", "rgb_color"], + multiple=False, + mode=selector.SelectSelectorMode.DROPDOWN, + ), + ), + ), + (CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP, int_between(1000, 10000)), + ( + CONF_SLEEP_RGB_COLOR, + DEFAULT_SLEEP_RGB_COLOR, + selector.ColorRGBSelector(selector.ColorRGBSelectorConfig()), + ), + (CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION, VALID_TRANSITION), + (CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP, bool), + (CONF_SUNRISE_TIME, NONE_STR, str), + (CONF_MIN_SUNRISE_TIME, NONE_STR, str), + (CONF_MAX_SUNRISE_TIME, NONE_STR, str), + (CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET, int), + (CONF_SUNSET_TIME, NONE_STR, str), + (CONF_MIN_SUNSET_TIME, NONE_STR, str), + (CONF_MAX_SUNSET_TIME, NONE_STR, str), + (CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET, int), + ( + CONF_BRIGHTNESS_MODE, + DEFAULT_BRIGHTNESS_MODE, + selector.SelectSelector( + selector.SelectSelectorConfig( + options=["default", "linear", "tanh"], + multiple=False, + mode=selector.SelectSelectorMode.DROPDOWN, + ), + ), + ), + (CONF_BRIGHTNESS_MODE_TIME_DARK, DEFAULT_BRIGHTNESS_MODE_TIME_DARK, int), + (CONF_BRIGHTNESS_MODE_TIME_LIGHT, DEFAULT_BRIGHTNESS_MODE_TIME_LIGHT, int), + (CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool), + (CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool), + ( + CONF_AUTORESET_CONTROL, + DEFAULT_AUTORESET_CONTROL, + int_between(0, 365 * 24 * 60 * 60), # 1 year max + ), + (CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE, bool), + (CONF_ADAPT_ONLY_ON_BARE_TURN_ON, DEFAULT_ADAPT_ONLY_ON_BARE_TURN_ON, bool), + (CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS, bool), + (CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY, int_between(0, 10000)), + (CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY, cv.positive_float), + ( + CONF_SKIP_REDUNDANT_COMMANDS, + DEFAULT_SKIP_REDUNDANT_COMMANDS, + bool, + ), + (CONF_INTERCEPT, DEFAULT_INTERCEPT, bool), + (CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT, bool), + (CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES, bool), +] + + +def timedelta_as_int(value): + """Convert a `datetime.timedelta` object to an integer. + + This integer can be serialized to json but a timedelta cannot. + """ + return value.total_seconds() + + +# conf_option: (validator, coerce) tuples +# these validators cannot be serialized but can be serialized when coerced by coerce. +EXTRA_VALIDATION = { + CONF_INTERVAL: (cv.time_period, timedelta_as_int), + CONF_SUNRISE_OFFSET: (cv.time_period, timedelta_as_int), + CONF_SUNRISE_TIME: (cv.time, str), + CONF_MIN_SUNRISE_TIME: (cv.time, str), + CONF_MAX_SUNRISE_TIME: (cv.time, str), + CONF_SUNSET_OFFSET: (cv.time_period, timedelta_as_int), + CONF_SUNSET_TIME: (cv.time, str), + CONF_MIN_SUNSET_TIME: (cv.time, str), + CONF_MAX_SUNSET_TIME: (cv.time, str), + CONF_BRIGHTNESS_MODE_TIME_LIGHT: (cv.time_period, timedelta_as_int), + CONF_BRIGHTNESS_MODE_TIME_DARK: (cv.time_period, timedelta_as_int), +} + + +def maybe_coerce(key, validation): + """Coerce the validation into a json serializable type.""" + validation, coerce = EXTRA_VALIDATION.get(key, (validation, None)) + if coerce is not None: + return vol.All(validation, vol.Coerce(coerce)) + return validation + + +def replace_none_str(value, replace_with=None): + """Replace "None" -> replace_with.""" + return value if value != NONE_STR else replace_with + + +_yaml_validation_tuples = [ + (key, default, maybe_coerce(key, validation)) + for key, default, validation in VALIDATION_TUPLES +] + [(CONF_NAME, DEFAULT_NAME, cv.string)] + +_DOMAIN_SCHEMA = vol.Schema( + { + vol.Optional(key, default=replace_none_str(default, vol.UNDEFINED)): validation + for key, default, validation in _yaml_validation_tuples + }, +) + + +def apply_service_schema(initial_transition: int = 1): + """Return the schema for the apply service.""" + return vol.Schema( + { + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, + vol.Optional( + CONF_TRANSITION, + default=initial_transition, + ): VALID_TRANSITION, + vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean, + vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean, + vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean, + vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, + }, + ) + + +SET_MANUAL_CONTROL_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, + vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean, + }, +) diff --git a/custom_components/adaptive_lighting/hass_utils.py b/custom_components/adaptive_lighting/hass_utils.py new file mode 100644 index 000000000..ba5bb8b0d --- /dev/null +++ b/custom_components/adaptive_lighting/hass_utils.py @@ -0,0 +1,80 @@ +"""Utility functions for HA core.""" +import logging +from collections.abc import Awaitable, Callable + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.util.read_only_dict import ReadOnlyDict + +from .adaptation_utils import ServiceData + +_LOGGER = logging.getLogger(__name__) + + +def setup_service_call_interceptor( + hass: HomeAssistant, + domain: str, + service: str, + intercept_func: Callable[[ServiceCall, ServiceData], Awaitable[None] | None], +) -> Callable[[], None]: + """Inject a function into a registered service call to preprocess service data. + + The injected interceptor function receives the service call and a writeable data dictionary + (the data of the service call is read-only) before the service call is executed. + """ + try: + # HACK: Access protected attribute of HA service registry. + # This is necessary to replace a registered service handler with our + # proxy handler to intercept calls. + registered_services = ( + hass.services._services # pylint: disable=protected-access + ) + except AttributeError as error: + msg = ( + "Intercept failed because registered services are no longer" + " accessible (internal API may have changed)" + ) + raise RuntimeError(msg) from error + + if domain not in registered_services or service not in registered_services[domain]: + msg = f"Intercept failed because service {domain}.{service} is not registered" + raise RuntimeError(msg) + + existing_service = registered_services[domain][service] + + async def service_func_proxy(call: ServiceCall) -> None: + try: + # Convert read-only data to writeable dictionary for modification by interceptor + data = dict(call.data) + + # Call interceptor + await intercept_func(call, data) + + # Convert data back to read-only + call.data = ReadOnlyDict(data) + except Exception as e: # noqa: BLE001 + # Blindly catch all exceptions to avoid breaking light.turn_on + _LOGGER.error( + "Error for call '%s' in service_func_proxy: '%s'", + call.data, + e, + ) + # Call original service handler with processed data + await existing_service.job.target(call) + + hass.services.async_register( + domain, + service, + service_func_proxy, + existing_service.schema, + ) + + def remove(): + # Remove the interceptor by reinstalling the original service handler + hass.services.async_register( + domain, + service, + existing_service.job.target, + existing_service.schema, + ) + + return remove diff --git a/custom_components/adaptive_lighting/helpers.py b/custom_components/adaptive_lighting/helpers.py new file mode 100644 index 000000000..2e7ba23ea --- /dev/null +++ b/custom_components/adaptive_lighting/helpers.py @@ -0,0 +1,84 @@ +"""Helper functions for the Adaptive Lighting custom components.""" + +from __future__ import annotations + +import base64 +import math + + +def clamp(value: float, minimum: float, maximum: float) -> float: + """Clamp value between minimum and maximum.""" + return max(minimum, min(value, maximum)) + + +def int_to_base36(num: int) -> str: + """Convert an integer to its base-36 representation using numbers and uppercase letters. + + Base-36 encoding uses digits 0-9 and uppercase letters A-Z, providing a case-insensitive + alphanumeric representation. The function takes an integer `num` as input and returns + its base-36 representation as a string. + + Parameters + ---------- + num + The integer to convert to base-36. + + Returns + ------- + str + The base-36 representation of the input integer. + + Examples + -------- + >>> num = 123456 + >>> base36_num = int_to_base36(num) + >>> print(base36_num) + '2N9' + """ + alphanumeric_chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + if num == 0: + return alphanumeric_chars[0] + + base36_str = "" + base = len(alphanumeric_chars) + + while num: + num, remainder = divmod(num, base) + base36_str = alphanumeric_chars[remainder] + base36_str + + return base36_str + + +def short_hash(string: str, length: int = 4) -> str: + """Create a hash of 'string' with length 'length'.""" + return base64.b32encode(string.encode()).decode("utf-8").zfill(length)[:length] + + +def remove_vowels(input_str: str, length: int = 4) -> str: + """Remove vowels from a string and return a string of length 'length'.""" + vowels = "aeiouAEIOU" + output_str = "".join([char for char in input_str if char not in vowels]) + return output_str.zfill(length)[:length] + + +def color_difference_redmean( + rgb1: tuple[float, float, float], + rgb2: tuple[float, float, float], +) -> float: + """Distance between colors in RGB space (redmean metric). + + The maximal distance between (255, 255, 255) and (0, 0, 0) ≈ 765. + + Sources: + - https://en.wikipedia.org/wiki/Color_difference#Euclidean + - https://www.compuphase.com/cmetric.htm + """ + r_hat = (rgb1[0] + rgb2[0]) / 2 + delta_r, delta_g, delta_b = ( + (col1 - col2) for col1, col2 in zip(rgb1, rgb2, strict=True) + ) + red_term = (2 + r_hat / 256) * delta_r**2 + green_term = 4 * delta_g**2 + blue_term = (2 + (255 - r_hat) / 256) * delta_b**2 + return math.sqrt(red_term + green_term + blue_term) diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json new file mode 100644 index 000000000..eb7f01556 --- /dev/null +++ b/custom_components/adaptive_lighting/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "adaptive_lighting", + "name": "Adaptive Lighting", + "codeowners": ["@basnijholt", "@RubenKelevra", "@th3w1zard1", "@protyposis"], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/basnijholt/adaptive-lighting#readme", + "iot_class": "calculated", + "issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues", + "requirements": ["ulid-transform"], + "version": "1.19.0" +} diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml new file mode 100644 index 000000000..4b79f88cf --- /dev/null +++ b/custom_components/adaptive_lighting/services.yaml @@ -0,0 +1,251 @@ +# This file is auto-generated by .github/update-services.py. +apply: + description: Applies the current Adaptive Lighting settings to lights. + fields: + entity_id: + description: The `entity_id` of the switch with the settings to apply. 📝 + selector: + entity: + integration: adaptive_lighting + domain: switch + multiple: false + lights: + description: A light (or list of lights) to apply the settings to. 💡 + selector: + entity: + domain: light + multiple: true + transition: + description: Duration of transition when lights change, in seconds. 🕑 + example: 10 + selector: + text: null + adapt_brightness: + description: Whether to adapt the brightness of the light. 🌞 + example: true + selector: + boolean: null + adapt_color: + description: Whether to adapt the color on supporting lights. 🌈 + example: true + selector: + boolean: null + prefer_rgb_color: + description: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 + example: false + selector: + boolean: null + turn_on_lights: + description: Whether to turn on lights that are currently off. 🔆 + example: false + selector: + boolean: null +set_manual_control: + description: Mark whether a light is 'manually controlled'. + fields: + entity_id: + description: The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 + selector: + entity: + integration: adaptive_lighting + domain: switch + multiple: false + lights: + description: entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡 + selector: + entity: + domain: light + multiple: true + manual_control: + description: Whether to add ("true") or remove ("false") the light from the "manual_control" list. 🔒 + example: true + default: true + selector: + boolean: null +change_switch_settings: + description: Change any settings you'd like in the switch. All options here are the same as in the config flow. + fields: + entity_id: + description: Entity ID of the switch. 📝 + required: true + selector: + entity: + domain: switch + use_defaults: + description: 'Sets the default values not specified in this service call. Options: "current" (default, retains current values), "factory" (resets to documented defaults), or "configuration" (reverts to switch config defaults). ⚙️' + example: current + required: false + default: current + selector: + select: + options: + - current + - configuration + - factory + include_config_in_attributes: + description: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 + required: false + selector: + boolean: null + turn_on_lights: + description: Whether to turn on lights that are currently off. 🔆 + example: false + required: false + selector: + boolean: null + initial_transition: + description: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ + example: 1 + required: false + selector: + text: null + sleep_transition: + description: Duration of transition when "sleep mode" is toggled in seconds. 😴 + example: 1 + required: false + selector: + text: null + max_brightness: + description: Maximum brightness percentage. 💡 + required: false + example: 100 + selector: + text: null + max_color_temp: + description: Coldest color temperature in Kelvin. ❄️ + required: false + example: 5500 + selector: + text: null + min_brightness: + description: Minimum brightness percentage. 💡 + required: false + example: 1 + selector: + text: null + min_color_temp: + description: Warmest color temperature in Kelvin. 🔥 + required: false + example: 2000 + selector: + text: null + only_once: + description: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 + example: false + required: false + selector: + boolean: null + prefer_rgb_color: + description: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 + required: false + example: false + selector: + boolean: null + separate_turn_on_commands: + description: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 + required: false + example: false + selector: + boolean: null + send_split_delay: + description: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ + required: false + example: 0 + selector: + boolean: null + sleep_brightness: + description: Brightness percentage of lights in sleep mode. 😴 + required: false + example: 1 + selector: + text: null + sleep_rgb_or_color_temp: + description: Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 + required: false + example: color_temp + selector: + select: + options: + - rgb_color + - color_temp + sleep_rgb_color: + description: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 + required: false + selector: + color_rgb: null + sleep_color_temp: + description: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 + required: false + example: 1000 + selector: + text: null + sunrise_offset: + description: Adjust sunrise time with a positive or negative offset in seconds. ⏰ + required: false + example: 0 + selector: + number: + min: 0 + max: 86300 + sunrise_time: + description: Set a fixed time (HH:MM:SS) for sunrise. 🌅 + required: false + example: '' + selector: + time: null + sunset_offset: + description: Adjust sunset time with a positive or negative offset in seconds. ⏰ + required: false + example: '' + selector: + number: + min: 0 + max: 86300 + sunset_time: + description: Set a fixed time (HH:MM:SS) for sunset. 🌇 + example: '' + required: false + selector: + time: null + max_sunrise_time: + description: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅 + example: '' + required: false + selector: + time: null + min_sunset_time: + description: Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇 + example: '' + required: false + selector: + time: null + take_over_control: + description: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒 + required: false + example: true + selector: + boolean: null + detect_non_ha_changes: + description: 'Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an ''on'' state, which could result in lights turning on unexpectedly. Disable this feature if you encounter such issues.' + required: false + example: false + selector: + boolean: null + transition: + description: Duration of transition when lights change, in seconds. 🕑 + required: false + example: 45 + selector: + text: null + adapt_delay: + description: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ + required: false + example: 0 + selector: + text: null + autoreset_control_seconds: + description: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ + required: false + example: 0 + selector: + text: null diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json new file mode 100644 index 000000000..f6f6890b5 --- /dev/null +++ b/custom_components/adaptive_lighting/strings.json @@ -0,0 +1,268 @@ +{ + "config": { + "step": { + "user": { + "title": "Choose a name for the Adaptive Lighting instance", + "description": "Every instance can contain multiple lights!", + "data": { + "name": "Name" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "title": "Adaptive Lighting options", + "description": "Configure an Adaptive Lighting component. Option names align with the YAML settings. If you've defined this entry in YAML, no options will appear here. For interactive graphs that demonstrate parameter effects, visit [this web app](https://basnijholt.github.io/adaptive-lighting). For further details, see the [official documentation](https://github.com/basnijholt/adaptive-lighting#readme).", + "data": { + "lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟", + "interval": "interval", + "transition": "transition", + "initial_transition": "initial_transition", + "min_brightness": "min_brightness: Minimum brightness percentage. 💡", + "max_brightness": "max_brightness: Maximum brightness percentage. 💡", + "min_color_temp": "min_color_temp: Warmest color temperature in Kelvin. 🔥", + "max_color_temp": "max_color_temp: Coldest color temperature in Kelvin. ❄️", + "prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈", + "sleep_brightness": "sleep_brightness", + "sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp", + "sleep_color_temp": "sleep_color_temp", + "sleep_rgb_color": "sleep_rgb_color", + "sleep_transition": "sleep_transition", + "transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙", + "sunrise_time": "sunrise_time", + "min_sunrise_time": "min_sunrise_time", + "max_sunrise_time": "max_sunrise_time", + "sunrise_offset": "sunrise_offset", + "sunset_time": "sunset_time", + "min_sunset_time": "min_sunset_time", + "max_sunset_time": "max_sunset_time", + "sunset_offset": "sunset_offset", + "brightness_mode": "brightness_mode", + "brightness_mode_time_dark": "brightness_mode_time_dark", + "brightness_mode_time_light": "brightness_mode_time_light", + "take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒", + "detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable this feature if you encounter such issues.", + "autoreset_control_seconds": "autoreset_control_seconds", + "only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄", + "adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️ ", + "separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀", + "send_split_delay": "send_split_delay", + "adapt_delay": "adapt_delay", + "skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. 📉Disable if physical light states get out of sync with HA's recorded state.", + "intercept": "intercept: Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. 🏎️ Disable for lights that do not support `light.turn_on` with color and brightness.", + "multi_light_intercept": "multi_light_intercept: Intercept and adapt `light.turn_on` calls that target multiple lights. ➗⚠️ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. Requires `intercept` to be enabled.", + "include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝" + }, + "data_description": { + "interval": "Frequency to adapt the lights, in seconds. 🔄", + "transition": "Duration of transition when lights change, in seconds. 🕑", + "initial_transition": "Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️", + "sleep_brightness": "Brightness percentage of lights in sleep mode. 😴", + "sleep_rgb_or_color_temp": "Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙", + "sleep_color_temp": "Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴", + "sleep_rgb_color": "RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈", + "sleep_transition": "Duration of transition when \"sleep mode\" is toggled in seconds. 😴", + "sunrise_time": "Set a fixed time (HH:MM:SS) for sunrise. 🌅", + "min_sunrise_time": "Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅", + "max_sunrise_time": "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅", + "sunrise_offset": "Adjust sunrise time with a positive or negative offset in seconds. ⏰", + "sunset_time": "Set a fixed time (HH:MM:SS) for sunset. 🌇", + "min_sunset_time": "Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇", + "max_sunset_time": "Set the latest virtual sunset time (HH:MM:SS), allowing for earlier sunsets. 🌇", + "sunset_offset": "Adjust sunset time with a positive or negative offset in seconds. ⏰", + "brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈", + "brightness_mode_time_dark": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉", + "brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.", + "autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️", + "send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", + "adapt_delay": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️" + } + } + }, + "error": { + "option_error": "Invalid option", + "entity_missing": "One or more selected light entities are missing from Home Assistant" + } + }, + "services": { + "apply": { + "name": "apply", + "description": "Applies the current Adaptive Lighting settings to lights.", + "fields": { + "entity_id": { + "description": "The `entity_id` of the switch with the settings to apply. 📝", + "name": "entity_id" + }, + "lights": { + "description": "A light (or list of lights) to apply the settings to. 💡", + "name": "lights" + }, + "transition": { + "description": "Duration of transition when lights change, in seconds. 🕑", + "name": "transition" + }, + "adapt_brightness": { + "description": "Whether to adapt the brightness of the light. 🌞", + "name": "adapt_brightness" + }, + "adapt_color": { + "description": "Whether to adapt the color on supporting lights. 🌈", + "name": "adapt_color" + }, + "prefer_rgb_color": { + "description": "Whether to prefer RGB color adjustment over light color temperature when possible. 🌈", + "name": "prefer_rgb_color" + }, + "turn_on_lights": { + "description": "Whether to turn on lights that are currently off. 🔆", + "name": "turn_on_lights" + } + } + }, + "set_manual_control": { + "name": "set_manual_control", + "description": "Mark whether a light is 'manually controlled'.", + "fields": { + "entity_id": { + "description": "The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝", + "name": "entity_id" + }, + "lights": { + "description": "entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡", + "name": "lights" + }, + "manual_control": { + "description": "Whether to add (\"true\") or remove (\"false\") the light from the \"manual_control\" list. 🔒", + "name": "manual_control" + } + } + }, + "change_switch_settings": { + "name": "change_switch_settings", + "description": "Change any settings you'd like in the switch. All options here are the same as in the config flow.", + "fields": { + "entity_id": { + "description": "Entity ID of the switch. 📝", + "name": "entity_id" + }, + "use_defaults": { + "description": "Sets the default values not specified in this service call. Options: \"current\" (default, retains current values), \"factory\" (resets to documented defaults), or \"configuration\" (reverts to switch config defaults). ⚙️", + "name": "use_defaults" + }, + "include_config_in_attributes": { + "description": "Show all options as attributes on the switch in Home Assistant when set to `true`. 📝", + "name": "include_config_in_attributes" + }, + "turn_on_lights": { + "description": "Whether to turn on lights that are currently off. 🔆", + "name": "turn_on_lights" + }, + "initial_transition": { + "description": "Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️", + "name": "initial_transition" + }, + "sleep_transition": { + "description": "Duration of transition when \"sleep mode\" is toggled in seconds. 😴", + "name": "sleep_transition" + }, + "max_brightness": { + "description": "Maximum brightness percentage. 💡", + "name": "max_brightness" + }, + "max_color_temp": { + "description": "Coldest color temperature in Kelvin. ❄️", + "name": "max_color_temp" + }, + "min_brightness": { + "description": "Minimum brightness percentage. 💡", + "name": "min_brightness" + }, + "min_color_temp": { + "description": "Warmest color temperature in Kelvin. 🔥", + "name": "min_color_temp" + }, + "only_once": { + "description": "Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄", + "name": "only_once" + }, + "prefer_rgb_color": { + "description": "Whether to prefer RGB color adjustment over light color temperature when possible. 🌈", + "name": "prefer_rgb_color" + }, + "separate_turn_on_commands": { + "description": "Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀", + "name": "separate_turn_on_commands" + }, + "send_split_delay": { + "description": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", + "name": "send_split_delay" + }, + "sleep_brightness": { + "description": "Brightness percentage of lights in sleep mode. 😴", + "name": "sleep_brightness" + }, + "sleep_rgb_or_color_temp": { + "description": "Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙", + "name": "sleep_rgb_or_color_temp" + }, + "sleep_rgb_color": { + "description": "RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈", + "name": "sleep_rgb_color" + }, + "sleep_color_temp": { + "description": "Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴", + "name": "sleep_color_temp" + }, + "sunrise_offset": { + "description": "Adjust sunrise time with a positive or negative offset in seconds. ⏰", + "name": "sunrise_offset" + }, + "sunrise_time": { + "description": "Set a fixed time (HH:MM:SS) for sunrise. 🌅", + "name": "sunrise_time" + }, + "sunset_offset": { + "description": "Adjust sunset time with a positive or negative offset in seconds. ⏰", + "name": "sunset_offset" + }, + "sunset_time": { + "description": "Set a fixed time (HH:MM:SS) for sunset. 🌇", + "name": "sunset_time" + }, + "max_sunrise_time": { + "description": "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅", + "name": "max_sunrise_time" + }, + "min_sunset_time": { + "description": "Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇", + "name": "min_sunset_time" + }, + "take_over_control": { + "description": "Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒", + "name": "take_over_control" + }, + "detect_non_ha_changes": { + "description": "Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable this feature if you encounter such issues.", + "name": "detect_non_ha_changes" + }, + "transition": { + "description": "Duration of transition when lights change, in seconds. 🕑", + "name": "transition" + }, + "adapt_delay": { + "description": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️", + "name": "adapt_delay" + }, + "autoreset_control_seconds": { + "description": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️", + "name": "autoreset_control_seconds" + } + } + } + } +} diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py new file mode 100644 index 000000000..ff951d15c --- /dev/null +++ b/custom_components/adaptive_lighting/switch.py @@ -0,0 +1,2697 @@ +"""Switch for the Adaptive Lighting integration.""" +from __future__ import annotations + +import asyncio +import datetime +import logging +import zoneinfo +from copy import deepcopy +from datetime import timedelta +from typing import TYPE_CHECKING, Any, Literal + +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util +import ulid_transform +import voluptuous as vol +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, + ATTR_FLASH, + ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + ATTR_TRANSITION, + ATTR_XY_COLOR, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + COLOR_MODE_RGB, + COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, + COLOR_MODE_XY, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + SUPPORT_TRANSITION, + is_on, + preprocess_turn_on_alternatives, +) +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + ATTR_AREA_ID, + ATTR_DOMAIN, + ATTR_ENTITY_ID, + ATTR_SERVICE, + ATTR_SERVICE_DATA, + ATTR_SUPPORTED_FEATURES, + CONF_NAME, + CONF_PARAMS, + EVENT_CALL_SERVICE, + EVENT_HOMEASSISTANT_STARTED, + EVENT_STATE_CHANGED, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import ( + CALLBACK_TYPE, + Context, + Event, + HomeAssistant, + ServiceCall, + State, + callback, +) +from homeassistant.helpers import entity_platform, entity_registry +from homeassistant.helpers.event import ( + async_track_state_change_event, + async_track_time_interval, +) +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.sun import get_astral_location +from homeassistant.helpers.template import area_entities +from homeassistant.loader import bind_hass +from homeassistant.util import slugify +from homeassistant.util.color import ( + color_temperature_to_rgb, + color_xy_to_RGB, +) + +from .adaptation_utils import ( + BRIGHTNESS_ATTRS, + COLOR_ATTRS, + AdaptationData, + ServiceData, + prepare_adaptation_data, +) +from .color_and_brightness import SunLightSettings +from .const import ( + ADAPT_BRIGHTNESS_SWITCH, + ADAPT_COLOR_SWITCH, + ATTR_ADAPT_BRIGHTNESS, + ATTR_ADAPT_COLOR, + ATTR_ADAPTIVE_LIGHTING_MANAGER, + CONF_ADAPT_DELAY, + CONF_ADAPT_ONLY_ON_BARE_TURN_ON, + CONF_ADAPT_UNTIL_SLEEP, + CONF_AUTORESET_CONTROL, + CONF_BRIGHTNESS_MODE, + CONF_BRIGHTNESS_MODE_TIME_DARK, + CONF_BRIGHTNESS_MODE_TIME_LIGHT, + CONF_DETECT_NON_HA_CHANGES, + CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, + CONF_INITIAL_TRANSITION, + CONF_INTERCEPT, + CONF_INTERVAL, + CONF_LIGHTS, + CONF_MANUAL_CONTROL, + CONF_MAX_BRIGHTNESS, + CONF_MAX_COLOR_TEMP, + CONF_MAX_SUNRISE_TIME, + CONF_MAX_SUNSET_TIME, + CONF_MIN_BRIGHTNESS, + CONF_MIN_COLOR_TEMP, + CONF_MIN_SUNRISE_TIME, + CONF_MIN_SUNSET_TIME, + CONF_MULTI_LIGHT_INTERCEPT, + CONF_ONLY_ONCE, + CONF_PREFER_RGB_COLOR, + CONF_SEND_SPLIT_DELAY, + CONF_SEPARATE_TURN_ON_COMMANDS, + CONF_SKIP_REDUNDANT_COMMANDS, + CONF_SLEEP_BRIGHTNESS, + CONF_SLEEP_COLOR_TEMP, + CONF_SLEEP_RGB_COLOR, + CONF_SLEEP_RGB_OR_COLOR_TEMP, + CONF_SLEEP_TRANSITION, + CONF_SUNRISE_OFFSET, + CONF_SUNRISE_TIME, + CONF_SUNSET_OFFSET, + CONF_SUNSET_TIME, + CONF_TAKE_OVER_CONTROL, + CONF_TRANSITION, + CONF_TURN_ON_LIGHTS, + CONF_USE_DEFAULTS, + DOMAIN, + EXTRA_VALIDATION, + ICON_BRIGHTNESS, + ICON_COLOR_TEMP, + ICON_MAIN, + ICON_SLEEP, + SERVICE_APPLY, + SERVICE_CHANGE_SWITCH_SETTINGS, + SERVICE_SET_MANUAL_CONTROL, + SET_MANUAL_CONTROL_SCHEMA, + SLEEP_MODE_SWITCH, + TURNING_OFF_DELAY, + VALIDATION_TUPLES, + apply_service_schema, + replace_none_str, +) +from .hass_utils import setup_service_call_interceptor +from .helpers import ( + clamp, + color_difference_redmean, + int_to_base36, + remove_vowels, + short_hash, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Coroutine, Iterable + + from homeassistant.config_entries import ConfigEntry + from homeassistant.helpers.entity_platform import AddEntitiesCallback + +_SUPPORT_OPTS = { + "brightness": SUPPORT_BRIGHTNESS, + "color_temp": SUPPORT_COLOR_TEMP, + "color": SUPPORT_COLOR, + "transition": SUPPORT_TRANSITION, +} + + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=10) + +# Consider it a significant change when attribute changes more than +BRIGHTNESS_CHANGE = 25 # ≈10% of total range +COLOR_TEMP_CHANGE = 100 # ≈3% of total range (2000-6500) +RGB_REDMEAN_CHANGE = 80 # ≈10% of total range + + +# Keep a short domain version for the context instances (which can only be 36 chars) +_DOMAIN_SHORT = "al" + + +def create_context( + name: str, + which: str, + index: int, + parent: Context | None = None, +) -> Context: + """Create a context that can identify this integration.""" + # Use a hash for the name because otherwise the context might become + # too long (max len == 26) to fit in the database. + # Pack index with base85 to maximize the number of contexts we can create + # before we exceed the 26-character limit and are forced to wrap. + time_stamp = ulid_transform.ulid_now()[:10] # time part of a ULID + name_hash = short_hash(name) + which_short = remove_vowels(which) + context_id_start = f"{time_stamp}:{_DOMAIN_SHORT}:{name_hash}:{which_short}:" + chars_left = 26 - len(context_id_start) + index_packed = int_to_base36(index).zfill(chars_left)[-chars_left:] + context_id = context_id_start + index_packed + parent_id = parent.id if parent else None + return Context(id=context_id, parent_id=parent_id) + + +def is_our_context_id(context_id: str | None, which: str | None = None) -> bool: + """Check whether this integration created 'context_id'.""" + if context_id is None: + return False + + is_al = f":{_DOMAIN_SHORT}:" in context_id + if not is_al: + return False + if which is None: + return True + return f":{remove_vowels(which)}:" in context_id + + +def is_our_context(context: Context | None, which: str | None = None) -> bool: + """Check whether this integration created 'context'.""" + if context is None: + return False + return is_our_context_id(context.id, which) + + +@bind_hass +def _switches_with_lights( + hass: HomeAssistant, + lights: list[str], + expand_light_groups: bool = True, +) -> list[AdaptiveSwitch]: + """Get all switches that control at least one of the lights passed.""" + config_entries = hass.config_entries.async_entries(DOMAIN) + data = hass.data[DOMAIN] + switches = [] + all_check_lights = ( + _expand_light_groups(hass, lights) if expand_light_groups else set(lights) + ) + for config in config_entries: + entry = data.get(config.entry_id) + if entry is None: # entry might be disabled and therefore missing + continue + switch = data[config.entry_id][SWITCH_DOMAIN] + switch._expand_light_groups() + # Check if any of the lights are in the switch's lights + if set(switch.lights) & set(all_check_lights): + switches.append(switch) + return switches + + +class NoSwitchFoundError(ValueError): + """No switches found for lights.""" + + +@bind_hass +def _switch_with_lights( + hass: HomeAssistant, + lights: list[str], + expand_light_groups: bool = True, +) -> AdaptiveSwitch: + """Find the switch that controls the lights in 'lights'.""" + switches = _switches_with_lights(hass, lights, expand_light_groups) + if len(switches) == 1: + return switches[0] + if len(switches) > 1: + on_switches = [s for s in switches if s.is_on] + if len(on_switches) == 1: + # Of the multiple switches, only one is on + return on_switches[0] + msg = ( + f"_switch_with_lights: Light(s) {lights} found in multiple switch configs" + f" ({[s.entity_id for s in switches]}). You must pass a switch under" + " 'entity_id'." + ) + raise NoSwitchFoundError(msg) + msg = ( + f"_switch_with_lights: Light(s) {lights} not found in any switch's" + " configuration. You must either include the light(s) that is/are" + " in the integration config, or pass a switch under 'entity_id'." + ) + raise NoSwitchFoundError(msg) + + +# For documentation on this function, see integration_entities() from HomeAssistant Core: +# https://github.com/home-assistant/core/blob/dev/homeassistant/helpers/template.py#L1109 +@bind_hass +def _switches_from_service_call( + hass: HomeAssistant, + service_call: ServiceCall, +) -> list[AdaptiveSwitch]: + data = service_call.data + lights = data[CONF_LIGHTS] + switch_entity_ids: list[str] | None = data.get("entity_id") + + if not lights and not switch_entity_ids: + msg = ( + "adaptive-lighting: Neither a switch nor a light was provided in the service call." + " If you intend to adapt all lights on all switches, please inform the" + " developers at https://github.com/basnijholt/adaptive-lighting about your" + " use case. Currently, you must pass either an adaptive-lighting switch or" + " the lights to an `adaptive_lighting` service call." + ) + raise ValueError(msg) + + if switch_entity_ids is not None: + if len(switch_entity_ids) > 1 and lights: + msg = ( + "adaptive-lighting: Cannot pass multiple switches with lights argument." + f" Invalid service data received: {service_call.data}" + ) + raise ValueError(msg) + switches = [] + ent_reg = entity_registry.async_get(hass) + for entity_id in switch_entity_ids: + ent_entry = ent_reg.async_get(entity_id) + assert ent_entry is not None + config_id = ent_entry.config_entry_id + switches.append(hass.data[DOMAIN][config_id][SWITCH_DOMAIN]) + return switches + + if lights: + switch = _switch_with_lights(hass, lights) + return [switch] + + msg = ( + "adaptive-lighting: Incorrect data provided in service call." + f" Entities not found in the integration. Service data: {service_call.data}" + ) + raise ValueError(msg) + + +async def handle_change_switch_settings( + switch: AdaptiveSwitch, + service_call: ServiceCall, +) -> None: + """Allows HASS to change config values via a service call.""" + data = service_call.data + which = data.get(CONF_USE_DEFAULTS, "current") + if which == "current": # use whatever we're already using. + defaults = switch._current_settings # pylint: disable=protected-access + elif which == "factory": # use actual defaults listed in the documentation + defaults = None + elif which == "configuration": + # use whatever's in the config flow or configuration.yaml + defaults = switch._config_backup + else: + defaults = None + + # deep copy the defaults so we don't modify the original dicts + switch._set_changeable_settings(data=data, defaults=deepcopy(defaults)) + switch._update_time_interval_listener() + + _LOGGER.debug( + "Called 'adaptive_lighting.change_switch_settings' service with '%s'", + data, + ) + + switch.manager.reset(*switch.lights, reset_manual_control=False) + if switch.is_on: + await switch._update_attrs_and_maybe_adapt_lights( # pylint: disable=protected-access + context=switch.create_context("service", parent=service_call.context), + lights=switch.lights, + transition=switch.initial_transition, + force=True, + ) + + +@callback +def _fire_manual_control_event( + switch: AdaptiveSwitch, + light: str, + context: Context, +): + """Fire an event that 'light' is marked as manual_control.""" + hass = switch.hass + _LOGGER.debug( + "'adaptive_lighting.manual_control' event fired for %s for light %s", + switch.entity_id, + light, + ) + switch.manager.mark_as_manual_control(light) + hass.bus.async_fire( + f"{DOMAIN}.manual_control", + {ATTR_ENTITY_ID: light, SWITCH_DOMAIN: switch.entity_id}, + context=context, + ) + + +async def async_setup_entry( # noqa: PLR0915 + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +): + """Set up the AdaptiveLighting switch.""" + assert hass is not None + data = hass.data[DOMAIN] + assert config_entry.entry_id in data + _LOGGER.debug( + "Setting up AdaptiveLighting with data: %s and config_entry %s", + data, + config_entry, + ) + if ( # Skip deleted YAML config entries or first time YAML config entries + config_entry.source == SOURCE_IMPORT + and config_entry.unique_id not in data.get("__yaml__", set()) + ): + _LOGGER.warning( + "Deleting AdaptiveLighting switch '%s' because YAML" + " defined switch has been removed from YAML configuration", + config_entry.unique_id, + ) + await hass.config_entries.async_remove(config_entry.entry_id) + return + + if (manager := data.get(ATTR_ADAPTIVE_LIGHTING_MANAGER)) is None: + manager = AdaptiveLightingManager(hass) + data[ATTR_ADAPTIVE_LIGHTING_MANAGER] = manager + + sleep_mode_switch = SimpleSwitch( + which="Sleep Mode", + initial_state=False, + hass=hass, + config_entry=config_entry, + icon=ICON_SLEEP, + ) + adapt_color_switch = SimpleSwitch( + which="Adapt Color", + initial_state=True, + hass=hass, + config_entry=config_entry, + icon=ICON_COLOR_TEMP, + ) + adapt_brightness_switch = SimpleSwitch( + which="Adapt Brightness", + initial_state=True, + hass=hass, + config_entry=config_entry, + icon=ICON_BRIGHTNESS, + ) + switch = AdaptiveSwitch( + hass, + config_entry, + manager, + sleep_mode_switch, + adapt_color_switch, + adapt_brightness_switch, + ) + + data[config_entry.entry_id][SLEEP_MODE_SWITCH] = sleep_mode_switch + data[config_entry.entry_id][ADAPT_COLOR_SWITCH] = adapt_color_switch + data[config_entry.entry_id][ADAPT_BRIGHTNESS_SWITCH] = adapt_brightness_switch + data[config_entry.entry_id][SWITCH_DOMAIN] = switch + + async_add_entities( + [sleep_mode_switch, adapt_color_switch, adapt_brightness_switch, switch], + update_before_add=True, + ) + + @callback + async def handle_apply(service_call: ServiceCall): + """Handle the entity service apply.""" + data = service_call.data + _LOGGER.debug( + "Called 'adaptive_lighting.apply' service with '%s'", + data, + ) + switches = _switches_from_service_call(hass, service_call) + lights = data[CONF_LIGHTS] + for switch in switches: + if not lights: + all_lights = switch.lights + else: + all_lights = _expand_light_groups(hass, lights) + switch.manager.lights.update(all_lights) + for light in all_lights: + if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light): + context = switch.create_context( + "service", + parent=service_call.context, + ) + await switch._adapt_light( # pylint: disable=protected-access + light, + context=context, + transition=data[CONF_TRANSITION], + adapt_brightness=data[ATTR_ADAPT_BRIGHTNESS], + adapt_color=data[ATTR_ADAPT_COLOR], + prefer_rgb_color=data[CONF_PREFER_RGB_COLOR], + force=True, + ) + + @callback + async def handle_set_manual_control(service_call: ServiceCall): + """Set or unset lights as 'manually controlled'.""" + data = service_call.data + _LOGGER.debug( + "Called 'adaptive_lighting.set_manual_control' service with '%s'", + data, + ) + switches = _switches_from_service_call(hass, service_call) + lights = data[CONF_LIGHTS] + for switch in switches: + if not lights: + all_lights = switch.lights + else: + all_lights = _expand_light_groups(hass, lights) + if service_call.data[CONF_MANUAL_CONTROL]: + for light in all_lights: + _fire_manual_control_event(switch, light, service_call.context) + else: + switch.manager.reset(*all_lights) + if switch.is_on: + context = switch.create_context( + "service", + parent=service_call.context, + ) + # pylint: disable=protected-access + await switch._update_attrs_and_maybe_adapt_lights( + context=context, + lights=all_lights, + transition=switch.initial_transition, + force=True, + ) + + # Register `apply` service + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_APPLY, + service_func=handle_apply, + schema=apply_service_schema(switch.initial_transition), + ) + + # Register `set_manual_control` service + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_SET_MANUAL_CONTROL, + service_func=handle_set_manual_control, + schema=SET_MANUAL_CONTROL_SCHEMA, + ) + + args = {vol.Optional(CONF_USE_DEFAULTS, default="current"): cv.string} + # Modifying these after init isn't possible + skip = (CONF_INTERVAL, CONF_NAME, CONF_LIGHTS) + for k, _, valid in VALIDATION_TUPLES: + if k not in skip: + args[vol.Optional(k)] = valid + platform = entity_platform.current_platform.get() + assert platform is not None + platform.async_register_entity_service( + SERVICE_CHANGE_SWITCH_SETTINGS, + args, + handle_change_switch_settings, + ) + + +def validate( + config_entry: ConfigEntry | None, + service_data: dict[str, Any] | None = None, + defaults: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Get the options and data from the config_entry and add defaults.""" + if defaults is None: + data = {key: default for key, default, _ in VALIDATION_TUPLES} + else: + data = deepcopy(defaults) + + if config_entry is not None: + assert service_data is None + assert defaults is None + data.update(config_entry.options) # come from options flow + data.update(config_entry.data) # all yaml settings come from data + else: + assert service_data is not None + changed_settings = { + key: value + for key, value in service_data.items() + if key not in (CONF_USE_DEFAULTS, ATTR_ENTITY_ID) + } + data.update(changed_settings) + data = {key: replace_none_str(value) for key, value in data.items()} + for key, (validate_value, _) in EXTRA_VALIDATION.items(): + value = data.get(key) + if value is not None: + data[key] = validate_value(value) # Fix the types of the inputs + return data + + +def _is_state_event(event: Event, from_or_to_state: Iterable[str]): + """Match state event when either 'from_state' or 'to_state' matches.""" + return ( + (old_state := event.data.get("old_state")) is not None + and old_state.state in from_or_to_state + ) or ( + (new_state := event.data.get("new_state")) is not None + and new_state.state in from_or_to_state + ) + + +@bind_hass +def _expand_light_groups( + hass: HomeAssistant, + lights: list[str], +) -> list[str]: + all_lights = set() + manager = hass.data[DOMAIN][ATTR_ADAPTIVE_LIGHTING_MANAGER] + for light in lights: + state = hass.states.get(light) + if state is None: + _LOGGER.debug("State of %s is None", light) + all_lights.add(light) + elif _is_light_group(state): + group = state.attributes["entity_id"] + manager.lights.discard(light) + all_lights.update(group) + _LOGGER.debug("Expanded %s to %s", light, group) + else: + all_lights.add(light) + return sorted(all_lights) + + +def _is_light_group(state: State) -> bool: + return "entity_id" in state.attributes + + +@bind_hass +def _supported_features(hass: HomeAssistant, light: str) -> set[str]: + state = hass.states.get(light) + assert state is not None + supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + assert isinstance(supported_features, int) + supported = { + key for key, value in _SUPPORT_OPTS.items() if supported_features & value + } + + supported_color_modes = state.attributes.get(ATTR_SUPPORTED_COLOR_MODES, set()) + color_modes = { + COLOR_MODE_RGB, + COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, + COLOR_MODE_XY, + COLOR_MODE_HS, + } + + # Adding brightness when color mode is supported, see + # comment https://github.com/basnijholt/adaptive-lighting/issues/112#issuecomment-836944011 + + for mode in color_modes: + if mode in supported_color_modes: + supported.update({"color", "brightness"}) + break + + if COLOR_MODE_COLOR_TEMP in supported_color_modes: + supported.update({"color_temp", "brightness"}) + + if COLOR_MODE_BRIGHTNESS in supported_color_modes: + supported.add("brightness") + + return supported + + +# All comparisons should be done with RGB since +# converting anything to color temp is inaccurate. +def _convert_attributes(attributes: dict[str, Any]) -> dict[str, Any]: + if ATTR_RGB_COLOR in attributes: + return attributes + + rgb = None + if (color := attributes.get(ATTR_COLOR_TEMP_KELVIN)) is not None: + rgb = color_temperature_to_rgb(color) + elif (color := attributes.get(ATTR_XY_COLOR)) is not None: + rgb = color_xy_to_RGB(*color) + + if rgb is not None: + attributes[ATTR_RGB_COLOR] = rgb + _LOGGER.debug(f"Converted {attributes} to rgb {rgb}") + else: + _LOGGER.debug("No suitable color conversion found for %s", attributes) + + return attributes + + +def _add_missing_attributes( + old_attributes: dict[str, Any], + new_attributes: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + if not any( + attr in old_attributes and attr in new_attributes + for attr in [ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR] + ): + old_attributes = _convert_attributes(old_attributes) + new_attributes = _convert_attributes(new_attributes) + + return old_attributes, new_attributes + + +def _attributes_have_changed( + light: str, + old_attributes: dict[str, Any], + new_attributes: dict[str, Any], + adapt_brightness: bool, + adapt_color: bool, + context: Context, +) -> bool: + if adapt_color: + old_attributes, new_attributes = _add_missing_attributes( + old_attributes, + new_attributes, + ) + + if ( + adapt_brightness + and ATTR_BRIGHTNESS in old_attributes + and ATTR_BRIGHTNESS in new_attributes + ): + last_brightness = old_attributes[ATTR_BRIGHTNESS] + current_brightness = new_attributes[ATTR_BRIGHTNESS] + if abs(current_brightness - last_brightness) > BRIGHTNESS_CHANGE: + _LOGGER.debug( + "Brightness of '%s' significantly changed from %s to %s with" + " context.id='%s'", + light, + last_brightness, + current_brightness, + context.id, + ) + return True + + if ( + adapt_color + and ATTR_COLOR_TEMP_KELVIN in old_attributes + and ATTR_COLOR_TEMP_KELVIN in new_attributes + ): + last_color_temp = old_attributes[ATTR_COLOR_TEMP_KELVIN] + current_color_temp = new_attributes[ATTR_COLOR_TEMP_KELVIN] + if abs(current_color_temp - last_color_temp) > COLOR_TEMP_CHANGE: + _LOGGER.debug( + "Color temperature of '%s' significantly changed from %s to %s with" + " context.id='%s'", + light, + last_color_temp, + current_color_temp, + context.id, + ) + return True + + if ( + adapt_color + and ATTR_RGB_COLOR in old_attributes + and ATTR_RGB_COLOR in new_attributes + ): + last_rgb_color = old_attributes[ATTR_RGB_COLOR] + current_rgb_color = new_attributes[ATTR_RGB_COLOR] + redmean_change = color_difference_redmean(last_rgb_color, current_rgb_color) + if redmean_change > RGB_REDMEAN_CHANGE: + _LOGGER.debug( + "color RGB of '%s' significantly changed from %s to %s with" + " context.id='%s'", + light, + last_rgb_color, + current_rgb_color, + context.id, + ) + return True + return False + + +class AdaptiveSwitch(SwitchEntity, RestoreEntity): + """Representation of a Adaptive Lighting switch.""" + + def __init__( + self, + hass, + config_entry: ConfigEntry, + manager: AdaptiveLightingManager, + sleep_mode_switch: SimpleSwitch, + adapt_color_switch: SimpleSwitch, + adapt_brightness_switch: SimpleSwitch, + ) -> None: + """Initialize the Adaptive Lighting switch.""" + # Set attributes that can't be modified during runtime + assert hass is not None + self.hass = hass + self.manager = manager + self.sleep_mode_switch = sleep_mode_switch + self.adapt_color_switch = adapt_color_switch + self.adapt_brightness_switch = adapt_brightness_switch + + data = validate(config_entry) + + self._name = data[CONF_NAME] + self._interval: timedelta = data[CONF_INTERVAL] + self.lights: list[str] = data[CONF_LIGHTS] + + # backup data for use in change_switch_settings "configuration" CONF_USE_DEFAULTS + self._config_backup = deepcopy(data) + self._set_changeable_settings(data=data, defaults=None) + + # Set other attributes + self._icon = ICON_MAIN + self._state: bool | None = None + + # To count the number of `Context` instances + self._context_cnt: int = 0 + + # Set in self._update_attrs_and_maybe_adapt_lights + self._settings: dict[str, Any] = {} + + # Set and unset tracker in async_turn_on and async_turn_off + self.remove_listeners: list[CALLBACK_TYPE] = [] + self.remove_interval: CALLBACK_TYPE = lambda: None + _LOGGER.debug( + "%s: Setting up with '%s'," + " config_entry.data: '%s'," + " config_entry.options: '%s', converted to '%s'.", + self._name, + self.lights, + config_entry.data, + config_entry.options, + data, + ) + + def _set_changeable_settings( + self, + data: dict[str, Any], + defaults: dict[str, Any] | None = None, + ): + # Only pass settings users can change during runtime + data = validate( + config_entry=None, + service_data=data, + defaults=defaults, + ) + + # backup data for use in change_switch_settings "current" CONF_USE_DEFAULTS + self._current_settings = data + + self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES] + self._include_config_in_attributes = data[CONF_INCLUDE_CONFIG_IN_ATTRIBUTES] + self._config: dict[str, Any] = {} + if self._include_config_in_attributes: + attrdata = deepcopy(data) + for k, v in attrdata.items(): + if isinstance(v, datetime.date | datetime.datetime): + attrdata[k] = v.isoformat() + elif isinstance(v, datetime.timedelta): + attrdata[k] = v.total_seconds() + self._config.update(attrdata) + + self.initial_transition = data[CONF_INITIAL_TRANSITION] + self._sleep_transition = data[CONF_SLEEP_TRANSITION] + self._only_once = data[CONF_ONLY_ONCE] + self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR] + self._separate_turn_on_commands = data[CONF_SEPARATE_TURN_ON_COMMANDS] + self._transition = data[CONF_TRANSITION] + self._adapt_delay = data[CONF_ADAPT_DELAY] + self._send_split_delay = data[CONF_SEND_SPLIT_DELAY] + self._take_over_control = data[CONF_TAKE_OVER_CONTROL] + if not data[CONF_TAKE_OVER_CONTROL] and ( + data[CONF_DETECT_NON_HA_CHANGES] or data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON] + ): + _LOGGER.warning( + "%s: Config mismatch: `detect_non_ha_changes` or `adapt_only_on_bare_turn_on` " + "set to `true` requires `take_over_control` to be enabled. Adjusting config " + "and continuing setup with `take_over_control: true`.", + self._name, + ) + self._take_over_control = True + self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES] + self._adapt_only_on_bare_turn_on = data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON] + self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL] + self._skip_redundant_commands = data[CONF_SKIP_REDUNDANT_COMMANDS] + self._intercept = data[CONF_INTERCEPT] + self._multi_light_intercept = data[CONF_MULTI_LIGHT_INTERCEPT] + if not data[CONF_INTERCEPT] and data[CONF_MULTI_LIGHT_INTERCEPT]: + _LOGGER.warning( + "%s: Config mismatch: `multi_light_intercept` set to `true` requires `intercept`" + " to be enabled. Adjusting config and continuing setup with" + " `multi_light_intercept: false`.", + self._name, + ) + self._multi_light_intercept = False + self._expand_light_groups() # updates manual control timers + location, _ = get_astral_location(self.hass) + + self._sun_light_settings = SunLightSettings( + name=self._name, + astral_location=location, + adapt_until_sleep=data[CONF_ADAPT_UNTIL_SLEEP], + max_brightness=data[CONF_MAX_BRIGHTNESS], + max_color_temp=data[CONF_MAX_COLOR_TEMP], + min_brightness=data[CONF_MIN_BRIGHTNESS], + min_color_temp=data[CONF_MIN_COLOR_TEMP], + sleep_brightness=data[CONF_SLEEP_BRIGHTNESS], + sleep_color_temp=data[CONF_SLEEP_COLOR_TEMP], + sleep_rgb_color=data[CONF_SLEEP_RGB_COLOR], + sleep_rgb_or_color_temp=data[CONF_SLEEP_RGB_OR_COLOR_TEMP], + sunrise_offset=data[CONF_SUNRISE_OFFSET], + sunrise_time=data[CONF_SUNRISE_TIME], + min_sunrise_time=data[CONF_MIN_SUNRISE_TIME], + max_sunrise_time=data[CONF_MAX_SUNRISE_TIME], + sunset_offset=data[CONF_SUNSET_OFFSET], + sunset_time=data[CONF_SUNSET_TIME], + min_sunset_time=data[CONF_MIN_SUNSET_TIME], + max_sunset_time=data[CONF_MAX_SUNSET_TIME], + brightness_mode=data[CONF_BRIGHTNESS_MODE], + brightness_mode_time_dark=data[CONF_BRIGHTNESS_MODE_TIME_DARK], + brightness_mode_time_light=data[CONF_BRIGHTNESS_MODE_TIME_LIGHT], + timezone=zoneinfo.ZoneInfo(self.hass.config.time_zone), + ) + _LOGGER.debug( + "%s: Set switch settings for lights '%s'. now using data: '%s'", + self._name, + self.lights, + data, + ) + + @property + def name(self): + """Return the name of the device if any.""" + return f"Adaptive Lighting: {self._name}" + + @property + def unique_id(self): + """Return the unique ID of entity.""" + return self._name + + @property + def is_on(self) -> bool | None: + """Return true if adaptive lighting is on.""" + return self._state + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + if self.hass.is_running: + await self._setup_listeners() + else: + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, + self._setup_listeners, + ) + last_state: State | None = await self.async_get_last_state() + is_new_entry = last_state is None # newly added to HA + if is_new_entry or last_state.state == STATE_ON: # type: ignore[union-attr] + await self.async_turn_on(adapt_lights=not self._only_once) + else: + self._state = False + assert not self.remove_listeners + + async def async_will_remove_from_hass(self): + """Remove the listeners upon removing the component.""" + self._remove_listeners() + + def _expand_light_groups(self) -> None: + all_lights = _expand_light_groups(self.hass, self.lights) + self.manager.lights.update(all_lights) + self.manager.set_auto_reset_manual_control_times( + all_lights, + self._auto_reset_manual_control_time, + ) + self.lights = list(all_lights) + + async def _setup_listeners(self, _=None) -> None: + _LOGGER.debug("%s: Called '_setup_listeners'", self._name) + if not self.is_on or not self.hass.is_running: + _LOGGER.debug("%s: Cancelled '_setup_listeners'", self._name) + return + + while not all( + sw._state is not None + for sw in [ + self.sleep_mode_switch, + self.adapt_brightness_switch, + self.adapt_color_switch, + ] + ): + # Waits until `async_added_to_hass` is done, which in SimpleSwitch + # is when `_state` is set to `True` or `False`. + # Fixes first issue in https://github.com/basnijholt/adaptive-lighting/issues/682 + _LOGGER.debug( + "%s: Waiting for simple switches to be initialized", + self._name, + ) + await asyncio.sleep(0.1) + + assert not self.remove_listeners + + self._update_time_interval_listener() + + remove_sleep = async_track_state_change_event( + self.hass, + entity_ids=self.sleep_mode_switch.entity_id, + action=self._sleep_mode_switch_state_event_action, + ) + + self.remove_listeners.append(remove_sleep) + self._expand_light_groups() + + def _update_time_interval_listener(self) -> None: + """Create or recreate the adaptation interval listener. + + Recreation is necessary when the configuration has changed (e.g., `send_split_delay`). + """ + self._remove_interval_listener() + + # An adaptation takes a little longer than its nominal duration due processing overhead, + # so we factor this in to avoid overlapping adaptations. Since this is a constant value, + # it might not cover all cases, but if large enough, it covers most. + # Ideally, the interval and adaptation are a coupled process where a finished adaptation + # triggers the next, but that requires a larger architectural change. + processing_overhead_time = 0.5 + + adaptation_interval = ( + self._interval + + timedelta(milliseconds=self._send_split_delay) + + timedelta(seconds=processing_overhead_time) + ) + + self.remove_interval = async_track_time_interval( + self.hass, + action=self._async_update_at_interval_action, + interval=adaptation_interval, + ) + + def _call_on_remove_callbacks(self) -> None: + """Call callbacks registered by async_on_remove.""" + # This is called when the integration is removed from HA + # and in `Entity.add_to_platform_abort`. + # For some unknown reason (to me) `async_will_remove_from_hass` + # is not called in `add_to_platform_abort`. + # See https://github.com/basnijholt/adaptive-lighting/issues/658 + self._remove_listeners() + try: + # HACK: this is a private method in `Entity` which can change + super()._call_on_remove_callbacks() + except AttributeError as err: + _LOGGER.error( + "%s: Caught AttributeError in `_call_on_remove_callbacks`: %s", + self._name, + err, + ) + + def _remove_interval_listener(self) -> None: + self.remove_interval() + self.remove_interval = lambda: None + + def _remove_listeners(self) -> None: + self._remove_interval_listener() + + while self.remove_listeners: + remove_listener = self.remove_listeners.pop() + remove_listener() + + @property + def icon(self) -> str: + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the attributes of the switch.""" + extra_state_attributes: dict[str, Any] = {"configuration": self._config} + if not self.is_on: + for key in self._settings: + extra_state_attributes[key] = None + return extra_state_attributes + extra_state_attributes["manual_control"] = [ + light for light in self.lights if self.manager.manual_control.get(light) + ] + extra_state_attributes.update(self._settings) + timers = self.manager.auto_reset_manual_control_timers + extra_state_attributes["autoreset_time_remaining"] = { + light: time + for light in self.lights + if (timer := timers.get(light)) and (time := timer.remaining_time()) > 0 + } + return extra_state_attributes + + def create_context( + self, + which: str = "default", + parent: Context | None = None, + ) -> Context: + """Create a context that identifies this Adaptive Lighting instance.""" + context = create_context(self._name, which, self._context_cnt, parent=parent) + self._context_cnt += 1 + return context + + async def async_turn_on( # type: ignore[override] + self, + adapt_lights: bool = True, + ) -> None: + """Turn on adaptive lighting.""" + _LOGGER.debug( + "%s: Called 'async_turn_on', current state is '%s'", + self._name, + self._state, + ) + if self.is_on: + return + self._state = True + self.manager.reset(*self.lights) + await self._setup_listeners() + if adapt_lights: + await self._update_attrs_and_maybe_adapt_lights( + context=self.create_context("turn_on"), + transition=self.initial_transition, + force=True, + ) + + async def async_turn_off(self, **kwargs) -> None: # noqa: ARG002 + """Turn off adaptive lighting.""" + if not self.is_on: + return + self._state = False + self._remove_listeners() + self.manager.reset(*self.lights) + + async def _async_update_at_interval_action(self, now=None) -> None: # noqa: ARG002 + """Update the attributes and maybe adapt the lights.""" + await self._update_attrs_and_maybe_adapt_lights( + context=self.create_context("interval"), + transition=self._transition, + force=False, + ) + + async def prepare_adaptation_data( + self, + light: str, + transition: int | None = None, + adapt_brightness: bool | None = None, + adapt_color: bool | None = None, + prefer_rgb_color: bool | None = None, + force: bool = False, + context: Context | None = None, + ) -> AdaptationData | None: + """Prepare `AdaptationData` for adapting a light.""" + if transition is None: + transition = self._transition + if adapt_brightness is None: + adapt_brightness = self.adapt_brightness_switch.is_on + if adapt_color is None: + adapt_color = self.adapt_color_switch.is_on + if prefer_rgb_color is None: + prefer_rgb_color = self._prefer_rgb_color + + if not adapt_color and not adapt_brightness: + _LOGGER.debug( + "%s: Skipping adaptation of %s because both adapt_brightness and" + " adapt_color are False", + self._name, + light, + ) + return None + + # The switch might be off and not have _settings set. + self._settings = self._sun_light_settings.get_settings( + self.sleep_mode_switch.is_on, + transition, + ) + + # Build service data. + service_data: dict[str, Any] = {ATTR_ENTITY_ID: light} + features = _supported_features(self.hass, light) + + # Check transition == 0 to fix #378 + use_transition = "transition" in features and transition > 0 + if use_transition: + service_data[ATTR_TRANSITION] = transition + + if "brightness" in features and adapt_brightness: + brightness = round(255 * self._settings["brightness_pct"] / 100) + service_data[ATTR_BRIGHTNESS] = brightness + + sleep_rgb = ( + self.sleep_mode_switch.is_on + and self._sun_light_settings.sleep_rgb_or_color_temp == "rgb_color" + ) + if ( + "color_temp" in features + and adapt_color + and not (prefer_rgb_color and "color" in features) + and not (sleep_rgb and "color" in features) + and not (self._settings["force_rgb_color"] and "color" in features) + ): + _LOGGER.debug("%s: Setting color_temp of light %s", self._name, light) + state = self.hass.states.get(light) + assert isinstance(state, State) + attributes = state.attributes + min_kelvin = attributes["min_color_temp_kelvin"] + max_kelvin = attributes["max_color_temp_kelvin"] + color_temp_kelvin = self._settings["color_temp_kelvin"] + color_temp_kelvin = clamp(color_temp_kelvin, min_kelvin, max_kelvin) + service_data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin + elif "color" in features and adapt_color: + _LOGGER.debug("%s: Setting rgb_color of light %s", self._name, light) + service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] + + required_attrs = [ATTR_RGB_COLOR, ATTR_COLOR_TEMP_KELVIN, ATTR_BRIGHTNESS] + if not any(attr in service_data for attr in required_attrs): + _LOGGER.debug( + "%s: Skipping adaptation of %s because no relevant attributes" + " are set in service_data: %s", + self._name, + light, + service_data, + ) + return None + + context = context or self.create_context("adapt_lights") + + return prepare_adaptation_data( + self.hass, + light, + context, + transition if use_transition else 0, + self._send_split_delay / 1000.0, + service_data, + split=self._separate_turn_on_commands, + filter_by_state=self._skip_redundant_commands, + force=force, + ) + + async def _adapt_light( + self, + light: str, + context: Context, + transition: int | None = None, + adapt_brightness: bool | None = None, + adapt_color: bool | None = None, + prefer_rgb_color: bool | None = None, + force: bool = False, + ) -> None: + if (lock := self.manager.turn_off_locks.get(light)) and lock.locked(): + _LOGGER.debug("%s: '%s' is locked", self._name, light) + return + + data = await self.prepare_adaptation_data( + light, + transition, + adapt_brightness, + adapt_color, + prefer_rgb_color, + force, + context, + ) + if data is None: + return # nothing to adapt + + await self.execute_cancellable_adaptation_calls(data) + + async def _execute_adaptation_calls(self, data: AdaptationData): + """Executes a sequence of adaptation service calls for the given service datas.""" + for index in range(data.max_length): + is_first_call = index == 0 + + # Sleep between multiple service calls. + if not is_first_call or data.initial_sleep: + await asyncio.sleep(data.sleep_time) + + # Instead of directly iterating the generator in the while-loop, we get + # the next item here after the sleep to make sure it incorporates state + # changes which happened during the sleep. + service_data = await data.next_service_call_data() + + if not service_data: + # All service datas processed + break + + if ( + not data.force + and not is_on(self.hass, data.entity_id) + # if proactively adapting, we are sure that it came from a `light.turn_on` + and not self.manager.is_proactively_adapting(data.context.id) + ): + # Do a last-minute check if the entity is still on. + _LOGGER.debug( + "%s: Skipping adaptation of %s because it is now off", + self._name, + data.entity_id, + ) + return + + _LOGGER.debug( + "%s: Scheduling 'light.turn_on' with the following 'service_data': %s" + " with context.id='%s'", + self._name, + service_data, + data.context.id, + ) + light = service_data[ATTR_ENTITY_ID] + self.manager.last_service_data[light] = service_data + await self.hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data, + context=data.context, + ) + + async def execute_cancellable_adaptation_calls( + self, + data: AdaptationData, + ): + """Executes a cancellable sequence of adaptation service calls for the given service datas. + + Wraps the sequence of service calls in a task that can be cancelled from elsewhere, e.g., + to cancel an ongoing adaptation when a light is turned off. + """ + # Prevent overlap of multiple adaptation sequences + self.manager.cancel_ongoing_adaptation_calls(data.entity_id, which=data.which) + _LOGGER.debug( + "%s: execute_cancellable_adaptation_calls with data: %s", + self._name, + data, + ) + # Execute adaptation calls within a task + try: + task = asyncio.ensure_future(self._execute_adaptation_calls(data)) + if data.which in ("both", "brightness"): + self.manager.adaptation_tasks_brightness[data.entity_id] = task + if data.which in ("both", "color"): + self.manager.adaptation_tasks_color[data.entity_id] = task + await task + except asyncio.CancelledError: + _LOGGER.debug( + "%s: Ongoing adaptation of %s cancelled, with AdaptationData: %s", + self._name, + data.entity_id, + data, + ) + + async def _update_attrs_and_maybe_adapt_lights( # noqa: PLR0912 + self, + *, + context: Context, + lights: list[str] | None = None, + transition: int | None = None, + force: bool = False, + ) -> None: + assert context is not None + _LOGGER.debug( + "%s: '_update_attrs_and_maybe_adapt_lights' called with context.id='%s'" + " lights: '%s', transition: '%s', force: '%s'", + self._name, + context.id, + lights, + transition, + force, + ) + assert self.is_on + self._settings.update( + self._sun_light_settings.get_settings( + self.sleep_mode_switch.is_on, + transition, + ), + ) + self.async_write_ha_state() + + if not force and self._only_once: + return + + if lights is None: + lights = self.lights + + on_lights = [light for light in lights if is_on(self.hass, light)] + + if force: + filtered_lights = on_lights + else: + filtered_lights = [] + for light in on_lights: + # Don't adapt lights that haven't finished prior transitions. + timer = self.manager.transition_timers.get(light) + if timer is not None and timer.is_running(): + _LOGGER.debug( + "%s: Light '%s' is still transitioning, context.id='%s'", + self._name, + light, + context.id, + ) + elif ( + # This is to prevent lights immediately turning on after + # being turned off in 'interval' update, see #726 + not self._detect_non_ha_changes + and is_our_context(context, "interval") + and (turn_on := self.manager.turn_on_event.get(light)) + and (turn_off := self.manager.turn_off_event.get(light)) + and turn_off.time_fired > turn_on.time_fired + ): + _LOGGER.debug( + "%s: Light '%s' was turned just turned off, context.id='%s'", + self._name, + light, + context.id, + ) + else: + filtered_lights.append(light) + + _LOGGER.debug("%s: filtered_lights: '%s'", self._name, filtered_lights) + if not filtered_lights: + return + + adapt_brightness = self.adapt_brightness_switch.is_on + adapt_color = self.adapt_color_switch.is_on + assert isinstance(adapt_brightness, bool) + assert isinstance(adapt_color, bool) + tasks = [] + for light in filtered_lights: + manually_controlled = ( + self._take_over_control + and self.manager.is_manually_controlled( + self, + light, + force, + adapt_brightness, + adapt_color, + ) + ) + if manually_controlled: + _LOGGER.debug( + "%s: '%s' is being manually controlled, stop adapting, context.id=%s.", + self._name, + light, + context.id, + ) + continue + + significant_change = ( + self._take_over_control + and self._detect_non_ha_changes + and not force + # Note: This call updates the state of the light + # so it might suddenly be off. + and await self.manager.significant_change( + self, + light, + adapt_brightness, + adapt_color, + context, + ) + ) + if significant_change: + _fire_manual_control_event(self, light, context) + continue + + _LOGGER.debug( + "%s: Calling _adapt_light from _update_attrs_and_maybe_adapt_lights:" + " '%s' with transition %s and context.id=%s", + self._name, + light, + transition, + context.id, + ) + coro = self._adapt_light(light, context, transition, force=force) + task = self.hass.async_create_task( + coro, + ) + tasks.append(task) + if tasks: + await asyncio.gather(*tasks) + + async def _respond_to_off_to_on_event(self, entity_id: str, event: Event) -> None: + assert not self.manager.is_proactively_adapting(event.context.id) + from_turn_on = self.manager._off_to_on_state_event_is_from_turn_on( + entity_id, + event, + ) + if ( + self._take_over_control + and not self._detect_non_ha_changes + and not from_turn_on + ): + # There is an edge case where 2 switches control the same light, e.g., + # one for brightness and one for color. Now we will mark both switches + # as manually controlled, which is not 100% correct. + _LOGGER.debug( + "%s: Ignoring 'off' → 'on' event for '%s' with context.id='%s'" + " because 'light.turn_on' was not called by HA and" + " 'detect_non_ha_changes' is False", + self._name, + entity_id, + event.context.id, + ) + self.manager.mark_as_manual_control(entity_id) + return + + if ( + self._take_over_control + and self._adapt_only_on_bare_turn_on + and from_turn_on + # adaptive_lighting.apply can turn on light, so check this is not our context + and not is_our_context(event.context) + ): + service_data = self.manager.turn_on_event[entity_id].data[ATTR_SERVICE_DATA] + if self.manager._mark_manual_control_if_non_bare_turn_on( + entity_id, + service_data, + ): + _LOGGER.debug( + "Skipping responding to 'off' → 'on' event for '%s' with context.id='%s' because" + " we only adapt on bare `light.turn_on` events and not on service_data: '%s'", + entity_id, + event.context.id, + service_data, + ) + return + + if self._adapt_delay > 0: + await asyncio.sleep(self._adapt_delay) + + await self._update_attrs_and_maybe_adapt_lights( + context=self.create_context("light_event", parent=event.context), + lights=[entity_id], + transition=self.initial_transition, + force=True, + ) + + async def _sleep_mode_switch_state_event_action(self, event: Event) -> None: + if not _is_state_event(event, (STATE_ON, STATE_OFF)): + _LOGGER.debug("%s: Ignoring sleep event %s", self._name, event) + return + _LOGGER.debug( + "%s: _sleep_mode_switch_state_event_action, event: '%s'", + self._name, + event, + ) + # Reset the manually controlled status when the "sleep mode" changes + self.manager.reset(*self.lights) + await self._update_attrs_and_maybe_adapt_lights( + context=self.create_context("sleep", parent=event.context), + transition=self._sleep_transition, + force=True, + ) + + +class SimpleSwitch(SwitchEntity, RestoreEntity): + """Representation of a Adaptive Lighting switch.""" + + def __init__( + self, + which: str, + initial_state: bool, + hass: HomeAssistant, + config_entry: ConfigEntry, + icon: str, + ) -> None: + """Initialize the Adaptive Lighting switch.""" + self.hass = hass + data = validate(config_entry) + self._icon = icon + self._state: bool | None = None + self._which = which + name = data[CONF_NAME] + self._unique_id = f"{name}_{slugify(self._which)}" + self._name = f"Adaptive Lighting {which}: {name}" + self._initial_state = initial_state + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def unique_id(self): + """Return the unique ID of entity.""" + return self._unique_id + + @property + def icon(self) -> str: + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def is_on(self) -> bool | None: + """Return true if adaptive lighting is on.""" + return self._state + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + last_state = await self.async_get_last_state() + _LOGGER.debug("%s: last state is %s", self._name, last_state) + if (last_state is None and self._initial_state) or ( + last_state is not None and last_state.state == STATE_ON + ): + await self.async_turn_on() + else: + await self.async_turn_off() + + async def async_turn_on(self, **kwargs) -> None: # noqa: ARG002 + """Turn on adaptive lighting sleep mode.""" + _LOGGER.debug("%s: Turning on", self._name) + self._state = True + + async def async_turn_off(self, **kwargs) -> None: # noqa: ARG002 + """Turn off adaptive lighting sleep mode.""" + _LOGGER.debug("%s: Turning off", self._name) + self._state = False + + +class AdaptiveLightingManager: + """Track 'light.turn_off' and 'light.turn_on' service calls.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the AdaptiveLightingManager that is shared among all switches.""" + assert hass is not None + self.hass = hass + self.lights: set[str] = set() + + # Tracks 'light.turn_off' service calls + self.turn_off_event: dict[str, Event] = {} + # Tracks 'light.turn_on' service calls + self.turn_on_event: dict[str, Event] = {} + # Tracks 'light.toggle' service calls + self.toggle_event: dict[str, Event] = {} + # Tracks 'on' → 'off' state changes + self.on_to_off_event: dict[str, Event] = {} + # Tracks 'off' → 'on' state changes + self.off_to_on_event: dict[str, Event] = {} + # Keep 'asyncio.sleep' tasks that can be cancelled by 'light.turn_on' events + self.sleep_tasks: dict[str, asyncio.Task] = {} + # Locks that prevent light adjusting when waiting for a light to 'turn_off' + self.turn_off_locks: dict[str, asyncio.Lock] = {} + # Tracks which lights are manually controlled + self.manual_control: dict[str, bool] = {} + # Track 'state_changed' events of self.lights resulting from this integration + self.our_last_state_on_change: dict[str, list[State]] = {} + # Track last 'service_data' to 'light.turn_on' resulting from this integration + self.last_service_data: dict[str, dict[str, Any]] = {} + # Track ongoing split adaptations to be able to cancel them + self.adaptation_tasks_brightness: dict[str, asyncio.Task] = {} + self.adaptation_tasks_color: dict[str, asyncio.Task] = {} + + # Track auto reset of manual_control + self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {} + self.auto_reset_manual_control_times: dict[str, float] = {} + + # Track light transitions + self.transition_timers: dict[str, _AsyncSingleShotTimer] = {} + + # Track _execute_cancellable_adaptation_calls tasks + self.adaptation_tasks = set() + + # Setup listeners and its callbacks to remove them later + self.listener_removers = [ + self.hass.bus.async_listen( + EVENT_CALL_SERVICE, + self.turn_on_off_event_listener, + ), + self.hass.bus.async_listen( + EVENT_STATE_CHANGED, + self.state_changed_event_listener, + ), + ] + + self._proactively_adapting_contexts: dict[str, str] = {} + + try: + self.listener_removers.append( + setup_service_call_interceptor( + hass, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + self._service_interceptor_turn_on_handler, + ), + ) + + self.listener_removers.append( + setup_service_call_interceptor( + hass, + LIGHT_DOMAIN, + SERVICE_TOGGLE, + self._service_interceptor_turn_on_handler, + ), + ) + except RuntimeError: + _LOGGER.warning( + "Failed to set up service call interceptors, " + "falling back to event-reactive mode", + exc_info=True, + ) + + def disable(self): + """Disable the listener by removing all subscribed handlers.""" + for remove in self.listener_removers: + remove() + + def set_proactively_adapting(self, context_id: str, entity_id: str) -> None: + """Declare the adaptation with context_id as proactively adapting, + and associate it to an entity_id. + """ # noqa: D205 + self._proactively_adapting_contexts[context_id] = entity_id + + def is_proactively_adapting(self, context_id: str) -> bool: + """Determine whether an adaptation with the given context_id is proactive.""" + is_proactively_adapting_context = ( + context_id in self._proactively_adapting_contexts + ) + + _LOGGER.debug( + "is_proactively_adapting_context='%s', context_id='%s'", + is_proactively_adapting_context, + context_id, + ) + + return is_proactively_adapting_context + + def clear_proactively_adapting(self, entity_id: str) -> None: + """Clear all context IDs associated with the given entity ID. + + Call this method to clear past context IDs and avoid a memory leak. + """ + # First get the keys to avoid modifying the dict while iterating it + keys = [ + k for k, v in self._proactively_adapting_contexts.items() if v == entity_id + ] + for key in keys: + self._proactively_adapting_contexts.pop(key) + + def _separate_entity_ids( + self, + entity_ids: list[str], + data, + ) -> tuple[list[str], list[str]]: + # Create a mapping from switch to entity IDs + # AdaptiveSwitch.name → entity_ids mapping + switch_to_eids: dict[str, list[str]] = {} + # AdaptiveSwitch.name → AdaptiveSwitch mapping + switch_name_mapping: dict[str, AdaptiveSwitch] = {} + # Note: In HA≥2023.5, AdaptiveSwitch is hashable, so we can + # use dict[AdaptiveSwitch, list[str]] + skipped: list[str] = [] + for entity_id in entity_ids: + try: + switch = _switch_with_lights( + self.hass, + [entity_id], + # Do not expand light groups, because HA will make a separate light.turn_on + # call where the lights are expanded, and that call will be intercepted. + expand_light_groups=False, + ) + except NoSwitchFoundError: + # Needs to make the original call but without adaptation + skipped.append(entity_id) + _LOGGER.debug( + "No switch found for entity_id='%s', skipped='%s'", + entity_id, + skipped, + ) + else: + if ( + not switch.is_on + or not switch._intercept + # Never adapt on light groups, because HA will make a separate light.turn_on + or _is_light_group(self.hass.states.get(entity_id)) + # Prevent adaptation of TURN_ON calls when light is already on, + # and of TOGGLE calls when toggling off. + or self.hass.states.is_state(entity_id, STATE_ON) + or self.manual_control.get(entity_id, False) + or ( + switch._take_over_control + and switch._adapt_only_on_bare_turn_on + and self._mark_manual_control_if_non_bare_turn_on( + entity_id, + data[CONF_PARAMS], + ) + ) + ): + _LOGGER.debug( + "Switch is off or light is already on for entity_id='%s', skipped='%s'" + " (is_on='%s', is_state='%s', manual_control='%s', switch._intercept='%s')", + entity_id, + skipped, + switch.is_on, + self.hass.states.is_state(entity_id, STATE_ON), + self.manual_control.get(entity_id, False), + switch._intercept, + ) + skipped.append(entity_id) + else: + switch_to_eids.setdefault(switch.name, []).append(entity_id) + switch_name_mapping[switch.name] = switch + return switch_to_eids, switch_name_mapping, skipped + + def _correct_for_multi_light_intercept( + self, + entity_ids, + switch_to_eids, + switch_name_mapping, + skipped, + ): + # Check for `multi_light_intercept: true/false` + mli = [sw._multi_light_intercept for sw in switch_name_mapping.values()] + more_than_one_switch = len(switch_to_eids) > 1 + single_switch_with_multiple_lights = ( + len(switch_to_eids) == 1 and len(next(iter(switch_to_eids.values()))) > 1 + ) + switch_without_multi_light_intercept = not all(mli) + if more_than_one_switch and switch_without_multi_light_intercept: + _LOGGER.warning( + "Multiple switches (%s) targeted, but not all have" + " `multi_light_intercept: true`, so skipping intercept" + " for all lights.", + switch_to_eids, + ) + skipped = entity_ids + switch_to_eids = {} + elif ( + single_switch_with_multiple_lights and switch_without_multi_light_intercept + ): + _LOGGER.warning( + "Single switch with multiple lights targeted, but" + " `multi_light_intercept: true` is not set, so skipping intercept" + " for all lights.", + switch_to_eids, + ) + skipped = entity_ids + switch_to_eids = {} + return switch_to_eids, switch_name_mapping, skipped + + async def _service_interceptor_turn_on_handler( + self, + call: ServiceCall, + service_data: ServiceData, + ) -> None: + """Intercept `light.turn_on` and `light.toggle` service calls and adapt them. + + It is possible that the calls are made for multiple lights at once, + which in turn might be in different switches or no switches at all. + If there are lights that are not all in a single switch, we need to + make multiple calls to `light.turn_on` with the correct entity IDs. + One of these calls can be intercepted and adapted, the others need to + be adapted by calling `_adapt_light` with the correct entity IDs or + by calling `light.turn_on` directly. + + We create a mapping from switch to entity IDs and keep a list + of skipped lights which are lights in no switches or in switches that + are off or lights that are already on. + + If there is only one switch and 0 skipped lights, we just intercept the + call directly. + + If there are multiple switches and skipped lights, we can adapt the call + for one of the switches to include only the lights in that switch and + need to call `_adapt_light` for the other switches with their + entity_ids. For skipped lights, we call light.turn_on directly with the + entity_ids and original service data. + + If there are only skipped lights, we can use the intercepted call + directly. + """ + is_skipped_hash = is_our_context(call.context, "skipped") + _LOGGER.debug( + "(0) _service_interceptor_turn_on_handler: call.context.id='%s', is_skipped_hash='%s'", + call.context.id, + is_skipped_hash, + ) + if is_our_context(call.context) and not is_skipped_hash: + # Don't adapt our own service calls, but do re-adapt calls that + # were skipped by us + return + + if ( + ATTR_EFFECT in service_data[CONF_PARAMS] + or ATTR_FLASH in service_data[CONF_PARAMS] + ): + return + + _LOGGER.debug( + "(1) _service_interceptor_turn_on_handler: call='%s', service_data='%s'", + call, + service_data, + ) + + # Because `_service_interceptor_turn_on_single_light_handler` modifies the + # original service data, we need to make a copy of it to use in the `skipped` call + service_data_copy = deepcopy(service_data) + + entity_ids = self._get_entity_list(service_data) + # Note: we do not expand light groups anywhere in this method, instead + # we skip them and rely on the followup call that HA will make + # with the expanded entity IDs. + + switch_to_eids, switch_name_mapping, skipped = self._separate_entity_ids( + entity_ids, + service_data, + ) + + ( + switch_to_eids, + switch_name_mapping, + skipped, + ) = self._correct_for_multi_light_intercept( + entity_ids, + switch_to_eids, + switch_name_mapping, + skipped, + ) + _LOGGER.debug( + "(2) _service_interceptor_turn_on_handler: switch_to_eids='%s', skipped='%s'", + switch_to_eids, + skipped, + ) + + def modify_service_data(service_data, entity_ids): + """Modify the service data to contain the entity IDs.""" + service_data.pop(ATTR_ENTITY_ID, None) + service_data.pop(ATTR_AREA_ID, None) + service_data[ATTR_ENTITY_ID] = entity_ids + return service_data + + # Intercept the call for first switch and call _adapt_light for the rest + has_intercepted = False # Can only intercept a turn_on call once + for adaptive_switch_name, _entity_ids in switch_to_eids.items(): + switch = switch_name_mapping[adaptive_switch_name] + transition = service_data[CONF_PARAMS].get( + ATTR_TRANSITION, + switch.initial_transition, + ) + if not has_intercepted: + _LOGGER.debug( + "(3) _service_interceptor_turn_on_handler: intercepting entity_ids='%s'", + _entity_ids, + ) + await self._service_interceptor_turn_on_single_light_handler( + entity_ids=_entity_ids, + switch=switch, + transition=transition, + call=call, + data=modify_service_data(service_data, _entity_ids), + ) + has_intercepted = True + continue + + for eid in _entity_ids: + # Must add a new context otherwise _adapt_light will bail out + context = switch.create_context("intercept") + self.clear_proactively_adapting(eid) + self.set_proactively_adapting(context.id, eid) + _LOGGER.debug( + "(4) _service_interceptor_turn_on_handler: calling `_adapt_light` with eid='%s', context='%s', transition='%s'", + eid, + context, + transition, + ) + await switch._adapt_light( + light=eid, + context=context, + transition=transition, + ) + + # Call light.turn_on service for skipped entities + if skipped: + if not has_intercepted: + assert set(skipped) == set(entity_ids) + return # The call will be intercepted with the original data + # Call light turn_on service for skipped entities + context = switch.create_context("skipped") + _LOGGER.debug( + "(5) _service_interceptor_turn_on_handler: calling `light.turn_on` with skipped='%s', service_data: '%s', context='%s'", + skipped, + service_data_copy, # This is the original service data + context.id, + ) + service_data = {ATTR_ENTITY_ID: skipped, **service_data_copy[CONF_PARAMS]} + if ( + ATTR_COLOR_TEMP in service_data + and ATTR_COLOR_TEMP_KELVIN in service_data + ): + # ATTR_COLOR_TEMP and ATTR_COLOR_TEMP_KELVIN are mutually exclusive + del service_data[ATTR_COLOR_TEMP] + await self.hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data, + blocking=True, + context=context, + ) + + async def _service_interceptor_turn_on_single_light_handler( + self, + entity_ids: list[str], + switch: AdaptiveSwitch, + transition: int, + call: ServiceCall, + data: ServiceData, + ): + _LOGGER.debug( + "Intercepted TURN_ON call with data %s (%s)", + data, + call.context.id, + ) + + # Reset because turning on the light, this also happens in + # `state_changed_event_listener`, however, this function is called + # before that one. + self.reset(*entity_ids, reset_manual_control=False) + for entity_id in entity_ids: + self.clear_proactively_adapting(entity_id) + + adaptation_data = await switch.prepare_adaptation_data( + entity_id, + transition, + ) + if adaptation_data is None: + return + + # Take first adaptation item to apply it to this service call + first_service_data = await adaptation_data.next_service_call_data() + + if not first_service_data: + return + + # Update/adapt service call data + first_service_data.pop(ATTR_ENTITY_ID, None) + # This is called as a preprocessing step by the schema validation of the original + # service call and needs to be repeated here to also process the added adaptation data. + # (A more generic alternative would be re-executing the validation, but that is more + # complicated and unstable because it requires transformation of the data object back + # into its original service call structure which cannot be reliably done due to the + # lack of a bijective mapping.) + preprocess_turn_on_alternatives(self.hass, first_service_data) + data[CONF_PARAMS].update(first_service_data) + + # Schedule additional service calls for the remaining adaptation data. + # We cannot know here whether there is another call to follow (since the + # state can change until the next call), so we just schedule it and let + # it sort out by itself. + for entity_id in entity_ids: + self.set_proactively_adapting(call.context.id, entity_id) + self.set_proactively_adapting(adaptation_data.context.id, entity_id) + adaptation_data.initial_sleep = True + + # Don't await to avoid blocking the service call. + # Assign to a variable only to await in tests. + self.adaptation_tasks.add( + asyncio.create_task( + switch.execute_cancellable_adaptation_calls(adaptation_data), + ), + ) + # Remove tasks that are done + if done_tasks := [t for t in self.adaptation_tasks if t.done()]: + self.adaptation_tasks.difference_update(done_tasks) + + def _handle_timer( + self, + light: str, + timers_dict: dict[str, _AsyncSingleShotTimer], + delay: float | None, + reset_coroutine: Callable[[], Coroutine[Any, Any, None]], + ) -> None: + timer = timers_dict.get(light) + if timer is not None: + if delay is None: # Timer object exists, but should not anymore + timer.cancel() + timers_dict.pop(light) + else: # Timer object already exists, just update the delay and restart it + timer.delay = delay + timer.start() + elif delay is not None: # Timer object does not exist, create it + timer = _AsyncSingleShotTimer(delay, reset_coroutine) + timers_dict[light] = timer + timer.start() + + def start_transition_timer(self, light: str) -> None: + """Mark a light as manually controlled.""" + last_service_data = self.last_service_data.get(light) + if last_service_data is None: + _LOGGER.debug( + "No last service data for light %s, not starting timer.", + light, + ) + return + + last_transition = last_service_data.get(ATTR_TRANSITION) + if not last_transition: + _LOGGER.debug( + "No transition in last adapt for light %s, not starting timer.", + light, + ) + return + + _LOGGER.debug( + "Start transition timer of %s seconds for light %s", + last_transition, + light, + ) + + async def reset(): + # Called when the timer expires, doesn't need to do anything + _LOGGER.debug( + "Transition finished for light %s", + light, + ) + + self._handle_timer(light, self.transition_timers, last_transition, reset) + + def set_auto_reset_manual_control_times(self, lights: list[str], time: float): + """Set the time after which the lights are automatically reset.""" + if time == 0: + return + for light in lights: + old_time = self.auto_reset_manual_control_times.get(light) + if (old_time is not None) and (old_time != time): + _LOGGER.info( + "Setting auto_reset_manual_control for '%s' from %s seconds to %s seconds." + " This might happen because the light is in multiple swiches" + " or because of a config change.", + light, + old_time, + time, + ) + self.auto_reset_manual_control_times[light] = time + + def mark_as_manual_control(self, light: str) -> None: + """Mark a light as manually controlled.""" + _LOGGER.debug("Marking '%s' as manually controlled.", light) + self.manual_control[light] = True + delay = self.auto_reset_manual_control_times.get(light) + + async def reset(): + _LOGGER.debug( + "Auto resetting 'manual_control' status of '%s' because" + " it was not manually controlled for %s seconds.", + light, + delay, + ) + self.reset(light) + switches = _switches_with_lights(self.hass, [light]) + for switch in switches: + if not switch.is_on: + continue + await switch._update_attrs_and_maybe_adapt_lights( + context=switch.create_context("autoreset"), + lights=[light], + transition=switch.initial_transition, + force=True, + ) + assert not self.manual_control[light] + + self._handle_timer(light, self.auto_reset_manual_control_timers, delay, reset) + + def cancel_ongoing_adaptation_calls( + self, + light_id: str, + which: Literal["color", "brightness", "both"] = "both", + ): + """Cancel ongoing adaptation service calls for a specific light entity.""" + brightness_task = self.adaptation_tasks_brightness.get(light_id) + color_task = self.adaptation_tasks_color.get(light_id) + if ( + which in ("both", "brightness") + and brightness_task is not None + and not brightness_task.done() + ): + _LOGGER.debug( + "Cancelled ongoing brightness adaptation calls (%s) for '%s'", + brightness_task, + light_id, + ) + brightness_task.cancel() + if ( + which in ("both", "color") + and color_task is not None + and color_task is not brightness_task + and not color_task.done() + ): + _LOGGER.debug( + "Cancelled ongoing color adaptation calls (%s) for '%s'", + color_task, + light_id, + ) + # color_task might be the same as brightness_task + color_task.cancel() + + def reset(self, *lights, reset_manual_control: bool = True) -> None: + """Reset the 'manual_control' status of the lights.""" + for light in lights: + if reset_manual_control: + self.manual_control[light] = False + if timer := self.auto_reset_manual_control_timers.pop(light, None): + timer.cancel() + self.our_last_state_on_change.pop(light, None) + self.last_service_data.pop(light, None) + self.cancel_ongoing_adaptation_calls(light) + + def _get_entity_list(self, service_data: ServiceData) -> list[str]: + if ATTR_ENTITY_ID in service_data: + return cv.ensure_list_csv(service_data[ATTR_ENTITY_ID]) + if ATTR_AREA_ID in service_data: + entity_ids = [] + area_ids = cv.ensure_list_csv(service_data[ATTR_AREA_ID]) + for area_id in area_ids: + area_entity_ids = area_entities(self.hass, area_id) + eids = [ + entity_id + for entity_id in area_entity_ids + if entity_id.startswith(LIGHT_DOMAIN) + ] + entity_ids.extend(eids) + _LOGGER.debug( + "Found entity_ids '%s' for area_id '%s'", + entity_ids, + area_id, + ) + return entity_ids + _LOGGER.debug( + "No entity_ids or area_ids found in service_data: %s", + service_data, + ) + return [] + + async def turn_on_off_event_listener(self, event: Event) -> None: + """Track 'light.turn_off' and 'light.turn_on' service calls.""" + domain = event.data.get(ATTR_DOMAIN) + if domain != LIGHT_DOMAIN: + return + + service = event.data[ATTR_SERVICE] + service_data = event.data[ATTR_SERVICE_DATA] + entity_ids = self._get_entity_list(service_data) + + if not any(eid in self.lights for eid in entity_ids): + return + + def off(eid: str, event: Event): + self.turn_off_event[eid] = event + self.reset(eid) + + def on(eid: str, event: Event): + task = self.sleep_tasks.get(eid) + if task is not None: + task.cancel() + self.turn_on_event[eid] = event + timer = self.auto_reset_manual_control_timers.get(eid) + if ( + timer is not None + and timer.is_running() + and event.time_fired > timer.start_time # type: ignore[operator] + ): + # Restart the auto reset timer + timer.start() + + if service == SERVICE_TURN_OFF: + transition = service_data.get(ATTR_TRANSITION) + _LOGGER.debug( + "Detected an 'light.turn_off('%s', transition=%s)' event with context.id='%s'", + entity_ids, + transition, + event.context.id, + ) + for eid in entity_ids: + off(eid, event) + + elif service == SERVICE_TURN_ON: + _LOGGER.debug( + "Detected an 'light.turn_on('%s')' event with context.id='%s'", + entity_ids, + event.context.id, + ) + for eid in entity_ids: + on(eid, event) + + elif service == SERVICE_TOGGLE: + _LOGGER.debug( + "Detected an 'light.toggle('%s')' event with context.id='%s'", + entity_ids, + event.context.id, + ) + for eid in entity_ids: + state = self.hass.states.get(eid).state + self.toggle_event[eid] = event + if state == STATE_ON: # is turning off + off(eid, event) + elif state == STATE_OFF: # is turning on + on(eid, event) + + async def state_changed_event_listener(self, event: Event) -> None: + """Track 'state_changed' events.""" + entity_id = event.data.get(ATTR_ENTITY_ID, "") + if entity_id not in self.lights: + return + + old_state = event.data.get("old_state") + new_state = event.data.get("new_state") + + new_on = new_state is not None and new_state.state == STATE_ON + new_off = new_state is not None and new_state.state == STATE_OFF + old_on = old_state is not None and old_state.state == STATE_ON + old_off = old_state is not None and old_state.state == STATE_OFF + + if new_on: + _LOGGER.debug( + "Detected a '%s' 'state_changed' event: '%s' with context.id='%s'", + entity_id, + new_state.attributes, + new_state.context.id, + ) + # It is possible to have multiple state change events with the same context. + # This can happen because a `turn_on.light(brightness_pct=100, transition=30)` + # event leads to an instant state change of + # `new_state=dict(brightness=100, ...)`. However, after polling the light + # could still only be `new_state=dict(brightness=50, ...)`. + # We save all events because the first event change might indicate at what + # settings the light will be later *or* the second event might indicate a + # final state. The latter case happens for example when a light was + # called with a color_temp outside of its range (and HA reports the + # incorrect 'min_kelvin' and 'max_kelvin', which happens e.g., for + # Philips Hue White GU10 Bluetooth lights). + last_state: list[State] | None = self.our_last_state_on_change.get( + entity_id, + ) + if is_our_context(new_state.context): + if ( + last_state is not None + and last_state[0].context.id == new_state.context.id + ): + _LOGGER.debug( + "AdaptiveLightingManager: State change event of '%s' is already" + " in 'self.our_last_state_on_change' (%s)" + " adding this state also", + entity_id, + new_state.context.id, + ) + self.our_last_state_on_change[entity_id].append(new_state) + else: + _LOGGER.debug( + "AdaptiveLightingManager: New adapt '%s' found for %s", + new_state, + entity_id, + ) + self.our_last_state_on_change[entity_id] = [new_state] + self.start_transition_timer(entity_id) + elif last_state is not None: + self.our_last_state_on_change[entity_id].append(new_state) + + if old_on and new_off: + # Tracks 'on' → 'off' state changes + self.on_to_off_event[entity_id] = event + self.reset(entity_id) + _LOGGER.debug( + "Detected an 'on' → 'off' event for '%s' with context.id='%s'", + entity_id, + event.context.id, + ) + elif old_off and new_on: + # Tracks 'off' → 'on' state changes + self.off_to_on_event[entity_id] = event + _LOGGER.debug( + "Detected an 'off' → 'on' event for '%s' with context.id='%s'", + entity_id, + event.context.id, + ) + + if self.is_proactively_adapting(event.context.id): + _LOGGER.debug( + "Skipping responding to 'off' → 'on' event for '%s' with context.id='%s' because" + " we are already proactively adapting", + entity_id, + event.context.id, + ) + # Note: the reset below already happened in `_service_interceptor_turn_on_handler` + return + + self.reset(entity_id, reset_manual_control=False) + lock = self.turn_off_locks.setdefault(entity_id, asyncio.Lock()) + async with lock: + if await self.just_turned_off(entity_id): + # Stop if a rapid 'off' → 'on' → 'off' happens. + _LOGGER.debug( + "Cancelling adjusting lights for %s", + entity_id, + ) + return + + switches = _switches_with_lights(self.hass, [entity_id]) + for switch in switches: + if switch.is_on: + await switch._respond_to_off_to_on_event( + entity_id, + event, + ) + + def is_manually_controlled( + self, + switch: AdaptiveSwitch, + light: str, + force: bool, + adapt_brightness: bool, + adapt_color: bool, + ) -> bool: + """Check if the light has been 'on' and is now manually controlled.""" + manual_control = self.manual_control.setdefault(light, False) + if manual_control: + # Manually controlled until light is turned on and off + return True + + turn_on_event = self.turn_on_event.get(light) + if ( + turn_on_event is not None + and not self.is_proactively_adapting(turn_on_event.context.id) + and not is_our_context(turn_on_event.context) + and not force + ): + keys = turn_on_event.data[ATTR_SERVICE_DATA].keys() + if ( + (adapt_color and COLOR_ATTRS.intersection(keys)) + or (adapt_brightness and BRIGHTNESS_ATTRS.intersection(keys)) + or (ATTR_FLASH in keys) + or (ATTR_EFFECT in keys) + ): + # Light was already on and 'light.turn_on' was not called by + # the adaptive_lighting integration. + manual_control = True + _fire_manual_control_event(switch, light, turn_on_event.context) + _LOGGER.debug( + "'%s' was already on and 'light.turn_on' was not called by the" + " adaptive_lighting integration (context.id='%s'), the Adaptive" + " Lighting will stop adapting the light until the switch or the" + " light turns off and then on again.", + light, + turn_on_event.context.id, + ) + return manual_control + + async def significant_change( + self, + switch: AdaptiveSwitch, + light: str, + adapt_brightness: bool, + adapt_color: bool, + context: Context, # just for logging + ) -> bool: + """Has the light made a significant change since last update. + + This method will detect changes that were made to the light without + calling 'light.turn_on', so outside of Home Assistant. If a change is + detected, we mark the light as 'manually controlled' until the light + or switch is turned 'off' and 'on' again. + """ + assert switch._detect_non_ha_changes + + last_service_data = self.last_service_data.get(light) + if last_service_data is None: + return False + # Update state and check for a manual change not done in HA. + # Ensure HASS is correctly updating your light's state with + # light.turn_on calls if any problems arise. This + # can happen e.g. using zigbee2mqtt with 'report: false' in device settings. + await self.hass.helpers.entity_component.async_update_entity(light) + refreshed_state = self.hass.states.get(light) + assert refreshed_state is not None + + changed = _attributes_have_changed( + old_attributes=last_service_data, + new_attributes=refreshed_state.attributes, + light=light, + adapt_brightness=adapt_brightness, + adapt_color=adapt_color, + context=context, + ) + if changed: + _LOGGER.debug( + "%s: State attributes of '%s' changed (%s) wrt 'last_service_data' (%s) (context.id=%s)", + switch._name, + light, + refreshed_state.attributes, + last_service_data, + context.id, + ) + return True + _LOGGER.debug( + "%s: State attributes of '%s' did not change (%s) wrt 'last_service_data' (%s) (context.id=%s)", + switch._name, + light, + refreshed_state.attributes, + last_service_data, + context.id, + ) + return False + + def _off_to_on_state_event_is_from_turn_on( + self, + entity_id: str, + off_to_on_event: Event, + ) -> bool: + # Adaptive Lighting should never turn on lights itself + if is_our_context(off_to_on_event.context) and not is_our_context( + off_to_on_event.context, + "service", # adaptive_lighting.apply is allowed to turn on lights + ): + _LOGGER.warning( + "Detected an 'off' → 'on' event for '%s' with context.id='%s' and" + " event='%s', triggered by the adaptive_lighting integration itself," + " which *should* not happen. If you see this please submit an issue with" + " your full logs at https://github.com/basnijholt/adaptive-lighting", + entity_id, + off_to_on_event.context.id, + off_to_on_event, + ) + turn_on_event: Event | None = self.turn_on_event.get(entity_id) + id_off_to_on = off_to_on_event.context.id + return ( + turn_on_event is not None + and id_off_to_on is not None + and id_off_to_on == turn_on_event.context.id + ) + + async def just_turned_off( # noqa: PLR0911, PLR0912 + self, + entity_id: str, + ) -> bool: + """Cancel the adjusting of a light if it has just been turned off. + + Possibly the lights just got a 'turn_off' call, however, the light + is actually still turning off (e.g., because of a 'transition') and + HA polls the light before the light is 100% off. This might trigger + a rapid switch 'off' → 'on' → 'off'. To prevent this component + from interfering on the 'on' state, we make sure to wait at least + TURNING_OFF_DELAY (or the 'turn_off' transition time) between a + 'off' → 'on' event and then check whether the light is still 'on' or + if the brightness is still decreasing. Only if it is the case we + adjust the lights. + """ + off_to_on_event = self.off_to_on_event[entity_id] + on_to_off_event = self.on_to_off_event.get(entity_id) + + if on_to_off_event is None: + _LOGGER.debug( + "just_turned_off: No 'on' → 'off' state change has been registered before for '%s'." + " It's possible that the light was already on when Home Assistant was turned on.", + entity_id, + ) + return False + + if off_to_on_event.context.id == on_to_off_event.context.id: + _LOGGER.debug( + "just_turned_off: 'on' → 'off' state change has the same context.id as the" + " 'off' → 'on' state change for '%s'. This is probably a false positive.", + entity_id, + ) + return True + + id_on_to_off = on_to_off_event.context.id + + turn_off_event = self.turn_off_event.get(entity_id) + if turn_off_event is not None: + transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION) + else: + transition = None + + if self._off_to_on_state_event_is_from_turn_on(entity_id, off_to_on_event): + is_toggle = off_to_on_event == self.toggle_event.get(entity_id) + from_service = "light.toggle" if is_toggle else "light.turn_on" + _LOGGER.debug( + "just_turned_off: State change 'off' → 'on' triggered by '%s'", + from_service, + ) + return False + + if ( + turn_off_event is not None + and id_on_to_off == turn_off_event.context.id + and id_on_to_off is not None + and transition is not None # 'turn_off' is called with transition=... + ): + # State change 'on' → 'off' and 'light.turn_off(..., transition=...)' come + # from the same event, so wait at least the 'turn_off' transition time. + delay = max(transition, TURNING_OFF_DELAY) + else: + # State change 'off' → 'on' happened because the light state was set. + # Possibly because of polling. + delay = TURNING_OFF_DELAY + + delta_time = (dt_util.utcnow() - on_to_off_event.time_fired).total_seconds() + if delta_time > delay: + _LOGGER.debug( + "just_turned_off: delta_time='%s' > delay='%s'", + delta_time, + delay, + ) + return False + + # Here we could just `return True` but because we want to prevent any updates + # from happening to this light (through async_track_time_interval or + # sleep_state) for some time, we wait below until the light + # is 'off' or the time has passed. + + delay -= delta_time # delta_time has passed since the 'off' → 'on' event + _LOGGER.debug( + "just_turned_off: Waiting with adjusting '%s' for %s", + entity_id, + delay, + ) + total_sleep = 0 + for _ in range(3): + # It can happen that the actual transition time is longer than the + # specified time in the 'turn_off' service. + coro = asyncio.sleep(delay) + total_sleep += delay + task = self.sleep_tasks[entity_id] = asyncio.ensure_future(coro) + try: + await task + except asyncio.CancelledError: # 'light.turn_on' has been called + _LOGGER.debug( + "just_turned_off: Sleep task is cancelled due to 'light.turn_on('%s')' call", + entity_id, + ) + return False + + if not is_on(self.hass, entity_id): + _LOGGER.debug( + "just_turned_off: '%s' is off after %s seconds, cancelling adaptation", + entity_id, + total_sleep, + ) + return True + delay = TURNING_OFF_DELAY # next time only wait this long + + if transition is not None: + # Always ignore when there's a 'turn_off' transition. + # Because it seems like HA cannot detect whether a light is + # transitioning into 'off'. Maybe needs some discussion/input? + return True + + # Now we assume that the lights are still on and they were intended + # to be on. + _LOGGER.debug( + "just_turned_off: '%s' is still on after %s seconds, assuming it was intended to be on", + entity_id, + total_sleep, + ) + return False + + def _mark_manual_control_if_non_bare_turn_on( + self, + entity_id: str, + service_data: ServiceData, + ) -> bool: + _LOGGER.debug( + "_mark_manual_control_if_non_bare_turn_on: entity_id='%s', service_data='%s'", + entity_id, + service_data, + ) + if any(attr in service_data for attr in COLOR_ATTRS | BRIGHTNESS_ATTRS): + self.mark_as_manual_control(entity_id) + return True + return False + + +class _AsyncSingleShotTimer: + def __init__(self, delay, callback) -> None: + """Initialize the timer.""" + self.delay = delay + self.callback = callback + self.task = None + self.start_time: datetime.datetime | None = None + + async def _run(self): + """Run the timer. Don't call this directly, use start() instead.""" + self.start_time = dt_util.utcnow() + await asyncio.sleep(self.delay) + if self.callback: + if asyncio.iscoroutinefunction(self.callback): + await self.callback() + else: + self.callback() + + def is_running(self): + """Return whether the timer is running.""" + return self.task is not None and not self.task.done() + + def start(self): + """Start the timer.""" + if self.task is not None and not self.task.done(): + self.task.cancel() + self.task = asyncio.create_task(self._run()) + + def cancel(self): + """Cancel the timer.""" + if self.task: + self.task.cancel() + self.callback = None + + def remaining_time(self): + """Return the remaining time before the timer expires.""" + if self.start_time is not None: + elapsed_time = (dt_util.utcnow() - self.start_time).total_seconds() + return max(0, self.delay - elapsed_time) + return 0 diff --git a/custom_components/adaptive_lighting/translations/cs.json b/custom_components/adaptive_lighting/translations/cs.json new file mode 100644 index 000000000..58f8feade --- /dev/null +++ b/custom_components/adaptive_lighting/translations/cs.json @@ -0,0 +1,57 @@ +{ + "title": "Adaptivní osvětlení", + "config": { + "step": { + "user": { + "title": "Vyberte název instance Adaptivního osvětlení", + "description": "Vyberte název pro tuto instanci. Můžete spustit několik instancí Adaptivního osvětlení, každá z nich může obsahovat více světel!", + "data": { + "name": "Název" + } + } + }, + "abort": { + "already_configured": "Toto zařízení je již nakonfigurováno" + } + }, + "options": { + "step": { + "init": { + "title": "Nastavení adaptivního osvětlení", + "description": "Všechna nastavení komponenty Adaptivního osvětlení. Názvy možností odpovídají nastavení YAML. Pokud máte v konfiguraci YAML definovánu položku 'adaptive_lighting', nezobrazí se žádné možnosti.", + "data": { + "lights": "osvětlení", + "initial_transition": "initial_transition: Prodlení pro změnu z 'vypnuto' do 'zapnuto' (sekundy)", + "sleep_transition": "sleep_transition: Prodleva pro přepnutí do „režimu spánku“ (sekundy)", + "interval": "interval: Prodleva pro změny osvětlení (v sekundách)", + "max_brightness": "max_brightness: Nejvyšší jas osvětlení během cyklu. (%)", + "max_color_temp": "max_color_temp: Nejchladnější odstín cyklu teploty barev. (Kelvin)", + "min_brightness": "min_brightness: Nejnižší jas osvětlení během cyklu. (%)", + "min_color_temp": "min_color_temp, Nejteplejší odstín cyklu teploty barev. (Kelvin)", + "only_once": "only_once: Přizpůsobení osvětlení pouze při rozsvícení.", + "prefer_rgb_color": "prefer_rgb_color: Upřednostněte použití 'rgb_color' před 'color_temp'.", + "separate_turn_on_commands": "separate_turn_on_commands: Oddělení příkazů pro každý atribut (barva, jas, atd.) v atributu 'light.turn_on' (vyžadováno pro některá světla).", + "send_split_delay": "send_split_delay: prodleva mezi příkazy (milisekundy), když je použit atribut 'separate_turn_on_commands'. Může zajistit správné zpracování obou příkazů.", + "sleep_brightness": "sleep_brightness, Nastavení jasu pro režim spánku. (%)", + "sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp, použijte 'rgb_color' nebo 'color_temp'", + "sleep_rgb_color": "sleep_rgb_color, v RGB", + "sleep_color_temp": "sleep_color_temp: Nastavení teploty barev pro režim spánku. (v Kelvinech)", + "sunrise_offset": "sunrise_offset: Jak dlouho před (-) nebo po (+) definovat bod cyklu východu slunce (+/- v sekundách)", + "sunrise_time": "sunrise_time: Manuální přepsání času východu slunce, pokud je „None“, použije se skutečný čas východu slunce ve vaší lokalitě (HH:MM:SS)", + "max_sunrise_time": "max_sunrise_time: Ruční přepsání nejpozdějšího času východu slunce, pokud je „None“, použije se skutečný čas východu slunce vaší lokality (HH:MM:SS)", + "sunset_offset": "sunset_offset: Jak dlouho před (-) nebo po (+) definovat bod cyklu západu slunce (+/- v sekundách)", + "sunset_time": "sunset_time: Ruční přepsání času západu slunce, pokud je „None“, použije se skutečný čas západu slunce vaší lokality (HH:MM:SS)", + "min_sunset_time": "min_sunset_time: Ruční přepsání nejdřívějšího času západu slunce, pokud je „None“, použije se skutečný čas západu slunce vaší lokality (HH:MM:SS)", + "take_over_control": "take_over_control: Je-li volán 'light.turn_on' z jiného zdroje, než Adaptivním osvětlením, když je světlo již rozsvíceno, přestaňte toto světlo ovládat, dokud není vypnuto -> zapnuto (nebo i vypínačem).", + "detect_non_ha_changes": "detect_non_ha_changes: detekuje všechny změny >10% provedených pro osvětlení (také mimo HA), vyžaduje povolení atributu 'take_over_control' (každý 'interval' spouští 'homeassistant.update_entity'!)", + "transition": "transition: doba přechodu při změně osvětlení (sekundy)", + "adapt_delay": "adapt_delay: prodleva mezi zapnutím světla ( sekundy) a projevem změny v Adaptivní osvětlení. Může předcházet blikání." + } + } + }, + "error": { + "option_error": "Neplatná možnost", + "entity_missing": "V aplikaci Home Assistant chybí jedna nebo více vybraných entit osvětlení" + } + } +} diff --git a/custom_components/adaptive_lighting/translations/da.json b/custom_components/adaptive_lighting/translations/da.json new file mode 100644 index 000000000..5b63c115b --- /dev/null +++ b/custom_components/adaptive_lighting/translations/da.json @@ -0,0 +1,50 @@ +{ + "title": "Adaptiv Belysning", + "config": { + "step": { + "user": { + "title": "Vælg et navn for denne Adaptive Belysning", + "description": "Vælg et navn til denne konfiguration. Du kan køre flere konfigurationer af Adaptiv Belysning, og hver af dem kan indeholde flere lys!", + "data": { + "name": "Navn" + } + } + }, + "abort": { + "already_configured": "Denne enhed er allerede konfigureret" + } + }, + "options": { + "step": { + "init": { + "title": "Adaptiv Belysnings indstillinger", + "description": "Alle indstillinger tilhørende en Adaptiv Belysnings komponent. Indstillingernes navne svarer til YAML indstillingernes. Ingen indstillinger vises hvis du allerede har konfigureret den i YAML.", + "data": { + "lights": "lights: lyskilder", + "initial_transition": "initial_transition: Hvor lang overgang når lyset går fra 'off' til 'on' eller når 'sleep_state' skiftes. (i sekunder)", + "interval": "interval: Tid imellem opdateringer (i sekunder)", + "max_brightness": "max_brightness: Højeste lysstyrke i cyklussen. (%)", + "max_color_temp": "max_color_temp: Koldeste lystemperatur i cyklussen. (Kelvin)", + "min_brightness": "min_brightness: Laveste lysstyrke i cyklussen. (%)", + "min_color_temp": "min_color_temp: Varmeste lystemperatur i cyklussen. (Kelvin)", + "only_once": "only_once: Juster udelukkende lysene adaptivt i øjeblikket de tændes.", + "prefer_rgb_color": "prefer_rgb_color: Brug 'rgb_color' istedet for 'color_temp' når muligt.", + "separate_turn_on_commands": "separate_turn_on_commands: Adskil kommandoerne for hver attribut (color, brightness, etc.) ved 'light.turn_on' (nødvendigt for bestemte lys).", + "sleep_brightness": "sleep_brightness, Lysstyrke for Sleep Mode. (%)", + "sleep_color_temp": "sleep_color_temp: Farvetemperatur under Sleep Mode. (Kelvin)", + "sunrise_offset": "sunrise_offset: Hvor længe før (-) eller efter (+) at definere solopgangen i cyklussen (+/- sekunder)", + "sunrise_time": "sunrise_time: Manuel overstyring af solopgangstidspunktet, hvis 'None', bruges det egentlige tidspunkt din lokation. (HH:MM:SS)", + "sunset_offset": "sunset_offset: Hvor længe før (-) eller efter (+) at definere solnedgangen i cyklussen (+/- sekunder)", + "sunset_time": "sunset_time: Manuel overstyring af solnedgangstidspunktet, hvis 'None', bruges det egentlige tidspunkt for din lokation. (HH:MM:SS)", + "take_over_control": "take_over_control: Hvis andet end Adaptiv Belysning kalder 'light.turn_on' på et lys der allerede er tændt, afbryd adaptering af lyset indtil at det tændes igen.", + "detect_non_ha_changes": "detect_non_ha_changes: Registrer alle ændringer på >10% på et lys (også udenfor HA), kræver at 'take_over_control' er slået til (kalder 'homeassistant.update_entity' hvert 'interval'!)", + "transition": "Overgangsperiode når en ændring i lyset udføres (i sekunder)" + } + } + }, + "error": { + "option_error": "Ugyldig indstilling", + "entity_missing": "Et udvalgt lys blev ikke fundet " + } + } +} diff --git a/custom_components/adaptive_lighting/translations/de.json b/custom_components/adaptive_lighting/translations/de.json new file mode 100644 index 000000000..79eb0df81 --- /dev/null +++ b/custom_components/adaptive_lighting/translations/de.json @@ -0,0 +1,58 @@ +{ + "title": "Adaptive Lighting", + "config": { + "step": { + "user": { + "title": "Benenne das Adaptive Lighting", + "description": "Jede Instanz kann mehrere Licht Entitäten beinhalten", + "data": { + "name": "Name" + } + } + }, + "abort": { + "already_configured": "Gerät ist bereits konfiguriert!" + } + }, + "options": { + "step": { + "init": { + "title": "Adaptive Lighting Optionen", + "description": "Alle Einstellungen für eine Adaptive Lighting Komponente. Die Optionsnamen entsprechen den YAML-Einstellungen. Es werden keine Optionen angezeigt, wenn dieser Eintrag in YAML konfiguriert wurde.", + "data": { + "lights": "Lichter", + "initial_transition": "initial_transition, wenn Lichter von 'off' zu 'on' wechseln oder wenn 'sleep_state' wechselt", + "sleep_transition": "sleep_transition: Wenn 'sleep_state' sich ändert. (Sekunden)", + "interval": "interval, Zeit zwischen Updates des Switches", + "max_brightness": "max_brightness, maximale Helligkeit in %", + "max_color_temp": "max_color_temp, maximale Farbtemperatur in Kelvin", + "min_brightness": "min_brightness, minimale Helligkeit in %", + "min_color_temp": "min_color_temp, minimale Farbtemperatur in Kelvin", + "only_once": "only_once, passe die Lichter nur beim Einschalten an", + "prefer_rgb_color": "prefer_rgb_color, nutze 'rgb_color' vor 'color_temp', wenn möglich", + "separate_turn_on_commands": "separate_turn_on_commands, für jedes Attribut (Farbe, Helligkeit usw.) in 'light.turn_on' werden separate Befehle gesendet. Wird für manche Leuchtmittel benötigt.", + "send_split_delay": "send_split_delay: Wartezeit zwischen dem Senden der Befehle (Millisekunden), wenn separate_turn_on_commands genutzt wird. Kann helfen, wenn die Leuchtmittel die separaten Befehle nicht korrekt umsetzen.", + "sleep_brightness": "sleep_brightness, Schlafhelligkeit in %", + "sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp, nutze 'rgb_color' oder 'color_temp'", + "sleep_rgb_color": "sleep_rgb_color, in RGB", + "sleep_color_temp": "sleep_color_temp, Schlaffarbtemperatur in Kelvin", + "sunrise_offset": "sunrise_offset, Sonnenaufgang Verschiebung in +/- Sekunden", + "sunrise_time": "sunrise_time, Sonnenaufgangszeit in 'HH:MM:SS' Format (wenn 'None' wird die aktuelle Zeit des Sonnenaufgangs an deiner Position verwendet)", + "max_sunrise_time": "max_sunrise_time: Manuelles Überschreiben der max. sunrise_time. Falls 'None', wird die tatsächliche sunrise_time an deiner Position verwendet (HH:MM:SS)", + "sunset_offset": "sunset_offset, Sonnenuntergang Verschiebung in +/- Sekunden", + "sunset_time": "sunset_time, Sonnenuntergangszeit in 'HH:MM:SS' Format (wenn 'None' wird die aktuelle Zeit des Sonnenuntergangs an deiner Position verwendet)", + "min_sunset_time": "min_sunset_time: Manuelles Überschreiben der min. sunset_time. Falls 'None', wird die tatsächliche sunset_time an deiner Position verwendet (HH:MM:SS)", + "take_over_control": "take_over_control, wenn irgendetwas während ein Licht an ist außer Adaptive Lighting den Service 'light.turn_on' aufruft, stoppe die Anpassung des Lichtes (oder des Schalters) bis dieser wieder von off -> on geschaltet wird.", + "detect_non_ha_changes": "detect_non_ha_changes, entdeckt alle Änderungen über 10% am Licht (auch außerhalb von HA gemacht), 'take_over_control' muss aktiviert sein (ruft 'homeassistant.update_entity' jede 'interval' auf!)", + "transition": "transition, Wechselzeit in Sekunden", + "adapt_delay": "adapt_delay: Wartezeit (in Sekunden) zwischen Anschalten des Licht und der Anpassung durch Adaptive Lights. Kann Flackern vermeiden.", + "skip_redundant_commands": "Keine Adaptierungsbefehle senden, deren erwünschter Status schon dem bekanntes Status von Lichtern entspricht. Minimiert die Netzwerkbelastung und verbessert die Adaptierung in manchen Situationen. Deaktiviert lassen falls der pysikalische Status der Lichter und der erkannte Status in HA nicht synchron bleiben." + } + } + }, + "error": { + "option_error": "Fehlerhafte Option", + "entity_missing": "Ein ausgewähltes Licht wurde nicht gefunden" + } + } +} diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json new file mode 100644 index 000000000..f55a6d889 --- /dev/null +++ b/custom_components/adaptive_lighting/translations/en.json @@ -0,0 +1,269 @@ +{ + "title": "Adaptive Lighting", + "config": { + "step": { + "user": { + "title": "Choose a name for the Adaptive Lighting instance", + "description": "Every instance can contain multiple lights!", + "data": { + "name": "Name" + } + } + }, + "abort": { + "already_configured": "This device is already configured" + } + }, + "options": { + "step": { + "init": { + "title": "Adaptive Lighting options", + "description": "Configure an Adaptive Lighting component. Option names align with the YAML settings. If you've defined this entry in YAML, no options will appear here. For interactive graphs that demonstrate parameter effects, visit [this web app](https://basnijholt.github.io/adaptive-lighting). For further details, see the [official documentation](https://github.com/basnijholt/adaptive-lighting#readme).", + "data": { + "lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟", + "interval": "interval", + "transition": "transition", + "initial_transition": "initial_transition", + "min_brightness": "min_brightness: Minimum brightness percentage. 💡", + "max_brightness": "max_brightness: Maximum brightness percentage. 💡", + "min_color_temp": "min_color_temp: Warmest color temperature in Kelvin. 🔥", + "max_color_temp": "max_color_temp: Coldest color temperature in Kelvin. ❄️", + "prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈", + "sleep_brightness": "sleep_brightness", + "sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp", + "sleep_color_temp": "sleep_color_temp", + "sleep_rgb_color": "sleep_rgb_color", + "sleep_transition": "sleep_transition", + "transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙", + "sunrise_time": "sunrise_time", + "min_sunrise_time": "min_sunrise_time", + "max_sunrise_time": "max_sunrise_time", + "sunrise_offset": "sunrise_offset", + "sunset_time": "sunset_time", + "min_sunset_time": "min_sunset_time", + "max_sunset_time": "max_sunset_time", + "sunset_offset": "sunset_offset", + "brightness_mode": "brightness_mode", + "brightness_mode_time_dark": "brightness_mode_time_dark", + "brightness_mode_time_light": "brightness_mode_time_light", + "take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒", + "detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable this feature if you encounter such issues.", + "autoreset_control_seconds": "autoreset_control_seconds", + "only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄", + "adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️ ", + "separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀", + "send_split_delay": "send_split_delay", + "adapt_delay": "adapt_delay", + "skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. 📉Disable if physical light states get out of sync with HA's recorded state.", + "intercept": "intercept: Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. 🏎️ Disable for lights that do not support `light.turn_on` with color and brightness.", + "multi_light_intercept": "multi_light_intercept: Intercept and adapt `light.turn_on` calls that target multiple lights. ➗⚠️ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. Requires `intercept` to be enabled.", + "include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝" + }, + "data_description": { + "interval": "Frequency to adapt the lights, in seconds. 🔄", + "transition": "Duration of transition when lights change, in seconds. 🕑", + "initial_transition": "Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️", + "sleep_brightness": "Brightness percentage of lights in sleep mode. 😴", + "sleep_rgb_or_color_temp": "Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙", + "sleep_color_temp": "Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴", + "sleep_rgb_color": "RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈", + "sleep_transition": "Duration of transition when \"sleep mode\" is toggled in seconds. 😴", + "sunrise_time": "Set a fixed time (HH:MM:SS) for sunrise. 🌅", + "min_sunrise_time": "Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅", + "max_sunrise_time": "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅", + "sunrise_offset": "Adjust sunrise time with a positive or negative offset in seconds. ⏰", + "sunset_time": "Set a fixed time (HH:MM:SS) for sunset. 🌇", + "min_sunset_time": "Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇", + "max_sunset_time": "Set the latest virtual sunset time (HH:MM:SS), allowing for earlier sunsets. 🌇", + "sunset_offset": "Adjust sunset time with a positive or negative offset in seconds. ⏰", + "brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈", + "brightness_mode_time_dark": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉", + "brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.", + "autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️", + "send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", + "adapt_delay": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️" + } + } + }, + "error": { + "option_error": "Invalid option", + "entity_missing": "One or more selected light entities are missing from Home Assistant" + } + }, + "services": { + "apply": { + "name": "apply", + "description": "Applies the current Adaptive Lighting settings to lights.", + "fields": { + "entity_id": { + "description": "The `entity_id` of the switch with the settings to apply. 📝", + "name": "entity_id" + }, + "lights": { + "description": "A light (or list of lights) to apply the settings to. 💡", + "name": "lights" + }, + "transition": { + "description": "Duration of transition when lights change, in seconds. 🕑", + "name": "transition" + }, + "adapt_brightness": { + "description": "Whether to adapt the brightness of the light. 🌞", + "name": "adapt_brightness" + }, + "adapt_color": { + "description": "Whether to adapt the color on supporting lights. 🌈", + "name": "adapt_color" + }, + "prefer_rgb_color": { + "description": "Whether to prefer RGB color adjustment over light color temperature when possible. 🌈", + "name": "prefer_rgb_color" + }, + "turn_on_lights": { + "description": "Whether to turn on lights that are currently off. 🔆", + "name": "turn_on_lights" + } + } + }, + "set_manual_control": { + "name": "set_manual_control", + "description": "Mark whether a light is 'manually controlled'.", + "fields": { + "entity_id": { + "description": "The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝", + "name": "entity_id" + }, + "lights": { + "description": "entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡", + "name": "lights" + }, + "manual_control": { + "description": "Whether to add (\"true\") or remove (\"false\") the light from the \"manual_control\" list. 🔒", + "name": "manual_control" + } + } + }, + "change_switch_settings": { + "name": "change_switch_settings", + "description": "Change any settings you'd like in the switch. All options here are the same as in the config flow.", + "fields": { + "entity_id": { + "description": "Entity ID of the switch. 📝", + "name": "entity_id" + }, + "use_defaults": { + "description": "Sets the default values not specified in this service call. Options: \"current\" (default, retains current values), \"factory\" (resets to documented defaults), or \"configuration\" (reverts to switch config defaults). ⚙️", + "name": "use_defaults" + }, + "include_config_in_attributes": { + "description": "Show all options as attributes on the switch in Home Assistant when set to `true`. 📝", + "name": "include_config_in_attributes" + }, + "turn_on_lights": { + "description": "Whether to turn on lights that are currently off. 🔆", + "name": "turn_on_lights" + }, + "initial_transition": { + "description": "Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️", + "name": "initial_transition" + }, + "sleep_transition": { + "description": "Duration of transition when \"sleep mode\" is toggled in seconds. 😴", + "name": "sleep_transition" + }, + "max_brightness": { + "description": "Maximum brightness percentage. 💡", + "name": "max_brightness" + }, + "max_color_temp": { + "description": "Coldest color temperature in Kelvin. ❄️", + "name": "max_color_temp" + }, + "min_brightness": { + "description": "Minimum brightness percentage. 💡", + "name": "min_brightness" + }, + "min_color_temp": { + "description": "Warmest color temperature in Kelvin. 🔥", + "name": "min_color_temp" + }, + "only_once": { + "description": "Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄", + "name": "only_once" + }, + "prefer_rgb_color": { + "description": "Whether to prefer RGB color adjustment over light color temperature when possible. 🌈", + "name": "prefer_rgb_color" + }, + "separate_turn_on_commands": { + "description": "Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀", + "name": "separate_turn_on_commands" + }, + "send_split_delay": { + "description": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", + "name": "send_split_delay" + }, + "sleep_brightness": { + "description": "Brightness percentage of lights in sleep mode. 😴", + "name": "sleep_brightness" + }, + "sleep_rgb_or_color_temp": { + "description": "Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙", + "name": "sleep_rgb_or_color_temp" + }, + "sleep_rgb_color": { + "description": "RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈", + "name": "sleep_rgb_color" + }, + "sleep_color_temp": { + "description": "Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴", + "name": "sleep_color_temp" + }, + "sunrise_offset": { + "description": "Adjust sunrise time with a positive or negative offset in seconds. ⏰", + "name": "sunrise_offset" + }, + "sunrise_time": { + "description": "Set a fixed time (HH:MM:SS) for sunrise. 🌅", + "name": "sunrise_time" + }, + "sunset_offset": { + "description": "Adjust sunset time with a positive or negative offset in seconds. ⏰", + "name": "sunset_offset" + }, + "sunset_time": { + "description": "Set a fixed time (HH:MM:SS) for sunset. 🌇", + "name": "sunset_time" + }, + "max_sunrise_time": { + "description": "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅", + "name": "max_sunrise_time" + }, + "min_sunset_time": { + "description": "Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇", + "name": "min_sunset_time" + }, + "take_over_control": { + "description": "Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒", + "name": "take_over_control" + }, + "detect_non_ha_changes": { + "description": "Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable this feature if you encounter such issues.", + "name": "detect_non_ha_changes" + }, + "transition": { + "description": "Duration of transition when lights change, in seconds. 🕑", + "name": "transition" + }, + "adapt_delay": { + "description": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️", + "name": "adapt_delay" + }, + "autoreset_control_seconds": { + "description": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️", + "name": "autoreset_control_seconds" + } + } + } + } +} diff --git a/custom_components/adaptive_lighting/translations/et.json b/custom_components/adaptive_lighting/translations/et.json new file mode 100644 index 000000000..b2599f2ab --- /dev/null +++ b/custom_components/adaptive_lighting/translations/et.json @@ -0,0 +1,50 @@ +{ + "title": "Kohanduv valgus", + "config": { + "step": { + "user": { + "title": "Vali kohanduva valguse üksuse nimi", + "description": "Igas üksuses võib olla mitu valgustit!", + "data": { + "name": "Nimi" + } + } + }, + "abort": { + "already_configured": "Üksus on juba seadistatud" + } + }, + "options": { + "step": { + "init": { + "title": "Kohanduva valguse suvandid", + "description": "Kohanduva valguse suvandid. Valikute nimetused ühtuvad YAML kirjes olevatega. Valikuid ei kuvata kui seadistus on tehtud YAML kirjes.", + "data": { + "lights": "valgustid", + "initial_transition": "Algne üleminek kui valgustid lülituvad sisse/välja või unerežiim muutub", + "interval": "Intervall, aeg muutuste vahel sekundites", + "max_brightness": "Suurim heledus %", + "max_color_temp": "Suurim värvustemperatuur Kelvinites", + "min_brightness": "Vähim heledus %", + "min_color_temp": "Vähim värvustemperatuur Kelvinites", + "only_once": "Ainult üks kord, rakendub ainult valgusti sisselülitamisel", + "prefer_rgb_color": "Eelista RGB värve, võimalusel kasuta RGB sätteid värvustemperatuuri asemel", + "separate_turn_on_commands": "Eraldi lülitused iga valiku (värvus, heledus jne.) sisselülitamiseks, mõned valgustid vajavad seda.", + "sleep_brightness": "Unerežiimi heledus %", + "sleep_color_temp": "Uneržiimi värvus Kelvinites", + "sunrise_offset": "Nihe päikesetõusust, +/- sekundit", + "sunrise_time": "Päikesetõusu aeg 'HH:MM:SS' vormingus. (Kui jätta tühjaks kasutatakse asukohajärgset)", + "sunset_offset": "Nihe päikeseloojangust, +/- sekundit", + "sunset_time": "Päikeseloojangu aeg 'HH:MM:SS' vormingus. (Kui jätta tühjaks kasutatakse asukohajärgset)", + "take_over_control": "Käsitsi juhtimine: kui miski peale kohanduva valguse enda lültiab valgusti sisse ja see juba põleb, katkesta kohandamine kuni järgmise välise lülitamiseni.", + "detect_non_ha_changes": "Märka väliseid lülitusi: kui mõni säte muutub üle 10% (isegi väljaspoolt HA juhituna) siis peab käsitsi juhtimine olema lubatud (kutsutakse 'homeassistant.update_entity')'interval'!)", + "transition": "Üleminekud, sekundites" + } + } + }, + "error": { + "option_error": "Vigane suvand", + "entity_missing": "Valitud valgust ei leitud" + } + } +} diff --git a/custom_components/adaptive_lighting/translations/fr.json b/custom_components/adaptive_lighting/translations/fr.json new file mode 100644 index 000000000..a41d84a01 --- /dev/null +++ b/custom_components/adaptive_lighting/translations/fr.json @@ -0,0 +1,51 @@ +{ + "title": "Éclairage adaptatif", + "config": { + "step": { + "user": { + "title": "Choisissez un nom pour cette instance d'éclairage adaptatif", + "description": "Choisissez un nom pour cette instance. Vous pouvez configurer plusieurs instances d'éclairage adaptatif, chacune pouvant contrôler plusieurs lampes !", + "data": { + "name": "Nom" + } + } + }, + "abort": { + "already_configured": "Cet appareil est déjà configuré" + } + }, + "options": { + "step": { + "init": { + "title": "Options d'éclairage adaptatif", + "description": "Tous les paramètres de l'instance d'éclairage adaptatif. Les noms des options correspondent aux paramètres YAML. Aucune option n'est affichée si l'entrée adaptive_lighting est définie dans votre configuration YAML.", + "data": { + "lights": "lights : Les lampes à contrôler", + "initial_transition": "initial_transition : Transition (en secondes) lorsque l'état d'une lampe passe d'« éteinte » à « allumée ».", + "sleep_transition": "sleep_transition : Transition (en secondes) lorsque « sleep_state » est commuté.", + "interval": "interval : Temps (en secondes) entre deux mises à jour du commutateur.", + "max_brightness": "max_brightness : Luminosité maximale des lampes (en pourcentage) au cours d'un cycle.", + "max_color_temp": "max_color_temp : Couleur la plus froide (en kelvins) du cycle de température de couleur.", + "min_brightness": "min_brightness : Luminosité minimale des lampes (en pourcentage) au cours d'un cycle.", + "min_color_temp": "min_color_temp : Couleur la plus chaude (en kelvins) du cycle de température de couleur.", + "only_once": "only_once : Adapter les lampes uniquement au moment où elles sont allumées.", + "prefer_rgb_color": "prefer_rgb_color : Utiliser « rgb_color » plutôt que « color_temp » lorsque cela est possible.", + "separate_turn_on_commands": "separate_turn_on_commands : Séparer les commandes pour chaque attribut (couleur, luminosité, etc.) de « light.turn_on » (nécessaire pour certaines lampes).", + "sleep_brightness": "sleep_brightness : Luminosité (en pourcentage) du mode nuit.", + "sleep_color_temp": "sleep_color_temp : Température de couleur (en kelvins) du mode nuit.", + "sunrise_offset": "sunrise_offset : Décalage (en secondes [- : passé, + : futur]) du cycle par rapport au lever du soleil.", + "sunrise_time": "sunrise_time : Heure (HH:MM:SS) du lever du soleil. Si « None », utilise l'heure correspondant à votre emplacement.", + "sunset_offset": "sunset_offset : Décalage (en secondes [- : passé, + : futur]) du cycle par rapport au coucher du soleil.", + "sunset_time": "sunset_time : Heure (HH:MM:SS) du coucher du soleil. Si « None », utilise l'heure correspondant à votre emplacement.", + "take_over_control": "take_over_control : Si quelque chose d'autre que l'éclairage adaptatif appelle « light.turn_on » alors qu'une lampe est déjà allumée, cesser d'adapter cette lampe jusqu'à ce qu'elle (ou le commutateur) soit éteinte puis rallumée.", + "detect_non_ha_changes": "detect_non_ha_changes : Détecter tout changement de plus de 10 % appliqué aux lampes (même en dehors de HA). Nécessite que « take_over_control » soit activé. (Appelle « homeassistant.update_entity » tous les « interval » !)", + "transition": "transition : Durée de la transition (en secondes) des changements appliqués aux lampes." + } + } + }, + "error": { + "option_error": "Option non valide", + "entity_missing": "Une lumière sélectionnée n’a pas été trouvée" + } + } +} diff --git a/custom_components/adaptive_lighting/translations/it.json b/custom_components/adaptive_lighting/translations/it.json new file mode 100644 index 000000000..c1ae61634 --- /dev/null +++ b/custom_components/adaptive_lighting/translations/it.json @@ -0,0 +1,52 @@ +{ + "title": "Illuminazione Adattiva", + "config": { + "step": { + "user": { + "title": "Scegli un nome per l'istanza di Illuminazione Adattiva", + "description": "Scegli un nome per questa istanza. Puoi eseguire più istanze di Illuminazione adattiva, ognuna delle quali può contenere più luci!", + "data": { + "name": "Nome" + } + } + }, + "abort": { + "already_configured": "Questo dispositivo è già stato configurato" + } + }, + "options": { + "step": { + "init": { + "title": "Opzioni Illuminazione Adattiva", + "description": "Tutte le opzioni per il componente Illuminazione Adattiva. I nomi delle opzioni corrispondono con le impostazioni YAML. Non sono mostrate opzioni se hai la voce adaptive-lighting definita nella tua configurazione YAML.", + "data": { + "lights": "luci", + "initial_transition": "initial_transition: Quando le luci vengono accese (off -> on). (secondi)", + "sleep_transition": "sleep_transition: Quando 'sleep_state' cambia. (secondi)", + "interval": "interval: Tempo tra i cambiamenti dello switch. (secondi)", + "max_brightness": "max_brightness: Luminosità massima delle luci durante un ciclo. (%)", + "max_color_temp": "max_color_temp: Gradazione più fredda del ciclo di temperatura del colore. (Kelvin)", + "min_brightness": "min_brightness: Luminosità minima delle luci durante un ciclo. (%)", + "min_color_temp": "min_color_temp: Gradazione più calda del ciclo di temperatura del colore. (Kelvin)", + "only_once": "only_once: Adatta le luci solo quando vengono accese.", + "prefer_rgb_color": "prefer_rgb_color: Usa 'rgb_color' al posto di 'color_temp' quando possibile.", + "separate_turn_on_commands": "separate_turn_on_commands: Separa i comandi per ogni attributo (color, brightness, etc.) in 'light.turn_on' (richiesto per alcune luci).", + "sleep_brightness": "sleep_brightness: Impostazione della luminosità per la modalità notturna. (%)", + "sleep_color_temp": "sleep_color_temp: Impostazione della temperatura colore per la modalità notturna. (Kelvin)", + "sunrise_offset": "sunrise_offset: Imposta quanto anticipare(-) o ritardare(+) l'alba nel ciclo (+/- secondi)", + "sunrise_time": "sunrise_time: Imposta manualmente l'ora dell'alba, se 'None', usa l'ora effettiva dell'alba alla tua posizione (HH:MM:SS)", + "sunset_offset": "sunset_offset: Imposta quanto anticipare(-) o ritardare(+) il tramonto nel ciclo (+/- secondi)", + "sunset_time": "sunset_time: Imposta manualmente l'ora del tramonto, se 'None', usa l'ora effettiva del tramonto alla tua posizione (HH:MM:SS)", + "take_over_control": "take_over_control: Se viene chiamato il servizio 'lights.turn_on' (non da Illuminazione Adattiva) quando una luce è già accesa, interrompi l'adattamento della luce finquando essa o l'interruttore non vengono riaccesi (off -> on.)", + "detect_non_ha_changes": "detect_non_ha_changes: rileva tutti i cambiamenti >10% applicati alle luci (anche fuori da HA), richiede che 'take_over_control' sia abilitato (chiama 'homeassistant.update_entity' ad ogni 'intervallo'!)", + "transition": "Tempo di transizione quando viene applicata una modifica alle luci (secondi)", + "adapt_delay": "Tempo di attesa tra l'accensione della luce, e Illuminazione Adattiva che applica le modifiche allo stato della luce. Potrebbe evitare sfarfallii." + } + } + }, + "error": { + "option_error": "Opzione non valida", + "entity_missing": "Non è stata trovata una luce selezionata" + } + } + } diff --git a/custom_components/adaptive_lighting/translations/nb.json b/custom_components/adaptive_lighting/translations/nb.json new file mode 100644 index 000000000..7cfba678f --- /dev/null +++ b/custom_components/adaptive_lighting/translations/nb.json @@ -0,0 +1,50 @@ +{ + "title":"Adaptiv Belysning", + "config":{ + "step":{ + "user":{ + "title":"Velg et navn", + "description":"Velg et navn for denne konfigurasjonen for adaptiv belysning - hver konfigurasjon kan inneholde flere lyskilder!", + "data":{ + "name":"Navn" + } + } + }, + "abort":{ + "already_configured":"Denne enheten er allerede konfigurert!" + } + }, + "options":{ + "step":{ + "init":{ + "title":"Adaptiv Belysning Innstillinger", + "description":"Alle innstillinger for en adaptiv belysning konfigurasjon. Innstillingene er identiske med innstillingene for YAML konfigurasjon. Ingen innstillinger vises dersom du har definert adaptive_lighting i din YAML konfigurasjon.", + "data":{ + "lights":"Lys / Lyskilder", + "initial_transition":"'initial_transition': overgangen (i sekunder) når lysene skrus av eller på - eller når 'sleep_state' endres", + "interval":"'interval': tiden mellom oppdateringer (i sekunder)", + "max_brightness":"'max_brightness': den høyeste lysstyrken (i prosent) på lysene i løpet av en syklus", + "max_color_temp":"'max_color_temp': den høyeste fargetemperaturen (i kelvin) på lysene i løpet av en syklus", + "min_brightness":"'min_brightness': den laveste lysstyrken (i prosent) på lysene i løpet av en syklus", + "min_color_temp":"'min_color_temp': den laveste fargetemperaturen (i kelvin) på lysene i løpet av en syklus", + "only_once":"'only_once': anvend innstillingene for adaptiv belysning kun når lysene skrus av eller på", + "prefer_rgb_color":"'prefer_rgb_color': benytt rgb i stedet for fargetemperatur dersom det er mulig", + "separate_turn_on_commands":"'separate_turn_on_commands': separer kommandone i 'light.turn_on' for hver attributt (farge, lysstyrke, osv.). Dette kan være nødvendig for enkelte typer lys / lyskilder", + "sleep_brightness":"'sleep_brightness': lysstyrken på lysene (i prosent) når 'sleep_mode' (søvnmodus) er aktiv", + "sleep_color_temp":"'sleep_color_temp': fargetemperaturen på lysene (i kelvin) når 'sleep_mode' (søvnmodus) er aktiv", + "sunrise_offset":"'sunrise_offset': hvor lenge før (-) eller etter (+) tidspunktet solen står opp (lokalt) skal defineres som soloppgang (i sekunder)", + "sunrise_time":"'sunrise_time': definer tidspunktet for soloppgang manuelt (i følgende format: TT:MM:SS)", + "sunset_offset":"'sunset_offset': hvor lenge før (-) eller etter (+) tidspunktet solen går ned (lokalt) skal defineres som solnedgang (i sekunder)", + "sunset_time":"'sunset_time': definer tidspunktet for solnedgang manuelt (i følgende format: TT:MM:SS - f. eks: '20:30:00' vil definere tidspunktet for solnegang som halv-ni på kvelden)", + "take_over_control":"'take_over_control': dersom en annen tjeneste enn adaptiv belysning skrur lysene av eller på, vil automatisk adaptering av lyset stoppes inntil lyset (eller den tilhørende bryteren for adaptiv belysning) blir slått av - og på igjen", + "detect_non_ha_changes":"'detect_non_ha_changes': registrerer alle endringer i lysstyrke over 10% med opprinnelse utenfor Home Assistant - krever at 'take_over_control' er aktivert (OBS: tilkaller 'homeassistant.update_entity' ved hvert 'interval'!)", + "transition":"'transition': varigheten (i sekunder) på overgangen når lysene oppdateres " + } + } + }, + "error":{ + "option_error":"En eller flere valgte innstillinger er ugyldige", + "entity_missing": "Et utvalgt lys ble ikke funnet" + } + } +} diff --git a/custom_components/adaptive_lighting/translations/nl.json b/custom_components/adaptive_lighting/translations/nl.json new file mode 100644 index 000000000..a0b6b0829 --- /dev/null +++ b/custom_components/adaptive_lighting/translations/nl.json @@ -0,0 +1,57 @@ +{ + "title": "Adaptieve verlichting", + "config": { + "step": { + "user": { + "title": "Kies een naam voor de adaptieve verlichting integratie", + "description": "Kies een naam voor deze integratie. U kunt verschillende integratie van Adaptieve verlichting uitvoeren, elk van deze kan meerdere lichten bevatten!", + "data": { + "name": "Naam" + } + } + }, + "abort": { + "already_configured": "Dit apparaat is al geconfigureerd" + } + }, + "options": { + "step": { + "init": { + "title": "Adaptieve verlichting instellingen", + "description": "Alle instellingen voor een Adaptieve verlichting component. De optienamen komen overeen met de YAML-instellingen. Er worden geen opties weergegeven als u het item adaptive_lighting hebt gedefinieerd in uw YAML-configuratie.", + "data": { + "lights": "Lichten", + "initial_transition": "initial_transition: Wanneer lichten van 'uit' naar 'aan' gaan. (seconden)", + "sleep_transition": "sleep_transition: Wanneer 'sleep_state' verandert. (seconden)", + "interval": "interval: Tijd tussen switch-updates. (seconden)", + "max_brightness": "max_brightness: Hoogste helderheid van lichten tijdens een cyclus. (%)", + "max_color_temp": "max_color_temp: Koudste tint van de kleurtemperatuurcyclus. (kelvin)", + "min_brightness": "min_brightness: Laagste helderheid van lichten tijdens een cyclus. (%)", + "min_color_temp": "min_color_temp, Warmste tint van de kleurtemperatuurcyclus. (kelvin)", + "only_once": "only_once: pas de verlichting alleen aan wanneer u ze aanzet.", + "prefer_rgb_color": "prefer_rgb_color: Gebruik waar mogelijk 'rgb_color' in plaats van 'color_temp'.", + "separate_turn_on_commands": "separate_turn_on_commands: Scheid de commando's voor elk attribuut (kleur, helderheid, enz.) in 'light.turn_on' (vereist voor sommige lampen).", + "send_split_delay": "send_split_delay: wacht tussen commando's (milliseconden), wanneer separate_turn_on_commands wordt gebruikt. Kan ervoor zorgen dat beide commando's correct door de lamp worden afgehandeld.", + "sleep_brightness": "sleep_brightness, helderheidsinstelling voor slaapstand. (%)", + "sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp, gebruik 'rgb_color' of 'color_temp'", + "sleep_rgb_color": "sleep_rgb_color, in RGB", + "sleep_color_temp": "sleep_color_temp: Kleurtemperatuurinstelling voor slaapstand. (kelvin)", + "sunrise_offset": "sunrise_offset: Hoe lang voor(-) of na(+) zonsopgang uitvoeren (+/- seconden)", + "sunrise_time": "sunrise_time: Handmatige wijziging van de zonsopgangstijd, indien 'Geen', wordt de werkelijke zonsopgangstijd op uw locatie gebruikt (UU:MM:SS)", + "max_sunrise_time": "max_sunrise_time: handmatige aanpassing van de maximale zonsopgangstijd, indien 'Geen', wordt de werkelijke zonsopgangstijd op uw locatie gebruikt (UU:MM:SS)", + "sunset_offset": "sunset_offset: Hoe lang voor(-) of na(+) zonsondergang uitvoeren (+/- seconden)", + "sunset_time": "sunset_time: handmatige onderdrukking van de zonsondergangstijd, indien 'Geen', wordt de werkelijke zonsopgangstijd op uw locatie gebruikt (UU:MM:SS)", + "min_sunset_time": "min_sunset_time: handmatige onderdrukking van de minimale zonsondergangstijd, indien 'Geen', wordt de werkelijke zonsondergangstijd op uw locatie gebruikt (UU:MM:SS)", + "take_over_control": "take_over_control: Als iets anders dan Adaptive Lighting 'light.turn_on' roept wanneer een lamp al aan is, stop dan met het aanpassen van het licht totdat het (of de schakelaar) uit -> aan gaat.", + "detect_non_ha_changes": "detect_non_ha_changes: detecteert alle >10% wijzigingen aan de lichten (ook buiten HA), vereist dat 'take_over_control' is ingeschakeld (heet 'homeassistant.update_entity' elke 'interval'!)", + "transition": "Overgangstijd bij het aanbrengen van een wijziging op de lichten (seconden)", + "adapt_delay": "adapt_delay: wachttijd tussen het inschakelen van het licht (seconden) en het aanbrengen van wijzigingen in de lichtstatus door Adaptieve verlichting. Kan flikkering voorkomen." + } + } + }, + "error": { + "option_error": "Ongeldige optie", + "entity_missing": "Een of meer geselecteerde lichtentiteiten ontbreken in Home Assistant" + } + } +} diff --git a/custom_components/adaptive_lighting/translations/pl.json b/custom_components/adaptive_lighting/translations/pl.json new file mode 100644 index 000000000..99a97e3c4 --- /dev/null +++ b/custom_components/adaptive_lighting/translations/pl.json @@ -0,0 +1,51 @@ +{ + "title": "Adaptacyjne oświetlenie", + "config": { + "step": { + "user": { + "title": "Wybierz nazwę grupy dla Adaptacyjnego oświetlenia", + "description": "Wybierz nazwę dla grupy. Możesz użyć wiele grup Adaptacyjnego oświetlenia, każda może mieć dowolną konfigurację świateł!", + "data": { + "name": "Nazwa" + } + } + }, + "abort": { + "already_configured": "Już skonfigurowane!" + } + }, + "options": { + "step": { + "init": { + "title": "Adaptacyjne oświetlenie opcje", + "description": "Wszystkie ustawienia dla Adaptacyjnego oświetlenia. Nazwy opcji odpowiadają ustawieniom YAML. Żadne opcje nie są wyświetlane, jeśli masz wpis adaptive_lighting zdefiniowany w konfiguracji YAML.", + "data": { + "lights": "światła", + "initial_transition": "initial_transition: When lights turn 'off' to 'on'. (sekund)", + "sleep_transition": "sleep_transition: When 'sleep_state' changes. (sekund)", + "interval": "interval: Time between switch updates. (sekund)", + "max_brightness": "max_brightness: Highest brightness of lights during a cycle. (%)", + "max_color_temp": "max_color_temp: Coldest hue of the color temperature cycle. (Kelvin)", + "min_brightness": "min_brightness: Lowest brightness of lights during a cycle. (%)", + "min_color_temp": "min_color_temp, Warmest hue of the color temperature cycle. (Kelvin)", + "only_once": "only_once: Only adapt the lights when turning them on.", + "prefer_rgb_color": "prefer_rgb_color: Use 'rgb_color' rather than 'color_temp' when possible.", + "separate_turn_on_commands": "separate_turn_on_commands: Separate the commands for each attribute (color, brightness, etc.) in 'light.turn_on' (required for some lights).", + "sleep_brightness": "sleep_brightness, Brightness setting for Sleep Mode. (%)", + "sleep_color_temp": "sleep_color_temp: Color temperature setting for Sleep Mode. (Kelvin)", + "sunrise_offset": "sunrise_offset: How long before(-) or after(+) to define the sunrise point of the cycle (+/- sekund)", + "sunrise_time": "sunrise_time: Manual override of the sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)", + "sunset_offset": "sunset_offset: How long before(-) or after(+) to define the sunset point of the cycle (+/- sekund)", + "sunset_time": "sunset_time: Manual override of the sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)", + "take_over_control": "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.", + "detect_non_ha_changes": "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)", + "transition": "Transition time when applying a change to the lights (sekund)" + } + } + }, + "error": { + "option_error": "Błędne opcje", + "entity_missing": "Nie znaleziono wybranego światła" + } + } +} diff --git a/custom_components/adaptive_lighting/translations/pt-BR.json b/custom_components/adaptive_lighting/translations/pt-BR.json new file mode 100644 index 000000000..43eacb01a --- /dev/null +++ b/custom_components/adaptive_lighting/translations/pt-BR.json @@ -0,0 +1,51 @@ +{ + "title": "Iluminação Adaptativa", + "config": { + "step": { + "user": { + "title": "Escolha um nome para a instância da Iluminação Adaptativa", + "description": "Escolha um nome para esta instância. Você pode executar várias instâncias de iluminação adaptativa, cada uma delas pode conter várias luzes!", + "data": { + "name": "Nome" + } + } + }, + "abort": { + "already_configured": "Este dispositivo já está configurado" + } + }, + "options": { + "step": { + "init": { + "title": "Opções da iluminação adaptiva", + "description": "Todas as configurações de um componente de iluminação adaptativa. Os nomes das opções correspondem às configurações de YAML. Nenhuma opção será exibida se você tiver a entrada adaptive_lighting definida em sua configuração YAML.", + "data": { + "lights": "luzes", + "initial_transition": "initial_transition: Quando as luzes mudam de 'off' para 'on'. (segundos)", + "sleep_transition": "sleep_transition: Quando 'sleep_state' muda. (segundos)", + "interval": "interval: Tempo entre as atualizações do switch. (segundos)", + "max_brightness": "max_brightness: Maior brilho das luzes durante um ciclo. (%)", + "max_color_temp": "max_color_temp: Matiz mais frio do ciclo de temperatura de cor. (Kelvin)", + "min_brightness": "min_brightness: Menor brilho das luzes durante um ciclo. (%)", + "min_color_temp": "min_color_temp, matiz mais quente do ciclo de temperatura de cor. (Kelvin)", + "only_once": "only_once: Apenas adapte as luzes ao ligá-las.", + "prefer_rgb_color": "prefer_rgb_color: Use 'rgb_color' em vez de 'color_temp' quando possível.", + "separate_turn_on_commands": "separar_turn_on_commands: Separe os comandos para cada atributo (cor, brilho, etc.) em 'light.turn_on' (necessário para algumas luzes).", + "sleep_brightness": "sleep_brightness, configuração de brilho para o modo de suspensão. (%)", + "sleep_color_temp": "sleep_color_temp: configuração de temperatura de cor para o modo de suspensão. (Kelvin)", + "sunrise_offset": "sunrise_offset: Quanto tempo antes (-) ou depois (+) para definir o ponto do nascer do sol do ciclo (+/- segundos)", + "sunrise_time": "sunrise_time: substituição manual do horário do nascer do sol, se 'Nenhum', ele usa o horário real do nascer do sol em sua localização (HH:MM:SS)", + "sunset_offset": "Sunset_offset: Quanto tempo antes (-) ou depois (+) para definir o ponto de pôr do sol do ciclo (+/- segundos)", + "sunset_time": "sunset_time: substituição manual do horário do pôr do sol, se 'Nenhum', ele usa o horário real do nascer do sol em sua localização (HH:MM:SS)", + "take_over_control": "take_over_control: Se qualquer coisa, exceto Adaptive Lighting, chamar 'light.turn_on' quando uma luz já estiver acesa, pare de adaptar essa luz até que ela (ou o interruptor) desligue -> ligue.", + "detect_non_ha_changes": "detect_non_ha_changes: detecta todas as alterações > 10% feitas nas luzes (também fora do HA), requer que 'take_over_control' seja ativado (chama 'homeassistant.update_entity' a cada 'intervalo'!)", + "transition": "Tempo de transição ao aplicar uma mudança nas luzes (segundos)" + } + } + }, + "error": { + "option_error": "Opção inválida", + "entity_missing": "Uma luz selecionada não foi encontrada" + } + } +} diff --git a/custom_components/adaptive_lighting/translations/ru.json b/custom_components/adaptive_lighting/translations/ru.json new file mode 100644 index 000000000..2e160f9e4 --- /dev/null +++ b/custom_components/adaptive_lighting/translations/ru.json @@ -0,0 +1,52 @@ +{ + "title": "Adaptive Lighting", + "config": { + "step": { + "user": { + "title": "Выберите имя для экземпляра Adaptive Lighting", + "description": "Выберите имя для этого экземпляра. Вы можете запустить несколько экземпляров Adaptive Lighting, каждый из которых может содержать несколько источников света!", + "data": { + "name": "Имя" + } + } + }, + "abort": { + "already_configured": "Это устройство уже настроено" + } + }, + "options": { + "step": { + "init": { + "title": "Настройки Adaptive Lighting", + "description": "Все настройки компонента Adaptive Lighting. Названия опций соответствуют настройкам в YAML. Параметры не отображаются, если в конфигурации YAML определена запись adaptive_lighting.", + "data": { + "lights": "Осветительные приборы", + "initial_transition": "initial_transition: Начальный переход, когда свет переключается с 'off' на 'on'. (секунды)", + "sleep_transition": "sleep_transition: Когда прибор переходит в Режима Сна (Sleep Mode) и 'sleep_state' изменяется. (секунды)", + "interval": "interval: Интервал между обновлениями переключателя. (секунды)", + "max_brightness": "max_brightness: Максимальная яркость света во время цикла. (%)", + "max_color_temp": "max_color_temp: Самый холодный оттенок цветовой температуры во время цикла. (Kelvin)", + "min_brightness": "min_brightness: Минимальная яркость света во время цикла. (%)", + "min_color_temp": "min_color_temp: Самый теплый оттенок цветовой температуры во время цикла. (Kelvin)", + "only_once": "only_once: Адаптировать свет только при включении.", + "prefer_rgb_color": "prefer_rgb_color: По возможности использовать 'rgb_color' вместо 'color_temp'.", + "separate_turn_on_commands": "separate_turn_on_commands: Раздельные команды для каждого атрибута (цвет, яркость и т.д.) в 'light.turn_on' (требуется для некоторых источников света).", + "sleep_brightness": "sleep_brightness: Настройка яркости для Режима Сна (Sleep Mode). (%)", + "sleep_color_temp": "sleep_color_temp: Настройка цветовой температуры для Режима Сна (Sleep Mode). (Kelvin)", + "sunrise_offset": "sunrise_offset: За сколько времени до (-) или после (+) переопределить время восхода во время цикла. (+/- секунды)", + "sunrise_time": "sunrise_time: Ручное изменение времени восхода солнца, если указано 'None', используется фактическое время восхода в Вашем местоположении. (ЧЧ:ММ:СС)", + "sunset_offset": "sunset_offset: За сколько времени до (-) или после (+) переопределить время заката во время цикла. (+/- секунды)", + "sunset_time": "sunset_time: Ручное изменение времени заката солнца, если указано 'None', используется фактическое время заката в Вашем местоположении. (ЧЧ:ММ:СС)", + "take_over_control": "take_over_control: Если что-либо, кроме Adaptive Lighting, вызывает службу 'light.turn_on', когда свет уже включен, прекратить адаптацию этого осветительного прибора, пока он (или переключатель) не переключится off -> on.", + "detect_non_ha_changes": "detect_non_ha_changes: Обнаруживает все изменения на >10% примененные к освещению (также и из-за пределов Home Assistant), требует включения 'take_over_control' (вызывает 'homeassistant.update_entity' каждый 'interval'!)", + "transition": "Время перехода при применении изменения к источникам света. (секунды)", + "adapt_delay": "Время ожидания между включением света и применением адаптации. Может помочь избежать мерцания. (секунды)" + } + } + }, + "error": { + "option_error": "Ошибка в настройках!", + "entity_missing": "Выбранный индикатор не найден" + } + } +} diff --git a/custom_components/adaptive_lighting/translations/sv.json b/custom_components/adaptive_lighting/translations/sv.json new file mode 100644 index 000000000..2239ad3bf --- /dev/null +++ b/custom_components/adaptive_lighting/translations/sv.json @@ -0,0 +1,53 @@ +{ + "title": "Adaptiv Ljussättning", + "config": { + "step": { + "user": { + "title": "Välj ett namn för Adaptiv Ljussättning", + "description": "Varje konfiguration kan innehålla flera ljuskällor!", + "data": { + "name": "Namn" + } + } + }, + "abort": { + "already_configured": "Enheten är redan konfiguerad" + } + }, + "options": { + "step": { + "init": { + "title": "Adaptiv Ljussättning Inställningar", + "description": "Alla inställningar för en Adaptiv Ljussättning komponent. Titeln på inställningarna är desamma som i YAML konfigurationen. Inga inställningar visas om enheten redan är konfigurerad i YAML.", + "data": { + "lights": "lights, ljuskällor", + "adapt_brightness": "adapt_brightness, Adaptiv ljusstyrka", + "adapt_color_temp": "adapt_color_temp, Justera färgtemperatur genom att använda 'color_temp' om möjligt", + "adapt_rgb_color": "adapt_rgb_color, Justera färgtemperatur genom att använda RGB/XY om möjligt", + "initial_transition": "initial_transition, när ljuskällorna går från 'av' till 'på' eller när 'sleep_state' ändras", + "interval": "interval, Tid mellan uppdateringar i sekunder", + "max_brightness": "max_brightness, i procent %", + "max_color_temp": "max_color_temp, i Kelvin", + "min_brightness": "min_brightness, i %", + "min_color_temp": "min_color_temp, i Kelvin", + "only_once": "only_once, Adaptivt justera endast ljuskällorna när de sätts från 'av' till 'på'", + "prefer_rgb_color": "prefer_rgb_color, Använd 'rgb_color' över 'color_temp' om möjligt", + "separate_turn_on_commands": "separate_turn_on_commands, for each attribute (color, brightness, etc.) in 'light.turn_on', required for some lights.", + "sleep_brightness": "sleep_brightness, i %", + "sleep_color_temp": "sleep_color_temp, i Kelvin", + "sunrise_offset": "sunrise_offset, i +/- sekunder", + "sunrise_time": "sunrise_time, i 'HH:MM:SS' format (om 'None', används den faktiskta soluppgången för din position)", + "sunset_offset": "sunset_offset, i +/- sekunder", + "sunset_time": "sunset_time, i 'HH:MM:SS' format (om 'None', används den faktiskta solnedgången för din position)", + "take_over_control": "take_over_control, om något utöver 'Adaptiv Ljussättning' komponenten kallar på 'light.turn_on' när en ljuskälla redan är på, stängs den adaptiva justeringen av tills ljuskällan stängs av -> på igen, alternativt switchen för konfigurationen", + "detect_non_ha_changes": "detect_non_ha_changes, Upptäcker alla ändringar större än 5% gjorda på ljuskällorna som inte kommer från HA. Kräver att 'take_over_control' är påslaget.(Kallar på 'homeassistant.update_entity' vid varje 'interval'!)", + "transition": "transition, i sekunder" + } + } + }, + "error": { + "option_error": "Ogiltlig inställning", + "entity_missing": "Ett valt ljus hittades inte" + } + } +} diff --git a/custom_components/adaptive_lighting/translations/uk.json b/custom_components/adaptive_lighting/translations/uk.json new file mode 100644 index 000000000..90265f2e4 --- /dev/null +++ b/custom_components/adaptive_lighting/translations/uk.json @@ -0,0 +1,50 @@ +{ + "title": "Адаптивне освітлення", + "config": { + "step": { + "user": { + "title": "Оберіть ім’я для екземпляра адаптивного освітлення", + "description": "Оберіть ім’я для цього екземпляра. Ви можете мати декілька екземплярів адаптивного освітлення, кожен може містити декілька приладів!", + "data": { + "name": "Ім’я" + } + } + }, + "abort": { + "already_configured": "Цей пристрій вже налаштовано" + } + }, + "options": { + "step": { + "init": { + "title": "Опції адаптивного освітлення", + "description": "Всі налаштування компонента адаптивного освітлення. Назви опцій відповідають налаштуванням у YAML. Опції не відображаються, якщо ви вже визначили їх у компоненті adaptive_lighting вашої YAML-конфігурації.", + "data": { + "lights": "прилади", + "initial_transition": "initial_transition: Коли прилад вимикається (off), вмикається (on), або змінює 'sleep_state'. (секунди)", + "interval": "interval: Час між оновленнями перемикача. (секунди)", + "max_brightness": "max_brightness: Найвища яскравість світла під час циклу. (%)", + "max_color_temp": "max_color_temp: Найхолодніший відтінок циклу кольорової температури. (Кельвін)", + "min_brightness": "min_brightness: Найнижча яскравість світла під час циклу. (%)", + "min_color_temp": "min_color_temp: Найтепліший відтінок циклу кольорової температури. (%)", + "only_once": "only_once: Адаптувати світло лише після початкового увімкнення.", + "prefer_rgb_color": "prefer_rgb_color: Використовувати 'rgb_color' замість 'color_temp', коли можливо.", + "separate_turn_on_commands": "separate_turn_on_commands: Окремі команди для кожного атрибута (колір, яскравість, тощо.) в 'light.turn_on' (необхідні для деяких приладів).", + "sleep_brightness": "sleep_brightness: Налаштування яскравості для Режиму сну. (%)", + "sleep_color_temp": "sleep_color_temp: Температура кольору для Режиму сну. (Кельвін)", + "sunrise_offset": "sunrise_offset: Як за довго до(-) або після(+) визначати точку сходу сонця для циклу (+/- секунд)", + "sunrise_time": "sunrise_time: Ручний перезапис часу сходу сонця, якщо 'None', тоді використовується час сходу сонця у вашій локації (HH:MM:SS)", + "sunset_offset": "sunset_offset: Як за довго до(-) або після(+) визначати точку заходу сонця для циклу (+/- секунд)", + "sunset_time": "sunset_time: Ручний перезапис часу заходу сонця, якщо 'None', тоді використовується час заходу сонця у вашій локації (HH:MM:SS)", + "take_over_control": "take_over_control: Якщо що-небудь, окрім Адаптивного освітлення, викликає 'light.turn_on', коли світло вже увімкнено, чи адаптувати освітлення допоки світло (або перемикач) перемкнеться (off -> on).", + "detect_non_ha_changes": "detect_non_ha_changes: виявляти всі зміни >10% до освітлення (включаючи ті, що зроблені поза HA), вимагає, щоб 'take_over_control' був включений (виклик 'homeassistant.update_entity' кожного оновлення 'interval'!)", + "transition": "Час переходу, який застосовується до освітлення (секунди)" + } + } + }, + "error": { + "option_error": "Хибна опція", + "entity_missing": "Вибраного світла в домашньому помічнику не знайшли" + } + } +} diff --git a/custom_components/alexa_media/.translations/de.json b/custom_components/alexa_media/.translations/de.json index 40b7b8330..15a607363 100644 --- a/custom_components/alexa_media/.translations/de.json +++ b/custom_components/alexa_media/.translations/de.json @@ -8,6 +8,7 @@ "error": { "2fa_key_invalid": "Invalid Built-In 2FA key", "connection_error": "Verbindungsfehler; Netzwerk prüfen und erneut versuchen", + "hass_url_invalid": "Es kann keine Verbindung zur Home Assistant-URL hergestellt werden. Bitte überprüfen Sie die externe URL unter Konfiguration - > Allgemein", "identifier_exists": "Diese Email ist bereits registriert", "invalid_credentials": "Falsche Zugangsdaten", "unknown_error": "Unbekannter Fehler, bitte Log-Info melden" @@ -61,12 +62,13 @@ "description": "**{email} - alexa.{url}** \nDen Zwei Faktor Pin eingeben. \n{message}", "title": "Alexa Media Player - Zwei Faktor Authentifizierung" }, - "user_legacy": { + "user": { "data": { "cookies_txt": "config::step::user::data::cookies_txt", "debug": "Erweitertes debugging", "email": "Email Adresse", "exclude_devices": "Ausgeschlossene Geräte (komma getrennnt)", + "hass_url": "Url to access Home Assistant", "include_devices": "Eingebundene Geräte (komma getrennnt)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", @@ -76,16 +78,15 @@ "securitycode": "2FA Code (empfohlen, um Anmeldeprobleme zu vermeiden)", "url": "Amazon Region (z.B., amazon.de)" }, - "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" + "description": "Bitte geben Sie ihre Informationen ein.", + "title": "Alexa Media Player - Konfiguration" }, - "user": { + "user_legacy": { "data": { "cookies_txt": "config::step::user::data::cookies_txt", "debug": "Erweitertes debugging", "email": "Email Adresse", "exclude_devices": "Ausgeschlossene Geräte (komma getrennnt)", - "hass_url": "Url to access Home Assistant", "include_devices": "Eingebundene Geräte (komma getrennnt)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", @@ -95,8 +96,8 @@ "securitycode": "2FA Code (empfohlen, um Anmeldeprobleme zu vermeiden)", "url": "Amazon Region (z.B., amazon.de)" }, - "description": "Bitte geben Sie ihre Informationen ein.", - "title": "Alexa Media Player - Konfiguration" + "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", + "title": "Alexa Media Player - Legacy Configuration" } } }, @@ -104,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Schließen Sie Geräte ein, die über Echo verbunden sind", "queue_delay": "Sekunden zu warten, um Befehle in die Warteschlange zu stellen" } } diff --git a/custom_components/alexa_media/.translations/en.json b/custom_components/alexa_media/.translations/en.json index f64e203fc..f52044a02 100644 --- a/custom_components/alexa_media/.translations/en.json +++ b/custom_components/alexa_media/.translations/en.json @@ -8,9 +8,9 @@ "error": { "2fa_key_invalid": "Invalid Built-In 2FA key", "connection_error": "Error connecting; check network and retry", + "hass_url_invalid": "Unable to connect to Home Assistant url. Please check the External Url under Configuration -> General", "identifier_exists": "Email for Alexa URL already registered", "invalid_credentials": "Invalid credentials", - "hass_url_invalid": "Unable to connect to Home Assistant url. Please check the Internal Url under Configuration -> General", "unknown_error": "Unknown error, please enable advanced debugging and report log info" }, "step": { @@ -62,12 +62,13 @@ "description": "**{email} - alexa.{url}** \nEnter the One Time Password (OTP). \n{message}", "title": "Alexa Media Player - Two Factor Authentication" }, - "user_legacy": { + "user": { "data": { "cookies_txt": "Cookies.txt data", "debug": "Advanced debugging", "email": "Email Address", "exclude_devices": "Excluded device (comma separated)", + "hass_url": "Url to access Home Assistant", "include_devices": "Included device (comma separated)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", @@ -77,27 +78,26 @@ "securitycode": "2FA Code (recommended to avoid login issues)", "url": "Amazon region domain (e.g., amazon.co.uk)" }, - "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" + "description": "Please confirm the information below. For legacy configuration, disable `Use Login Proxy method` option.", + "title": "Alexa Media Player - Configuration" }, - "user": { + "user_legacy": { "data": { - "cookies_txt": "", + "cookies_txt": "Cookies.txt data", "debug": "Advanced debugging", "email": "Email Address", "exclude_devices": "Excluded device (comma separated)", - "hass_url": "Url to access Home Assistant", "include_devices": "Included device (comma separated)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", "password": "Password", "proxy": "Use Login Proxy method (2FA not required)", "scan_interval": "Seconds between scans", - "securitycode": "", + "securitycode": "2FA Code (recommended to avoid login issues)", "url": "Amazon region domain (e.g., amazon.co.uk)" }, - "description": "Please confirm the information below. For legacy configuration, disable `Use Login Proxy method` option.", - "title": "Alexa Media Player - Configuration" + "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", + "title": "Alexa Media Player - Legacy Configuration" } } }, @@ -105,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Include devices connected via Echo", "queue_delay": "Seconds to wait to queue commands together" } } diff --git a/custom_components/alexa_media/.translations/es.json b/custom_components/alexa_media/.translations/es.json index 866fd1a7d..d1ddc7a7c 100644 --- a/custom_components/alexa_media/.translations/es.json +++ b/custom_components/alexa_media/.translations/es.json @@ -8,6 +8,7 @@ "error": { "2fa_key_invalid": "Invalid Built-In 2FA key", "connection_error": "Error al conectar, verifique la red y vuelva a intentarlo", + "hass_url_invalid": "No se puede conectar a la url de Home Assistant. Compruebe la dirección URL externa en Configuración -> General", "identifier_exists": "Correo electrónico para la URL de Alexa ya registrado", "invalid_credentials": "Credenciales no válidas", "unknown_error": "Error desconocido, por favor revisa los registros en Home Assistant y reporta el error si es necesario." @@ -61,12 +62,13 @@ "description": "**{email} - alexa.{url}** \nIngrese la contraseña de un solo uso (OTP). \n{message}", "title": "Alexa Media Player - Autenticación de dos factores" }, - "user_legacy": { + "user": { "data": { "cookies_txt": "Datos de Cookies.txt", "debug": "Depuración avanzada", "email": "Dirección de correo electrónico", "exclude_devices": "Dispositivo excluido (separado por comas)", + "hass_url": "Url to access Home Assistant", "include_devices": "Dispositivo incluido (separado por comas)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", @@ -76,16 +78,15 @@ "securitycode": "Código 2FA (recomendado para evitar problemas de inicio de sesión)", "url": "Región del dominio de Amazon (por ejemplo, amazon.es)" }, - "description": "Por favor introduce tu [información](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **El método más rápido es [Importar cookies](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import).** \n**ADVERTENCIA: Amazon informará 'Introduce un correo electrónico o número de teléfono válido' si tu cuenta utiliza [códigos 2FA - Segundo Factor de Autenticación](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" + "description": "Confirme la siguiente información. Para la configuración heredada, desactive la opción `Usar método de proxy de inicio de sesión`.", + "title": "Alexa Media Player - Configuración" }, - "user": { + "user_legacy": { "data": { "cookies_txt": "Datos de Cookies.txt", "debug": "Depuración avanzada", "email": "Dirección de correo electrónico", "exclude_devices": "Dispositivo excluido (separado por comas)", - "hass_url": "Url to access Home Assistant", "include_devices": "Dispositivo incluido (separado por comas)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", @@ -96,7 +97,7 @@ "url": "Región del dominio de Amazon (por ejemplo, amazon.es)" }, "description": "Por favor introduce tu [información](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **El método más rápido es [Importar cookies](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import).** \n**ADVERTENCIA: Amazon informará 'Introduce un correo electrónico o número de teléfono válido' si tu cuenta utiliza [códigos 2FA - Segundo Factor de Autenticación](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Configuración" + "title": "Alexa Media Player - Legacy Configuration" } } }, @@ -104,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Incluir dispositivos conectados a través de Echo", "queue_delay": "Segundos a esperar para agrupar comandos" } } diff --git a/custom_components/alexa_media/.translations/fr.json b/custom_components/alexa_media/.translations/fr.json index 91abbb46e..6d73c4e90 100644 --- a/custom_components/alexa_media/.translations/fr.json +++ b/custom_components/alexa_media/.translations/fr.json @@ -8,6 +8,7 @@ "error": { "2fa_key_invalid": "Clé 2FA intégrée non valide", "connection_error": "Erreur de connexion; vérifier le réseau et réessayer", + "hass_url_invalid": "Impossible de se connecter à l'URL de Home Assistant. Veuillez vérifier l'URL externe sous Configuration - > Général", "identifier_exists": "Email pour l'URL Alexa déjà enregistré", "invalid_credentials": "Informations d'identification invalides", "unknown_error": "Erreur inconnue, veuillez signaler les informations du journal" @@ -15,7 +16,7 @@ "step": { "action_required": { "data": { - "proxy": "Use Login Proxy method (2FA not required)" + "proxy": "Utilisez la méthode du proxy de connexion (2FA non requis)" }, "description": "** {email} - alexa. {url} ** \n Amazon enverra une notification push conformément au message ci-dessous. Veuillez répondre complètement avant de continuer. \n {message}", "title": "Alexa Media Player - Action requise" @@ -32,7 +33,7 @@ "data": { "captcha": "Captcha", "password": "Mot de passe", - "proxy": "Use Login Proxy method (2FA not required)", + "proxy": "Utilisez la méthode du proxy de connexion (2FA non requis)", "securitycode": "Code 2FA (recommandé pour éviter les problèmes de connexion)" }, "description": "**{email} - alexa.{url}** \n{message} \n {captcha_image}", @@ -55,18 +56,19 @@ }, "twofactor": { "data": { - "proxy": "Use Login Proxy method (2FA not required)", + "proxy": "Utilisez la méthode du proxy de connexion (2FA non requis)", "securitycode": "2FA Code" }, "description": "**{email} - alexa.{url}** \nEntrez le mot de passe unique (OTP). \n{message}", "title": "Alexa Media Player - Authentification à deux facteurs" }, - "user_legacy": { + "user": { "data": { "cookies_txt": "Données cookies.txt", "debug": "Débogage avancé", "email": "Adresse Email", "exclude_devices": "Appareil exclu (séparé par des virgules)", + "hass_url": "Url to access Home Assistant", "include_devices": "Appareil inclus (séparé par des virgules)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Clé d'application 2FA intégrée (génère automatiquement des codes 2FA)", @@ -76,18 +78,17 @@ "securitycode": "Code 2FA (recommandé pour éviter les problèmes de connexion)", "url": "Domaine de la région Amazon (exemple, amazon.fr)" }, - "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" + "description": "Veuillez confirmer les informations ci-dessous. Pour la configuration héritée, désactivez l'option `Utiliser la méthode proxy de connexion`.", + "title": "Alexa Media Player - Configuration" }, - "user": { + "user_legacy": { "data": { "cookies_txt": "Données cookies.txt", "debug": "Débogage avancé", "email": "Adresse Email", "exclude_devices": "Appareil exclu (séparé par des virgules)", - "hass_url": "Url to access Home Assistant", "include_devices": "Appareil inclus (séparé par des virgules)", - "oauth_login": "Enable oauth-token app method", + "oauth_login": "Activer la méthode d'application oauth-token", "otp_secret": "Clé d'application 2FA intégrée (génère automatiquement des codes 2FA)", "password": "Mot de passe", "proxy": "Use Login Proxy method (2FA not required)", @@ -95,8 +96,8 @@ "securitycode": "Code 2FA (recommandé pour éviter les problèmes de connexion)", "url": "Domaine de la région Amazon (exemple, amazon.fr)" }, - "description": "Veuillez saisir vos informations.", - "title": "Alexa Media Player - Configuration" + "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", + "title": "Alexa Media Player - Legacy Configuration" } } }, @@ -104,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Inclure les appareils connectés via Echo", "queue_delay": "Secondes à attendre pour mettre les commandes en file d'attente ensemble" } } diff --git a/custom_components/alexa_media/.translations/it.json b/custom_components/alexa_media/.translations/it.json index 237cd0dcb..8d0c8998a 100644 --- a/custom_components/alexa_media/.translations/it.json +++ b/custom_components/alexa_media/.translations/it.json @@ -8,14 +8,15 @@ "error": { "2fa_key_invalid": "Invalid Built-In 2FA key", "connection_error": "Errore durante la connessione; controlla la rete e riprova", + "hass_url_invalid": "Impossibile collegarsi ad Home Assistant. Controllare l'URL esterno nel menu Configurazione -> Generale", "identifier_exists": "L'Email per l'URL di Alexa è già stata registrata", "invalid_credentials": "Credenziali non valide", - "unknown_error": "Errore sconosciuto, si prega di riportrtare i log di informazione" + "unknown_error": "Errore sconosciuto, si prega di abilitare il debug avanzato e riportare i log informativi" }, "step": { "action_required": { "data": { - "proxy": "Use Login Proxy method (2FA not required)" + "proxy": "Usa metodo Proxy per il login (2FA non richiesto)" }, "description": "** {email} - alexa. {url} ** \n Amazon invierà una notifica push per il seguente messaggio. Si prega di rispondere completamente prima di continuare. \n {message}", "title": "Alexa Media Player - Azione Richiesta" @@ -23,7 +24,7 @@ "authselect": { "data": { "authselectoption": "Metodo password usa e getta (OTP)", - "proxy": "Use Login Proxy method (2FA not required)" + "proxy": "Usa metodo Proxy per il login (2FA non richiesto)" }, "description": "**{email} - alexa.{url}** \n{message}", "title": "Alexa Media Player - Password Usa e Getta (One Time Password)" @@ -32,7 +33,7 @@ "data": { "captcha": "Captcha", "password": "Password", - "proxy": "Use Login Proxy method (2FA not required)", + "proxy": "Usa metodo Proxy per il login (2FA non richiesto)", "securitycode": "config::step::captcha::data::securitycode" }, "description": "**{email} - alexa.{url}** \n{message} \n {captcha_image}", @@ -41,7 +42,7 @@ "claimspicker": { "data": { "authselectoption": "Metodi di Verifica", - "proxy": "Use Login Proxy method (2FA not required)" + "proxy": "Usa metodo Proxy per il login (2FA non richiesto)" }, "description": "**{email} - alexa.{url}** \nPrego selezionare un metodo di verifica. (e.g., `0` or `1`) \n{message}", "title": "Alexa Media Player - Metodi di Verifica" @@ -55,48 +56,48 @@ }, "twofactor": { "data": { - "proxy": "Use Login Proxy method (2FA not required)", + "proxy": "Usa metodo Proxy per il login (2FA non richiesto)", "securitycode": "Codice autenticazione a 2 fattori (2FA)" }, "description": "**{email} - alexa.{url}** \nInserisci la password usa e getta (OTP). \n{message}", "title": "Alexa Media Player - Autenticazione a Due Fattori" }, - "user_legacy": { + "user": { "data": { - "cookies_txt": "config::step::user::data::cookies_txt", + "cookies_txt": "Dati cookies.txt", "debug": "Debug avanzato", "email": "Indirizzo email", "exclude_devices": "Dispositivi da escludere (separati da virgola)", + "hass_url": "Url to access Home Assistant", "include_devices": "Dispositivi da includere (separati da virgola)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", "password": "Password", "proxy": "Use Login Proxy method (2FA not required)", "scan_interval": "Tempo in secondi fra le scansioni", - "securitycode": "2FA Code (recommended to avoid login issues)", + "securitycode": "Codice 2FA (raccomandato per evitare problemi di login)", "url": "Regione del dominio Amazon (e.g., amazon.it)" }, - "description": "Prego inserisci le tue [informazioni](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) potrebbe essere più semplice!** \n**ATTENZIONE: Amazon risponde incorrettamente 'Inserire una mail valida o un numero di telefono' quando è richiesto il codice 2FA](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" + "description": "Confermare le informazioni sottostanti. Per le vecchie configurazioni, disabilitare l'opzione `Usa Proxy come metodo di login`.", + "title": "Alexa Media Player - Configurazione" }, - "user": { + "user_legacy": { "data": { - "cookies_txt": "config::step::user::data::cookies_txt", + "cookies_txt": "Dati cookies.txt", "debug": "Debug avanzato", "email": "Indirizzo email", "exclude_devices": "Dispositivi da escludere (separati da virgola)", - "hass_url": "Url to access Home Assistant", "include_devices": "Dispositivi da includere (separati da virgola)", - "oauth_login": "Enable oauth-token app method", - "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", + "oauth_login": "Abilitare il metodo oauth-token app", + "otp_secret": "Chiave 2FA interna (genera automaticamente i codici 2FA)", "password": "Password", - "proxy": "Use Login Proxy method (2FA not required)", + "proxy": "Usa il Proxy come metodo di login (2FA non richiesta)", "scan_interval": "Tempo in secondi fra le scansioni", - "securitycode": "2FA Code (recommended to avoid login issues)", - "url": "Regione del dominio Amazon (e.g., amazon.it)" + "securitycode": "Codice 2FA (raccomandato per evitare problemi di login)", + "url": "Regione del dominio Amazon (e.g. amazon.it)" }, "description": "Prego inserisci le tue [informazioni](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) potrebbe essere più semplice!** \n**ATTENZIONE: Amazon risponde incorrettamente 'Inserire una mail valida o un numero di telefono' quando è richiesto il codice 2FA](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Configurazione" + "title": "Alexa Media Player - Legacy Configuration" } } }, @@ -104,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Includi i dispositivi collegati tramite Echo", "queue_delay": "Secondi di attesa per accodare i comandi insieme" } } diff --git a/custom_components/alexa_media/.translations/nb.json b/custom_components/alexa_media/.translations/nb.json index 55b6cc071..333797c9b 100644 --- a/custom_components/alexa_media/.translations/nb.json +++ b/custom_components/alexa_media/.translations/nb.json @@ -8,6 +8,7 @@ "error": { "2fa_key_invalid": "Invalid Built-In 2FA key", "connection_error": "Feil ved tilkobling; sjekk nettverket og prøv på nytt", + "hass_url_invalid": "Kan ikke koble til nettadressen til hjemmeassistenten. Vennligst sjekk den eksterne nettadressen under Konfigurasjon - > Generelt", "identifier_exists": "E-post for Alexa URL allerede registrert", "invalid_credentials": "ugyldige legitimasjon", "unknown_error": "Ukjent feil, vennligst rapporter logginfo" @@ -61,12 +62,13 @@ "description": "**{email} - alexa.{url}** \nSkriv inn engangspassordet (OTP). \n>{message}", "title": "Alexa Media Player - Tofaktorautentisering" }, - "user_legacy": { + "user": { "data": { "cookies_txt": "config::step::user::data::cookies_txt", "debug": "Avansert feilsøking", "email": "Epostadresse", "exclude_devices": "Ekskludert enhet (kommaseparert)", + "hass_url": "Url to access Home Assistant", "include_devices": "Inkluder enhet (kommaseparert)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", @@ -76,16 +78,15 @@ "securitycode": "2FA-kode (anbefales for å unngå påloggingsproblemer)", "url": "Amazon-regiondomenet (f.eks. Amazon.co.uk)" }, - "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" + "description": "Bekreft informasjonen nedenfor. For eldre konfigurasjon, deaktiver alternativet \"Bruk innlogging proxy-metode\".", + "title": "Alexa Media Player - Konfigurasjon" }, - "user": { + "user_legacy": { "data": { "cookies_txt": "config::step::user::data::cookies_txt", "debug": "Avansert feilsøking", "email": "Epostadresse", "exclude_devices": "Ekskludert enhet (kommaseparert)", - "hass_url": "Url to access Home Assistant", "include_devices": "Inkluder enhet (kommaseparert)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", @@ -95,8 +96,8 @@ "securitycode": "2FA-kode (anbefales for å unngå påloggingsproblemer)", "url": "Amazon-regiondomenet (f.eks. Amazon.co.uk)" }, - "description": "Vennligst skriv inn informasjonen din. \n**WARNING: Amazon rapporterer feilaktig 'Angi en gyldig e-postadresse eller et mobilnummer' når 2FA-kode kreves.** \n>{message}", - "title": "Alexa Media Player - Konfigurasjon" + "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", + "title": "Alexa Media Player - Legacy Configuration" } } }, @@ -104,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Inkluder enheter som er koblet til via Echo", "queue_delay": "Sekunder for å vente på køkommandoer sammen" } } diff --git a/custom_components/alexa_media/.translations/nl.json b/custom_components/alexa_media/.translations/nl.json index b10cbaf66..9a4afdbec 100644 --- a/custom_components/alexa_media/.translations/nl.json +++ b/custom_components/alexa_media/.translations/nl.json @@ -8,6 +8,7 @@ "error": { "2fa_key_invalid": "Invalid Built-In 2FA key", "connection_error": "Fout bij verbinden; controleer netwerk en probeer opnieuw", + "hass_url_invalid": "Kan geen verbinding maken met de Home Assistant-URL. Controleer de externe URL onder Configuratie - > Algemeen", "identifier_exists": "Dit e-mailadres is reeds geregistreerd", "invalid_credentials": "Ongeldige inloggegevens", "unknown_error": "Onbekende fout, meld de loggegevens" @@ -61,12 +62,13 @@ "description": "**{email} - alexa.{url}** \nGeef de verificatiecode in. \n{message}", "title": "Alexa Media Player - Tweestapsverificatie" }, - "user_legacy": { + "user": { "data": { "cookies_txt": "config::step::user::data::cookies_txt", "debug": "Geavanceerd debuggen", "email": "E-mailadres", "exclude_devices": "Apparaten uitsluiten (Scheiding: komma)", + "hass_url": "Url to access Home Assistant", "include_devices": "Apparaten toevoegen (Scheiding: komma)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", @@ -76,16 +78,15 @@ "securitycode": "2FA Code (recommended to avoid login issues)", "url": "Domeinnaam van Amazon regio (bv.amazon.co.uk)" }, - "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" + "description": "Vul je gegevens in a.u.b.", + "title": "Alexa Media Player - Configuratie" }, - "user": { + "user_legacy": { "data": { "cookies_txt": "config::step::user::data::cookies_txt", "debug": "Geavanceerd debuggen", "email": "E-mailadres", "exclude_devices": "Apparaten uitsluiten (Scheiding: komma)", - "hass_url": "Url to access Home Assistant", "include_devices": "Apparaten toevoegen (Scheiding: komma)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", @@ -95,8 +96,8 @@ "securitycode": "2FA Code (recommended to avoid login issues)", "url": "Domeinnaam van Amazon regio (bv.amazon.co.uk)" }, - "description": "Vul je gegevens in a.u.b.", - "title": "Alexa Media Player - Configuratie" + "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", + "title": "Alexa Media Player - Legacy Configuration" } } }, @@ -104,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Inclusief apparaten die zijn verbonden via Echo", "queue_delay": "Seconden om te wachten om opdrachten in de wachtrij te plaatsen" } } diff --git a/custom_components/alexa_media/.translations/pl.json b/custom_components/alexa_media/.translations/pl.json index 622cc24d4..eb6a80edf 100644 --- a/custom_components/alexa_media/.translations/pl.json +++ b/custom_components/alexa_media/.translations/pl.json @@ -8,6 +8,7 @@ "error": { "2fa_key_invalid": "Nieprawidłowy klucz z wbudowanej aplikacji uwierzytelniania dwuskładnikowego", "connection_error": "Błąd podczas łączenia; sprawdź sieć i spróbuj ponownie", + "hass_url_invalid": "Nie można połączyć się z adresem URL Home Assistanta. Sprawdź publiczny adres URL w sekcji Konfiguracja -> Ogólne", "identifier_exists": "Adres e-mail dla Alexy już jest zarejestrowany", "invalid_credentials": "Nieprawidłowe dane logowania", "unknown_error": "Nieznany błąd, zgłoś log z tego zdarzenia" @@ -15,7 +16,7 @@ "step": { "action_required": { "data": { - "proxy": "Use Login Proxy method (2FA not required)" + "proxy": "Użyj metody logowania proxy (2FA nie jest wymagane)" }, "description": "**{email} - alexa.{url}** \nAmazon wyśle powiadomienie push zgodnie z poniższą wiadomością. Przed kontynuowaniem odpowiedz na wiadomość. \n{message}", "title": "Alexa Media Player — wymagane działanie" @@ -23,7 +24,7 @@ "authselect": { "data": { "authselectoption": "Metoda OTP", - "proxy": "Use Login Proxy method (2FA not required)" + "proxy": "Użyj metody logowania proxy (2FA nie jest wymagane)" }, "description": "**{email} - alexa.{url}** \n{message}", "title": "Alexa Media Player — hasło jednorazowe" @@ -32,7 +33,7 @@ "data": { "captcha": "Kod Captcha", "password": "Hasło", - "proxy": "Use Login Proxy method (2FA not required)", + "proxy": "Użyj metody logowania proxy (2FA nie jest wymagane)", "securitycode": "Kod uwierzytelniania dwuskładnikowego" }, "description": "**{email} - alexa.{url}** \n{message} \n {captcha_image}", @@ -41,7 +42,7 @@ "claimspicker": { "data": { "authselectoption": "Metoda weryfikacji", - "proxy": "Use Login Proxy method (2FA not required)" + "proxy": "Użyj metody logowania proxy (2FA nie jest wymagane)" }, "description": "**{email} - alexa.{url}** \nWybierz metodę weryfikacji. (np., `0` lub `1`) \n{message}", "title": "Alexa Media Player — metoda weryfikacji" @@ -55,48 +56,48 @@ }, "twofactor": { "data": { - "proxy": "Use Login Proxy method (2FA not required)", + "proxy": "Użyj metody logowania proxy (2FA nie jest wymagane)", "securitycode": "Kod uwierzytelniania dwuskładnikowego" }, "description": "**{email} - alexa.{url}** \nWprowadź hasło jednorazowe (OTP). \n{message}", "title": "Alexa Media Player — uwierzytelnianie dwuskładnikowe" }, - "user_legacy": { + "user": { "data": { "cookies_txt": "zawartość pliku cookies.txt", "debug": "Zaawansowane debugowanie", "email": "Adres email", "exclude_devices": "Wykluczone urządzenia (oddzielone przecinkami)", + "hass_url": "URL dostępu do Home Assistanta", "include_devices": "Dodawane urządzenia (oddzielone przecinkami)", - "oauth_login": "Enable oauth-token app method", + "oauth_login": "Włącz metodę tokena OAuth aplikacji", "otp_secret": "Wbudowana aplikacja kluczy uwierzytelniania dwuskładnikowego (automatycznie generuje kody uwierzytelniania dwuskładnikowego)", "password": "Hasło", - "proxy": "Use Login Proxy method (2FA not required)", + "proxy": "Użyj metody logowania proxy (2FA nie jest wymagane)", "scan_interval": "Interwał skanowania (sekundy)", - "securitycode": "Kod uwierzytelniania dwuskładnikowego", + "securitycode": "Kod uwierzytelniania dwuskładnikowego (zalecany w celu uniknięcia problemów z logowaniem)", "url": "Region/domena Amazon (np. amazon.co.uk)" }, - "description": "Wprowadź [dane](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Import pliku Cookie](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) może być najłatwiejszą metodą!** \n**OSTRZEŻENIE: Amazon nieprawidłowo zgłasza 'Wprowadź prawidłowy adres e-mail lub numer telefonu komórkowego', gdy wymagany jest [kod uwierzytelniania dwuskładnikowego](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" + "description": "Potwierdź poniższe informacje. W przypadku starszych konfiguracji wyłącz opcję 'Użyj metody logowania proxy'.", + "title": "Alexa Media Player — konfiguracja" }, - "user": { + "user_legacy": { "data": { "cookies_txt": "zawartość pliku cookies.txt", "debug": "Zaawansowane debugowanie", - "email": "Adres email", + "email": "Adres e-mail", "exclude_devices": "Wykluczone urządzenia (oddzielone przecinkami)", - "hass_url": "Url to access Home Assistant", "include_devices": "Dodawane urządzenia (oddzielone przecinkami)", - "oauth_login": "Enable oauth-token app method", + "oauth_login": "Włącz metodę tokena OAuth aplikacji", "otp_secret": "Wbudowana aplikacja kluczy uwierzytelniania dwuskładnikowego (automatycznie generuje kody uwierzytelniania dwuskładnikowego)", "password": "Hasło", - "proxy": "Use Login Proxy method (2FA not required)", + "proxy": "Użyj metody logowania proxy (2FA nie jest wymagane)", "scan_interval": "Interwał skanowania (sekundy)", - "securitycode": "Kod uwierzytelniania dwuskładnikowego (zalecany w celu uniknięcia problemów z logowaniem)", + "securitycode": "Kod uwierzytelniania dwuskładnikowego", "url": "Region/domena Amazon (np. amazon.co.uk)" }, "description": "Wprowadź [dane](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Import pliku Cookie](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) może być najłatwiejszą metodą!** \n**OSTRZEŻENIE: Amazon nieprawidłowo zgłasza 'Wprowadź prawidłowy adres e-mail lub numer telefonu komórkowego', gdy wymagany jest [kod uwierzytelniania dwuskładnikowego](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player — konfiguracja" + "title": "Alexa Media Player — starsza konfiguracja" } } }, @@ -104,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Uwzględnij urządzenia podłączone przez Echo", "queue_delay": "Sekundy oczekiwania na kolejkowanie poleceń" } } diff --git a/custom_components/alexa_media/.translations/pt_BR.json b/custom_components/alexa_media/.translations/pt_BR.json index 0033d847f..2696cc29b 100644 --- a/custom_components/alexa_media/.translations/pt_BR.json +++ b/custom_components/alexa_media/.translations/pt_BR.json @@ -1,103 +1,103 @@ { "config": { "abort": { - "forgot_password": "The Forgot Password page was detected. This normally is the result of too may failed logins. Amazon may require action before a relogin can be attempted.", - "login_failed": "Alexa Media Player failed to login.", - "reauth_successful": "Alexa Media Player successfully reauthenticated." + "forgot_password": "A página para senha esquecida foi detectada. Isto normalmente é o resultado de muitas tentativas de login falhas. A amazon pode requisitar ação antes de um novo login ser tentando.", + "login_failed": "Alexa Media Player falhou no login.", + "reauth_successful": "Alexa Media Player re-autenticado com sucesso." }, "error": { - "2fa_key_invalid": "Invalid Built-In 2FA key", - "connection_error": "Error connecting; check network and retry", - "hass_url_invalid": "Unable to connect to Home Assistant url. Please check the Internal Url under Configuration -> General", - "identifier_exists": "Email for Alexa URL already registered", - "invalid_credentials": "Invalid credentials", - "unknown_error": "Unknown error, please enable advanced debugging and report log info" + "2fa_key_invalid": "Chave integrada 2FA inválida", + "connection_error": "Erro de conexão; Verifique a sua conexão e tente novamente", + "hass_url_invalid": "Não foi possível conectar a URL do Home Assistant. Por favor verifique a URL externa em Configuração -> Geral", + "identifier_exists": "Email para URL Alexa já registrado", + "invalid_credentials": "Credenciais inválidas", + "unknown_error": "Erro desconhecido, favor habilitar a depuração avançada e reporte as informações de registro" }, "step": { "action_required": { "data": { - "proxy": "Use Login Proxy method (2FA not required)" + "proxy": "Usar método Login Proxy (Não requer 2FA)" }, - "description": "**{email} - alexa.{url}** \nAmazon will send a push notification per the below message. Please completely respond before continuing. \n{message}", - "title": "Alexa Media Player - Action Required" + "description": "**{email} - alexa.{url}** \nA amazon enviará uma notificação push para a mensagem abaixo. Por favor responda ela completamente antes de continuar. \n{message}", + "title": "Alexa Media Player - Ação Requisitada" }, "authselect": { "data": { - "authselectoption": "OTP method", - "proxy": "Use Login Proxy method (2FA not required)" + "authselectoption": "Método OTP", + "proxy": "Usar método Login Proxy (Não requer 2FA)" }, - "description": "**{email} - alexa.{url}** \n{message}", - "title": "Alexa Media Player - One Time Password" + "description": "**{email} - alexa.{url}**\n{message}", + "title": "Alexa Media Player - Senha de uso único" }, "captcha": { "data": { "captcha": "Captcha", - "password": "Password", - "proxy": "Use Login Proxy method (2FA not required)", - "securitycode": "2FA Code (recommended to avoid login issues)" + "password": "Senha", + "proxy": "Usar método Login Proxy (Não requer 2FA)", + "securitycode": "Código 2FA (recomendado para evitar problemas de login)" }, "description": "**{email} - alexa.{url}** \n{message} \n {captcha_image}", "title": "Alexa Media Player - Captcha" }, "claimspicker": { "data": { - "authselectoption": "Verification method", - "proxy": "Use Login Proxy method (2FA not required)" + "authselectoption": "Método de verificação", + "proxy": "Usar método Login Proxy (Não requer 2FA)" }, - "description": "**{email} - alexa.{url}** \nPlease select verification method by number. (e.g., `0` or `1`) \n{message}", - "title": "Alexa Media Player - Verification Method" + "description": "**{email} - alexa.{url}** \nPor favor selecione um método de verificação por número. (ex: `0` ou `1`) \n{message}", + "title": "Alexa Media Player - Método de verificação" }, "totp_register": { "data": { - "registered": "OTP from the Built-in 2FA App Key confirmed successfully." + "registered": "OTP do aplicativo integrado de chave 2FA confirmado com sucesso." }, - "description": "**{email} - alexa.{url}** \nHave you successfully confirmed an OTP from the Built-in 2FA App Key with Amazon? \n >OTP Code {message}", - "title": "Alexa Media Player - OTP Confirmation" + "description": "**{email} - alexa.{url}** \nVocê confirmou com sucesso um OTP no aplicativo integrado de chave 2FA com a amazon? \n >OTP Code {message}", + "title": "Alexa Media Player - Confirmação OTP" }, "twofactor": { "data": { - "proxy": "Use Login Proxy method (2FA not required)", - "securitycode": "2FA Code" + "proxy": "Usar método Login Proxy (Não requer 2FA)", + "securitycode": "Código 2FA" }, - "description": "**{email} - alexa.{url}** \nEnter the One Time Password (OTP). \n{message}", - "title": "Alexa Media Player - Two Factor Authentication" + "description": "**{email} - alexa.{url}** \nInsira a senha de uso único (OTP).\n{message}", + "title": "Alexa Media Player - Autenticação de dois fatores" }, - "user_legacy": { + "user": { "data": { - "cookies_txt": "Cookies.txt data", - "debug": "Advanced debugging", - "email": "Email Address", - "exclude_devices": "Excluded device (comma separated)", - "include_devices": "Included device (comma separated)", - "oauth_login": "Enable oauth-token app method", - "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", - "password": "Password", - "proxy": "Use Login Proxy method (2FA not required)", - "scan_interval": "Seconds between scans", - "securitycode": "2FA Code (recommended to avoid login issues)", - "url": "Amazon region domain (e.g., amazon.co.uk)" + "cookies_txt": "Dados de cookies.txt", + "debug": "Depuração avançada", + "email": "Endereço de email", + "exclude_devices": "Dispositivos exclusos (separados por virgula)", + "hass_url": "Url para acesso ao Home Assistant", + "include_devices": "Dispositivos inclusos (separados por vírgula)", + "oauth_login": "Habilitar o aplicativo para método auth-token", + "otp_secret": "Aplicativo integrado para chaves 2FA (Automaticamente gera códigos 2FA)", + "password": "Senha", + "proxy": "Usar método Login Proxy (Não requer 2FA)", + "scan_interval": "Segundos entre varreduras", + "securitycode": "Código 2FA (recomendado para evitar problemas de login)", + "url": "Domínio regional da amazon (ex: amazon.co.uk)" }, - "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" + "description": "Por favor, confirme as informações abaixo. Para configuração legada, desative a opção `Usar método de proxy de login'.", + "title": "Alexa Media Player - Configurações" }, - "user": { + "user_legacy": { "data": { - "cookies_txt": "Dados de cookies.txt", - "debug": "Advanced debugging", - "email": "Email Address", - "exclude_devices": "Excluded device (comma separated)", - "hass_url": "Url to access Home Assistant", - "include_devices": "Included device (comma separated)", - "oauth_login": "Enable oauth-token app method", - "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", - "password": "Password", - "proxy": "Use Login Proxy method (2FA not required)", - "scan_interval": "Seconds between scans", + "cookies_txt": "Cookies.txt data", + "debug": "Depuração avançada", + "email": "Endereço de email", + "exclude_devices": "Dispositivos exclusos (separados por virgula)", + "include_devices": "Dispositivos inclusos (separados por vírgula)", + "oauth_login": "Habilitar o aplicativo para método auth-token", + "otp_secret": "Aplicativo integrado para chaves 2FA (Automaticamente gera códigos 2FA)", + "password": "Senha", + "proxy": "Usar método Login Proxy (Não requer 2FA)", + "scan_interval": "Segundos entre varreduras", "securitycode": "Código 2FA (recomendado para evitar problemas de login)", - "url": "Amazon region domain (e.g., amazon.co.uk)" + "url": "Domínio regional da amazon (ex: amazon.co.uk)" }, - "description": "Por favor, confirme as informações abaixo. Para configuração legada, desative a opção `Usar método de proxy de login`.", - "title": "Alexa Media Player - Configuration" + "description": "Por favor insira sua [informação](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) pode ser mais fácil!** \n**AVISO! A amazon reporta incorretamente 'Insira um email ou número de telefone válido' quando [Código de 2FA é requirido](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", + "title": "Alexa Media Player - Configurações legado" } } }, @@ -105,7 +105,8 @@ "step": { "init": { "data": { - "queue_delay": "Seconds to wait to queue commands together" + "extended_entity_discovery": "Inclui dispositivos conectados via Echo", + "queue_delay": "Segundos para aguardar antes de enfileirar juntos" } } } diff --git a/custom_components/alexa_media/.translations/pt_PT.json b/custom_components/alexa_media/.translations/pt_PT.json index e3bfdf99e..5cf76c7d2 100644 --- a/custom_components/alexa_media/.translations/pt_PT.json +++ b/custom_components/alexa_media/.translations/pt_PT.json @@ -8,7 +8,7 @@ "error": { "2fa_key_invalid": "Chave 2FA integrada inválida", "connection_error": "Erro ao conectar; verifique a rede e tente novamente", - "hass_url_invalid": "Não foi possível conectar ao URL do Home Assistant. Verifique o URL interno em Configuração - > Geral", + "hass_url_invalid": "Não foi possível conectar ao URL do Home Assistant. Verifique o URL externo em Configuração - > Geral", "identifier_exists": "E-mail para URL Alexa já registado", "invalid_credentials": "Credenciais inválidas", "unknown_error": "Erro desconhecido, por favor habilite depuração avançada e informações de log de relatório" @@ -62,12 +62,13 @@ "description": "**{email} - alexa.{url}** \nDigite a senha de uso único (OTP). \n{message}", "title": "Alexa Media Player - autenticação de dois fatores" }, - "user_legacy": { + "user": { "data": { "cookies_txt": "Dados de cookies.txt", "debug": "Depuração avançada", "email": "Endereço de e-mail", "exclude_devices": "Dispositivo excluído (separado por vírgula)", + "hass_url": "URL para aceder o Home Assistant", "include_devices": "Dispositivo incluído (separado por vírgula)", "oauth_login": "Habilitar método de aplicativo oauth-token", "otp_secret": "Chave de aplicativo 2FA integrada (gerar códigos 2FA automaticamente)", @@ -77,16 +78,15 @@ "securitycode": "Código 2FA (recomendado para evitar problemas de login)", "url": "Região do domínio Amazon (ex. amazon.com.br)" }, - "description": "Por favor, introduza a sua [informação](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) pode ser mais fácil!** \n**Aviso: a Amazon informa incorretamente 'Insira um e-mail ou número de celular válido' quando [2FA Code é necessário](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Configuração de Compatibilidade" + "description": "Por favor, confirme as informações abaixo. Para configuração de compatibilidade, desative a opção 'Usar método de proxy de login'.", + "title": "Alexa Media Player - Configuração" }, - "user": { + "user_legacy": { "data": { "cookies_txt": "Dados de cookies.txt", "debug": "Depuração avançada", "email": "Endereço de e-mail", "exclude_devices": "Dispositivo excluído (separado por vírgula)", - "hass_url": "URL para aceder o Home Assistant", "include_devices": "Dispositivo incluído (separado por vírgula)", "oauth_login": "Habilitar método de aplicativo oauth-token", "otp_secret": "Chave de aplicativo 2FA integrada (gerar códigos 2FA automaticamente)", @@ -96,8 +96,8 @@ "securitycode": "Código 2FA (recomendado para evitar problemas de login)", "url": "Região do domínio Amazon (ex. amazon.com.br)" }, - "description": "Por favor, confirme as informações abaixo. Para configuração de compatibilidade, desative a opção 'Usar método de proxy de login'.", - "title": "Alexa Media Player - Configuração" + "description": "Por favor, introduza a sua [informação](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) pode ser mais fácil!** \n**Aviso: a Amazon informa incorretamente 'Insira um e-mail ou número de celular válido' quando [2FA Code é necessário](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", + "title": "Alexa Media Player - Configuração de Compatibilidade" } } }, @@ -105,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Inclui dispositivos conectados via Echo", "queue_delay": "Segundos de espera para agrupar comandos" } } diff --git a/custom_components/alexa_media/.translations/ru.json b/custom_components/alexa_media/.translations/ru.json index 40d67f914..ee10f8285 100644 --- a/custom_components/alexa_media/.translations/ru.json +++ b/custom_components/alexa_media/.translations/ru.json @@ -8,6 +8,7 @@ "error": { "2fa_key_invalid": "Invalid Built-In 2FA key", "connection_error": "Ошибка подключения; проверьте сеть и повторите попытку", + "hass_url_invalid": "Невозможно подключиться к URL-адресу Home Assistant. Пожалуйста, проверьте внешний URL-адрес в разделе \"Конфигурация\" - > Общие\".", "identifier_exists": "Электронная почта для Alexa уже зарегистрирована", "invalid_credentials": "Неверные учетные данные", "unknown_error": "Неизвестная ошибка, пожалуйста, сообщите информацию журнала" @@ -61,12 +62,13 @@ "description": "**{email} - alexa.{url}**\nВведите одноразовый пароль.\n{message}", "title": "Alexa Media Player - Двух факторная идентификация" }, - "user_legacy": { + "user": { "data": { "cookies_txt": "config::step::user::data::cookies_txt", "debug": "Расширенные возможности отладки", "email": "Адрес электронной почты", "exclude_devices": "Исключенные устройства (через запятую)", + "hass_url": "Url to access Home Assistant", "include_devices": "Включенные устройства (разделенное запятыми)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", @@ -76,16 +78,15 @@ "securitycode": "2FA Code (recommended to avoid login issues)", "url": "Домен региона Amazon (например, amazon.co.uk)" }, - "description": "Пожалуйста, введите свои данные.{message}", - "title": "Alexa Media Player - Legacy Configuration" + "description": "Пожалуйста, подтвердите информацию ниже. Для старой конфигурации отключите опцию `Use Login Proxy method`.", + "title": "Alexa Media Player - Конфигурация" }, - "user": { + "user_legacy": { "data": { "cookies_txt": "config::step::user::data::cookies_txt", "debug": "Расширенные возможности отладки", "email": "Адрес электронной почты", "exclude_devices": "Исключенные устройства (через запятую)", - "hass_url": "Url to access Home Assistant", "include_devices": "Включенные устройства (разделенное запятыми)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", @@ -96,7 +97,7 @@ "url": "Домен региона Amazon (например, amazon.co.uk)" }, "description": "Пожалуйста, введите свои данные.{message}", - "title": "Alexa Media Player - Конфигурация" + "title": "Alexa Media Player - Legacy Configuration" } } }, @@ -104,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Включить устройства, подключенные через Echo", "queue_delay": "Секунды ожидания, чтобы выполнить команды вместе" } } diff --git a/custom_components/alexa_media/.translations/zh-Hans.json b/custom_components/alexa_media/.translations/zh-Hans.json index 6fa4a55db..4af369e37 100644 --- a/custom_components/alexa_media/.translations/zh-Hans.json +++ b/custom_components/alexa_media/.translations/zh-Hans.json @@ -8,6 +8,7 @@ "error": { "2fa_key_invalid": "Invalid Built-In 2FA key", "connection_error": "连接错误;检查网络并重试", + "hass_url_invalid": "Unable to connect to Home Assistant url. Please check the External Url under Configuration -> General", "identifier_exists": "Alexa URL的电子邮件已注册", "invalid_credentials": "Invalid credentials", "unknown_error": "Unknown error, please report log info" @@ -61,12 +62,13 @@ "description": "**{email} - Alexa.{url} ** \n输入一次性密码(OTP)。 \n {message}", "title": "Alexa Media Player - Two Factor Authentication" }, - "user_legacy": { + "user": { "data": { - "cookies_txt": "config::step::user::data::cookies_txt", + "cookies_txt": "Cookie.txt数据", "debug": "高级调试", "email": "电子邮件地址", "exclude_devices": "Excluded device (comma separated)", + "hass_url": "Url to access Home Assistant", "include_devices": "Included device (comma separated)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", @@ -76,16 +78,15 @@ "securitycode": "2FA Code (recommended to avoid login issues)", "url": "Amazon region domain (e.g., amazon.co.uk)" }, - "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", - "title": "Alexa Media Player - Legacy Configuration" + "description": "请确认以下信息。对于旧版配置,请禁用“使用登录代理方法”选项。", + "title": "Alexa Media Player-配置" }, - "user": { + "user_legacy": { "data": { "cookies_txt": "config::step::user::data::cookies_txt", "debug": "高级调试", "email": "电子邮件地址", "exclude_devices": "Excluded device (comma separated)", - "hass_url": "Url to access Home Assistant", "include_devices": "Included device (comma separated)", "oauth_login": "Enable oauth-token app method", "otp_secret": "Built-in 2FA App Key (automatically generate 2FA Codes)", @@ -95,8 +96,8 @@ "securitycode": "2FA Code (recommended to avoid login issues)", "url": "Amazon region domain (e.g., amazon.co.uk)" }, - "description": "请输入您的信息。", - "title": "Alexa Media Player-配置" + "description": "Please enter your [information](https://github.com/custom-components/alexa_media_player/wiki/Configuration#integrations-page). **[Cookie import](https://github.com/custom-components/alexa_media_player/wiki/Configuration#cookie-import) may be easiest!** \n**WARNING: Amazon incorrectly reports 'Enter a valid email or mobile number' when [2FA Code is required](https://github.com/custom-components/alexa_media_player/wiki/Configuration#enable-two-factor-authentication-for-your-amazon-account).** \n>{message}", + "title": "Alexa Media Player - Legacy Configuration" } } }, @@ -104,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Include devices connected via Echo", "queue_delay": "Seconds to wait to queue commands together" } } diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 80b0db0bd..293faf7e2 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -8,17 +8,17 @@ """ import asyncio from datetime import datetime, timedelta -from json import JSONDecodeError +from json import JSONDecodeError, loads import logging import time -from typing import Optional, Text +from typing import Optional from alexapy import ( AlexaAPI, AlexaLogin, AlexapyConnectionError, AlexapyLoginError, - WebsocketEchoClient, + HTTP2EchoClient, __version__ as alexapy_version, hide_email, hide_serial, @@ -36,7 +36,9 @@ EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) +from homeassistant.core import callback from homeassistant.data_entry_flow import UnknownFlow +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -49,18 +51,18 @@ from .const import ( ALEXA_COMPONENTS, CONF_ACCOUNTS, - CONF_COOKIES_TXT, CONF_DEBUG, CONF_EXCLUDE_DEVICES, CONF_EXTENDED_ENTITY_DISCOVERY, CONF_INCLUDE_DEVICES, CONF_OAUTH, - CONF_OAUTH_LOGIN, CONF_OTPSECRET, + CONF_PUBLIC_URL, CONF_QUEUE_DELAY, DATA_ALEXAMEDIA, DATA_LISTENER, DEFAULT_EXTENDED_ENTITY_DISCOVERY, + DEFAULT_PUBLIC_URL, DEFAULT_QUEUE_DELAY, DEPENDENT_ALEXA_COMPONENTS, DOMAIN, @@ -149,9 +151,12 @@ async def async_setup(hass, config, discovery_info=None): CONF_SCAN_INTERVAL: account[ CONF_SCAN_INTERVAL ].total_seconds(), - CONF_OAUTH: account.get(CONF_OAUTH, {}), - CONF_OTPSECRET: account.get(CONF_OTPSECRET, ""), - CONF_OAUTH_LOGIN: account.get(CONF_OAUTH_LOGIN, True), + CONF_OAUTH: account.get( + CONF_OAUTH, entry.data.get(CONF_OAUTH, {}) + ), + CONF_OTPSECRET: account.get( + CONF_OTPSECRET, entry.data.get(CONF_OTPSECRET, "") + ), }, ) entry_found = True @@ -172,7 +177,6 @@ async def async_setup(hass, config, discovery_info=None): CONF_SCAN_INTERVAL: account[CONF_SCAN_INTERVAL].total_seconds(), CONF_OAUTH: account.get(CONF_OAUTH, {}), CONF_OTPSECRET: account.get(CONF_OTPSECRET, ""), - CONF_OAUTH_LOGIN: account.get(CONF_OAUTH_LOGIN, True), }, ) ) @@ -181,6 +185,7 @@ async def async_setup(hass, config, discovery_info=None): # @retry_async(limit=5, delay=5, catch_exceptions=True) async def async_setup_entry(hass, config_entry): + # noqa: MC0001 """Set up Alexa Media Player as config entry.""" async def close_alexa_media(event=None) -> None: @@ -191,6 +196,7 @@ async def close_alexa_media(event=None) -> None: await close_connections(hass, email) async def complete_startup(event=None) -> None: + # pylint: disable=unused-argument """Run final tasks after startup.""" _LOGGER.debug("Completing remaining startup tasks.") await asyncio.sleep(10) @@ -217,12 +223,11 @@ async def relogin(event=None) -> None: otp_secret=account.get(CONF_OTPSECRET, ""), oauth=account.get(CONF_OAUTH, {}), uuid=uuid, - oauth_login=bool( - account.get(CONF_OAUTH, {}).get("access_token") - or account.get(CONF_OAUTH_LOGIN) - ), + oauth_login=True, ) hass.data[DATA_ALEXAMEDIA]["accounts"][email]["login_obj"] = login_obj + else: + login_obj.oauth_login = True await login_obj.reset() # await login_obj.login() if await test_login_status(hass, config_entry, login_obj): @@ -263,6 +268,7 @@ async def login_success(event=None) -> None: "switch": {}, "guard": [], "light": [], + "binary_sensor": [], "temperature": [], }, "entities": { @@ -270,15 +276,16 @@ async def login_success(event=None) -> None: "switch": {}, "sensor": {}, "light": [], + "binary_sensor": [], "alarm_control_panel": {}, }, "excluded": {}, "new_devices": True, - "websocket_lastattempt": 0, - "websocketerror": 0, - "websocket_commands": {}, - "websocket_activity": {"serials": {}, "refreshed": {}}, - "websocket": None, + "http2_lastattempt": 0, + "http2error": 0, + "http2_commands": {}, + "http2_activity": {"serials": {}, "refreshed": {}}, + "http2": None, "auth_info": None, "second_account_index": 0, "should_get_network": True, @@ -286,6 +293,9 @@ async def login_success(event=None) -> None: CONF_QUEUE_DELAY: config_entry.options.get( CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY ), + CONF_PUBLIC_URL: config_entry.options.get( + CONF_PUBLIC_URL, DEFAULT_PUBLIC_URL + ), CONF_EXTENDED_ENTITY_DISCOVERY: config_entry.options.get( CONF_EXTENDED_ENTITY_DISCOVERY, DEFAULT_EXTENDED_ENTITY_DISCOVERY ), @@ -309,29 +319,32 @@ async def login_success(event=None) -> None: otp_secret=account.get(CONF_OTPSECRET, ""), oauth=account.get(CONF_OAUTH, {}), uuid=uuid, - oauth_login=bool( - account.get(CONF_OAUTH, {}).get("access_token") - or account.get(CONF_OAUTH_LOGIN) - ), + oauth_login=True, ), ) hass.data[DATA_ALEXAMEDIA]["accounts"][email]["login_obj"] = login + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["last_push_activity"] = 0 if not hass.data[DATA_ALEXAMEDIA]["accounts"][email]["second_account_index"]: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_alexa_media) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, complete_startup) hass.bus.async_listen("alexa_media_relogin_required", relogin) hass.bus.async_listen("alexa_media_relogin_success", login_success) - await login.login(cookies=await login.load_cookie()) - if await test_login_status(hass, config_entry, login): - await setup_alexa(hass, config_entry, login) - return True - return False + try: + await login.login(cookies=await login.load_cookie()) + if await test_login_status(hass, config_entry, login): + await setup_alexa(hass, config_entry, login) + return True + return False + except AlexapyConnectionError as err: + raise ConfigEntryNotReady(str(err) or "Connection Error during login") from err async def setup_alexa(hass, config_entry, login_obj: AlexaLogin): + # pylint: disable=too-many-statements,too-many-locals """Set up a alexa api based on host parameter.""" async def async_update_data() -> Optional[AlexaEntityData]: + # noqa pylint: disable=too-many-branches """Fetch data from API endpoint. This is the place to pre-process the data to lookup tables @@ -345,7 +358,7 @@ async def async_update_data() -> Optional[AlexaEntityData]: This will add new devices and services when discovered. By default this runs every SCAN_INTERVAL seconds unless another method calls it. if - websockets is connected, it will increase the delay 10-fold between updates. + push is connected, it will increase the delay 10-fold between updates. While throttled at MIN_TIME_BETWEEN_SCANS, care should be taken to reduce the number of runs to avoid flooding. Slow changing states should be checked here instead of in spawned components like @@ -397,10 +410,20 @@ async def async_update_data() -> Optional[AlexaEntityData]: if temp and temp.enabled: entities_to_monitor.add(temp.alexa_entity_id) + temp = sensor.get("Air_Quality") + if temp and temp.enabled: + entities_to_monitor.add(temp.alexa_entity_id) + for light in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"]["light"]: if light.enabled: entities_to_monitor.add(light.alexa_entity_id) + for binary_sensor in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][ + "binary_sensor" + ]: + if binary_sensor.enabled: + entities_to_monitor.add(binary_sensor.alexa_entity_id) + for guard in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][ "alarm_control_panel" ].values(): @@ -439,8 +462,8 @@ async def async_update_data() -> Optional[AlexaEntityData]: # First run is a special case. Get the state of all entities(including disabled) # This ensures all entities have state during startup without needing to request coordinator refresh - for typeOfEntity, entities in alexa_entities.items(): - if typeOfEntity == "guard" or extended_entity_discovery: + for type_of_entity, entities in alexa_entities.items(): + if type_of_entity == "guard" or extended_entity_discovery: for entity in entities: entities_to_monitor.add(entity.get("id")) entity_state = await get_entity_data( @@ -476,7 +499,7 @@ async def async_update_data() -> Optional[AlexaEntityData]: ) return except BaseException as err: - raise UpdateFailed(f"Error communicating with API: {err}") + raise UpdateFailed(f"Error communicating with API: {err}") from err new_alexa_clients = [] # list of newly discovered device names exclude_filter = [] @@ -524,13 +547,13 @@ async def async_update_data() -> Optional[AlexaEntityData]: _LOGGER.debug("Excluding %s for lacking capability", dev_name) continue - if "bluetoothStates" in bluetooth: + if bluetooth is not None and "bluetoothStates" in bluetooth: for b_state in bluetooth["bluetoothStates"]: if serial == b_state["deviceSerialNumber"]: device["bluetooth_state"] = b_state break - if "devicePreferences" in preferences: + if preferences is not None and "devicePreferences" in preferences: for dev in preferences["devicePreferences"]: if dev["deviceSerialNumber"] == serial: device["locale"] = dev["locale"] @@ -543,7 +566,7 @@ async def async_update_data() -> Optional[AlexaEntityData]: ) break - if "doNotDisturbDeviceStatusList" in dnd: + if dnd is not None and "doNotDisturbDeviceStatusList" in dnd: for dev in dnd["doNotDisturbDeviceStatusList"]: if dev["deviceSerialNumber"] == serial: device["dnd"] = dev["enabled"] @@ -616,11 +639,11 @@ async def async_update_data() -> Optional[AlexaEntityData]: hass.data[DATA_ALEXAMEDIA]["accounts"][email]["new_devices"] = False # prune stale devices - device_registry = await dr.async_get_registry(hass) + device_registry = dr.async_get(hass) for device_entry in dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ): - for (_, identifier) in device_entry.identifiers: + for _, identifier in device_entry.identifiers: if identifier in hass.data[DATA_ALEXAMEDIA]["accounts"][email][ "devices" ]["media_player"].keys() or identifier in map( @@ -646,6 +669,9 @@ async def async_update_data() -> Optional[AlexaEntityData]: "access_token": login_obj.access_token, "refresh_token": login_obj.refresh_token, "expires_in": login_obj.expires_in, + "mac_dms": login_obj.mac_dms, + "code_verifier": login_obj.code_verifier, + "authorization_code": login_obj.authorization_code, }, }, ) @@ -655,46 +681,53 @@ async def async_update_data() -> Optional[AlexaEntityData]: async def process_notifications(login_obj, raw_notifications=None): """Process raw notifications json.""" if not raw_notifications: + await asyncio.sleep(4) raw_notifications = await AlexaAPI.get_notifications(login_obj) - email: Text = login_obj.email + email: str = login_obj.email previous = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( "notifications", {} ) notifications = {"process_timestamp": dt.utcnow()} - for notification in raw_notifications: - n_dev_id = notification.get("deviceSerialNumber") - if n_dev_id is None: - # skip notifications untied to a device for now - # https://github.com/custom-components/alexa_media_player/issues/633#issuecomment-610705651 - continue - n_type = notification.get("type") - if n_type is None: - continue - if n_type == "MusicAlarm": - n_type = "Alarm" - n_id = notification["notificationIndex"] - if n_type == "Alarm": - n_date = notification.get("originalDate") - n_time = notification.get("originalTime") - notification["date_time"] = ( - f"{n_date} {n_time}" if n_date and n_time else None - ) - previous_alarm = previous.get(n_dev_id, {}).get("Alarm", {}).get(n_id) - if previous_alarm and alarm_just_dismissed( - notification, - previous_alarm.get("status"), - previous_alarm.get("version"), - ): - hass.bus.async_fire( - "alexa_media_alarm_dismissal_event", - event_data={"device": {"id": n_dev_id}, "event": notification}, + if raw_notifications is not None: + for notification in raw_notifications: + n_dev_id = notification.get("deviceSerialNumber") + if n_dev_id is None: + # skip notifications untied to a device for now + # https://github.com/custom-components/alexa_media_player/issues/633#issuecomment-610705651 + continue + n_type = notification.get("type") + if n_type is None: + continue + if n_type == "MusicAlarm": + n_type = "Alarm" + n_id = notification["notificationIndex"] + if n_type == "Alarm": + n_date = notification.get("originalDate") + n_time = notification.get("originalTime") + notification["date_time"] = ( + f"{n_date} {n_time}" if n_date and n_time else None ) + previous_alarm = ( + previous.get(n_dev_id, {}).get("Alarm", {}).get(n_id) + ) + if previous_alarm and alarm_just_dismissed( + notification, + previous_alarm.get("status"), + previous_alarm.get("version"), + ): + hass.bus.async_fire( + "alexa_media_alarm_dismissal_event", + event_data={ + "device": {"id": n_dev_id}, + "event": notification, + }, + ) - if n_dev_id not in notifications: - notifications[n_dev_id] = {} - if n_type not in notifications[n_dev_id]: - notifications[n_dev_id][n_type] = {} - notifications[n_dev_id][n_type][n_id] = notification + if n_dev_id not in notifications: + notifications[n_dev_id] = {} + if n_type not in notifications[n_dev_id]: + notifications[n_dev_id][n_type] = {} + notifications[n_dev_id][n_type][n_id] = notification hass.data[DATA_ALEXAMEDIA]["accounts"][email]["notifications"] = notifications _LOGGER.debug( "%s: Updated %s notifications for %s devices at %s", @@ -757,7 +790,7 @@ async def update_bluetooth_state(login_obj, device_serial): "media_player" ][device_serial] - if "bluetoothStates" in bluetooth: + if bluetooth is not None and "bluetoothStates" in bluetooth: for b_state in bluetooth["bluetoothStates"]: if device_serial == b_state["deviceSerialNumber"]: # _LOGGER.debug("%s: setting value for: %s to %s", @@ -780,7 +813,7 @@ async def update_dnd_state(login_obj) -> None: """Update the dnd state on ws dnd combo event.""" dnd = await AlexaAPI.get_dnd_state(login_obj) - if "doNotDisturbDeviceStatusList" in dnd: + if dnd is not None and "doNotDisturbDeviceStatusList" in dnd: async_dispatcher_send( hass, f"{DOMAIN}_{hide_email(email)}"[0:32], @@ -790,352 +823,384 @@ async def update_dnd_state(login_obj) -> None: _LOGGER.debug("%s: get_dnd_state failed: dnd:%s", hide_email(email), dnd) return - async def ws_connect() -> WebsocketEchoClient: - """Open WebSocket connection. + async def http2_connect() -> HTTP2EchoClient: + """Open HTTP2 Push connection. This will only attempt one login before failing. """ - websocket: Optional[WebsocketEchoClient] = None + http2: Optional[HTTP2EchoClient] = None + email = login_obj.email try: if login_obj.session.closed: _LOGGER.debug( - "%s: Websocket creation aborted. Session is closed.", + "%s: HTTP2 creation aborted. Session is closed.", hide_email(email), ) return - websocket = WebsocketEchoClient( + http2 = HTTP2EchoClient( login_obj, - ws_handler, - ws_open_handler, - ws_close_handler, - ws_error_handler, + msg_callback=http2_handler, + open_callback=http2_open_handler, + close_callback=http2_close_handler, + error_callback=http2_error_handler, + loop=hass.loop, ) - _LOGGER.debug("%s: Websocket created: %s", hide_email(email), websocket) - await websocket.async_run() + _LOGGER.debug("%s: HTTP2 created: %s", hide_email(email), http2) + await http2.async_run() + except AlexapyLoginError as exception_: + _LOGGER.debug( + "%s: Login Error detected from http2: %s", + hide_email(email), + exception_, + ) + hass.bus.async_fire( + "alexa_media_relogin_required", + event_data={"email": hide_email(email), "url": login_obj.url}, + ) + return except BaseException as exception_: # pylint: disable=broad-except _LOGGER.debug( - "%s: Websocket creation failed: %s", hide_email(email), exception_ + "%s: HTTP2 creation failed: %s", hide_email(email), exception_ ) return - return websocket + return http2 - async def ws_handler(message_obj): - """Handle websocket messages. + @callback + async def http2_handler(message_obj): + # pylint: disable=too-many-branches + """Handle http2 push messages. This allows push notifications from Alexa to update last_called and media state. """ - - command = ( - message_obj.json_payload["command"] - if isinstance(message_obj.json_payload, dict) - and "command" in message_obj.json_payload - else None - ) - json_payload = ( - message_obj.json_payload["payload"] - if isinstance(message_obj.json_payload, dict) - and "payload" in message_obj.json_payload - else None + updates = ( + message_obj.get("directive", {}) + .get("payload", {}) + .get("renderingUpdates", []) ) - existing_serials = _existing_serials(hass, login_obj) - seen_commands = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ - "websocket_commands" - ] - coord = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["coordinator"] - if command and json_payload: - - _LOGGER.debug( - "%s: Received websocket command: %s : %s", - hide_email(email), - command, - hide_serial(json_payload), + for item in updates: + resource = loads(item.get("resourceMetadata", "")) + command = ( + resource["command"] + if isinstance(resource, dict) and "command" in resource + else None ) - serial = None - command_time = time.time() - if command not in seen_commands: - _LOGGER.debug("Adding %s to seen_commands: %s", command, seen_commands) - seen_commands[command] = command_time - - if ( - "dopplerId" in json_payload - and "deviceSerialNumber" in json_payload["dopplerId"] - ): - serial = json_payload["dopplerId"]["deviceSerialNumber"] - elif ( - "key" in json_payload - and "entryId" in json_payload["key"] - and json_payload["key"]["entryId"].find("#") != -1 - ): - serial = (json_payload["key"]["entryId"]).split("#")[2] - json_payload["key"]["serialNumber"] = serial - else: + json_payload = ( + loads(resource["payload"]) + if isinstance(resource, dict) and "payload" in resource + else None + ) + existing_serials = _existing_serials(hass, login_obj) + seen_commands = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "http2_commands" + ] + coord = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["coordinator"] + if command and json_payload: + _LOGGER.debug( + "%s: Received http2push command: %s : %s", + hide_email(email), + command, + hide_serial(json_payload), + ) serial = None - if command == "PUSH_ACTIVITY": - # Last_Alexa Updated - last_called = { - "serialNumber": serial, - "timestamp": json_payload["timestamp"], - } - try: - await coord.async_request_refresh() - if serial and serial in existing_serials: - await update_last_called(login_obj, last_called) - async_dispatcher_send( - hass, - f"{DOMAIN}_{hide_email(email)}"[0:32], - {"push_activity": json_payload}, - ) - except (AlexapyConnectionError): - # Catch case where activities doesn't report valid json - pass - elif command in ( - "PUSH_AUDIO_PLAYER_STATE", - "PUSH_MEDIA_CHANGE", - "PUSH_MEDIA_PROGRESS_CHANGE", - ): - # Player update/ Push_media from tune_in - if serial and serial in existing_serials: - _LOGGER.debug( - "Updating media_player: %s", hide_serial(json_payload) - ) - async_dispatcher_send( - hass, - f"{DOMAIN}_{hide_email(email)}"[0:32], - {"player_state": json_payload}, - ) - elif command == "PUSH_VOLUME_CHANGE": - # Player volume update - if serial and serial in existing_serials: + command_time = time.time() + if command not in seen_commands: _LOGGER.debug( - "Updating media_player volume: %s", hide_serial(json_payload) + "Adding %s to seen_commands: %s", command, seen_commands ) - async_dispatcher_send( - hass, - f"{DOMAIN}_{hide_email(email)}"[0:32], - {"player_state": json_payload}, - ) - elif command in ( - "PUSH_DOPPLER_CONNECTION_CHANGE", - "PUSH_EQUALIZER_STATE_CHANGE", - ): - # Player availability update - if serial and serial in existing_serials: - _LOGGER.debug( - "Updating media_player availability %s", - hide_serial(json_payload), - ) - async_dispatcher_send( - hass, - f"{DOMAIN}_{hide_email(email)}"[0:32], - {"player_state": json_payload}, - ) - elif command == "PUSH_BLUETOOTH_STATE_CHANGE": - # Player bluetooth update - bt_event = json_payload["bluetoothEvent"] - bt_success = json_payload["bluetoothEventSuccess"] + seen_commands[command] = command_time + if ( - serial - and serial in existing_serials - and bt_success - and bt_event - and bt_event in ["DEVICE_CONNECTED", "DEVICE_DISCONNECTED"] + "dopplerId" in json_payload + and "deviceSerialNumber" in json_payload["dopplerId"] ): - _LOGGER.debug( - "Updating media_player bluetooth %s", hide_serial(json_payload) - ) - bluetooth_state = await update_bluetooth_state(login_obj, serial) - # _LOGGER.debug("bluetooth_state %s", - # hide_serial(bluetooth_state)) - if bluetooth_state: + serial = json_payload["dopplerId"]["deviceSerialNumber"] + elif ( + "key" in json_payload + and "entryId" in json_payload["key"] + and json_payload["key"]["entryId"].find("#") != -1 + ): + serial = (json_payload["key"]["entryId"]).split("#")[2] + json_payload["key"]["serialNumber"] = serial + else: + serial = None + if command == "PUSH_ACTIVITY": + # Last_Alexa Updated + last_called = { + "serialNumber": serial, + "timestamp": json_payload["timestamp"], + } + try: + await coord.async_request_refresh() + if serial and serial in existing_serials: + await update_last_called(login_obj, last_called) async_dispatcher_send( hass, f"{DOMAIN}_{hide_email(email)}"[0:32], - {"bluetooth_change": bluetooth_state}, + {"push_activity": json_payload}, ) - elif command == "PUSH_MEDIA_QUEUE_CHANGE": - # Player availability update - if serial and serial in existing_serials: - _LOGGER.debug( - "Updating media_player queue %s", hide_serial(json_payload) - ) - async_dispatcher_send( - hass, - f"{DOMAIN}_{hide_email(email)}"[0:32], - {"queue_state": json_payload}, - ) - elif command == "PUSH_NOTIFICATION_CHANGE": - # Player update - await process_notifications(login_obj) - if serial and serial in existing_serials: - _LOGGER.debug( - "Updating mediaplayer notifications: %s", - hide_serial(json_payload), - ) - async_dispatcher_send( - hass, - f"{DOMAIN}_{hide_email(email)}"[0:32], - {"notification_update": json_payload}, - ) - elif command in [ - "PUSH_DELETE_DOPPLER_ACTIVITIES", # delete Alexa history - "PUSH_LIST_CHANGE", # clear a shopping list https://github.com/custom-components/alexa_media_player/issues/1190 - "PUSH_LIST_ITEM_CHANGE", # update shopping list - "PUSH_CONTENT_FOCUS_CHANGE", # likely prime related refocus - "PUSH_DEVICE_SETUP_STATE_CHANGE", # likely device changes mid setup - ]: - pass - else: - _LOGGER.warning( - "Unhandled command: %s with data %s. Please report at %s", - command, - hide_serial(json_payload), - ISSUE_URL, - ) - if serial in existing_serials: - history = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ - "websocket_activity" - ]["serials"].get(serial) - if history is None or ( - history and command_time - history[len(history) - 1][1] > 2 + except AlexapyConnectionError: + # Catch case where activities doesn't report valid json + pass + elif command in ( + "PUSH_AUDIO_PLAYER_STATE", + "PUSH_MEDIA_CHANGE", + "PUSH_MEDIA_PROGRESS_CHANGE", + "NotifyMediaSessionsUpdated", + "NotifyNowPlayingUpdated", ): - history = [(command, command_time)] - else: - history.append([command, command_time]) - hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocket_activity"][ - "serials" - ][serial] = history - events = [] - for old_command, old_command_time in history: + # Player update/ Push_media from tune_in + if serial and serial in existing_serials: + _LOGGER.debug( + "Updating media_player: %s", hide_serial(json_payload) + ) + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"player_state": json_payload}, + ) + elif command == "PUSH_VOLUME_CHANGE": + # Player volume update + if serial and serial in existing_serials: + _LOGGER.debug( + "Updating media_player volume: %s", + hide_serial(json_payload), + ) + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"player_state": json_payload}, + ) + elif command in ( + "PUSH_DOPPLER_CONNECTION_CHANGE", + "PUSH_EQUALIZER_STATE_CHANGE", + ): + # Player availability update + if serial and serial in existing_serials: + _LOGGER.debug( + "Updating media_player availability %s", + hide_serial(json_payload), + ) + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"player_state": json_payload}, + ) + elif command == "PUSH_BLUETOOTH_STATE_CHANGE": + # Player bluetooth update + bt_event = json_payload["bluetoothEvent"] + bt_success = json_payload["bluetoothEventSuccess"] if ( - old_command - in {"PUSH_VOLUME_CHANGE", "PUSH_EQUALIZER_STATE_CHANGE"} - and command_time - old_command_time < 0.25 + serial + and serial in existing_serials + and bt_success + and bt_event + and bt_event in ["DEVICE_CONNECTED", "DEVICE_DISCONNECTED"] ): - events.append( - (old_command, round(command_time - old_command_time, 2)) + _LOGGER.debug( + "Updating media_player bluetooth %s", + hide_serial(json_payload), ) - elif old_command in {"PUSH_AUDIO_PLAYER_STATE"}: - # There is a potential false positive generated during this event - events = [] - if len(events) >= 4: - _LOGGER.debug( - "%s: Detected potential DND websocket change with %s events %s", - hide_serial(serial), - len(events), - events, + bluetooth_state = await update_bluetooth_state( + login_obj, serial + ) + # _LOGGER.debug("bluetooth_state %s", + # hide_serial(bluetooth_state)) + if bluetooth_state: + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"bluetooth_change": bluetooth_state}, + ) + elif command == "PUSH_MEDIA_QUEUE_CHANGE": + # Player availability update + if serial and serial in existing_serials: + _LOGGER.debug( + "Updating media_player queue %s", hide_serial(json_payload) + ) + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"queue_state": json_payload}, + ) + elif command == "PUSH_NOTIFICATION_CHANGE": + # Player update + await process_notifications(login_obj) + if serial and serial in existing_serials: + _LOGGER.debug( + "Updating mediaplayer notifications: %s", + hide_serial(json_payload), + ) + async_dispatcher_send( + hass, + f"{DOMAIN}_{hide_email(email)}"[0:32], + {"notification_update": json_payload}, + ) + elif command in [ + "PUSH_DELETE_DOPPLER_ACTIVITIES", # delete Alexa history + "PUSH_LIST_CHANGE", # clear a shopping list https://github.com/custom-components/alexa_media_player/issues/1190 + "PUSH_LIST_ITEM_CHANGE", # update shopping list + "PUSH_CONTENT_FOCUS_CHANGE", # likely prime related refocus + "PUSH_DEVICE_SETUP_STATE_CHANGE", # likely device changes mid setup + "PUSH_MEDIA_PREFERENCE_CHANGE", # disliking or liking songs, https://github.com/custom-components/alexa_media_player/issues/1599 + ]: + pass + else: + _LOGGER.warning( + "Unhandled command: %s with data %s. Please report at %s", + command, + hide_serial(json_payload), + ISSUE_URL, ) - await update_dnd_state(login_obj) - if ( - serial - and serial not in existing_serials - and serial - not in ( - hass.data[DATA_ALEXAMEDIA]["accounts"][email]["excluded"].keys() - ) - ): - _LOGGER.debug("Discovered new media_player %s", serial) - (hass.data[DATA_ALEXAMEDIA]["accounts"][email]["new_devices"]) = True - coordinator = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( - "coordinator" - ) - if coordinator: - await coordinator.async_request_refresh() + if serial in existing_serials: + history = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "http2_activity" + ]["serials"].get(serial) + if history is None or ( + history and command_time - history[len(history) - 1][1] > 2 + ): + history = [(command, command_time)] + else: + history.append([command, command_time]) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2_activity"][ + "serials" + ][serial] = history + events = [] + for old_command, old_command_time in history: + if ( + old_command + in {"PUSH_VOLUME_CHANGE", "PUSH_EQUALIZER_STATE_CHANGE"} + and command_time - old_command_time < 0.25 + ): + events.append( + (old_command, round(command_time - old_command_time, 2)) + ) + elif old_command in {"PUSH_AUDIO_PLAYER_STATE"}: + # There is a potential false positive generated during this event + events = [] + if len(events) >= 4: + _LOGGER.debug( + "%s: Detected potential DND http2push change with %s events %s", + hide_serial(serial), + len(events), + events, + ) + await update_dnd_state(login_obj) + if ( + serial + and serial not in existing_serials + and serial + not in ( + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["excluded"].keys() + ) + ): + _LOGGER.debug("Discovered new media_player %s", serial) + ( + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["new_devices"] + ) = True + coordinator = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get( + "coordinator" + ) + if coordinator: + await coordinator.async_request_refresh() - async def ws_open_handler(): - """Handle websocket open.""" + @callback + async def http2_open_handler(): + """Handle http2 open.""" - email: Text = login_obj.email - _LOGGER.debug("%s: Websocket successfully connected", hide_email(email)) + email: str = login_obj.email + _LOGGER.debug("%s: HTTP2push successfully connected", hide_email(email)) hass.data[DATA_ALEXAMEDIA]["accounts"][email][ - "websocketerror" + "http2error" ] = 0 # set errors to 0 - hass.data[DATA_ALEXAMEDIA]["accounts"][email][ - "websocket_lastattempt" - ] = time.time() + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2_lastattempt"] = time.time() - async def ws_close_handler(): - """Handle websocket close. + @callback + async def http2_close_handler(): + """Handle http2 close. This should attempt to reconnect up to 5 times """ - - email: Text = login_obj.email + email: str = login_obj.email + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2"] = None if login_obj.close_requested: _LOGGER.debug( - "%s: Close requested; will not reconnect websocket", hide_email(email) + "%s: Close requested; will not reconnect http2", hide_email(email) ) return if not login_obj.status.get("login_successful"): _LOGGER.debug( - "%s: Login error; will not reconnect websocket", hide_email(email) + "%s: Login error; will not reconnect http2", hide_email(email) ) return - errors: int = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocketerror"] - delay: int = 5 * 2 ** errors + errors: int = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2error"] + delay: int = 5 * 2**errors last_attempt = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ - "websocket_lastattempt" + "http2_lastattempt" ] now = time.time() if (now - last_attempt) < delay: return - websocket_enabled: bool = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ - "websocket" - ] - while errors < 5 and not (websocket_enabled): + http2_enabled: bool = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2"] + while errors < 5 and not (http2_enabled): _LOGGER.debug( - "%s: Websocket closed; reconnect #%i in %is", + "%s: HTTP2 push closed; reconnect #%i in %is", hide_email(email), errors, delay, ) hass.data[DATA_ALEXAMEDIA]["accounts"][email][ - "websocket_lastattempt" + "http2_lastattempt" ] = time.time() - websocket_enabled = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ - "websocket" - ] = await ws_connect() - errors = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocketerror"] = ( - hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocketerror"] + 1 + http2_enabled = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "http2" + ] = await http2_connect() + errors = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2error"] = ( + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2error"] + 1 ) - delay = 5 * 2 ** errors - errors = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocketerror"] + delay = 5 * 2**errors + errors = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2error"] await asyncio.sleep(delay) - if not websocket_enabled: + if not http2_enabled: _LOGGER.debug( - "%s: Websocket closed; retries exceeded; polling", hide_email(email) + "%s: HTTP2Push connection closed; retries exceeded; polling", + hide_email(email), ) coordinator = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get("coordinator") if coordinator: coordinator.update_interval = timedelta( - seconds=scan_interval * 10 if websocket_enabled else scan_interval + seconds=scan_interval * 10 if http2_enabled else scan_interval ) await coordinator.async_request_refresh() - async def ws_error_handler(message): - """Handle websocket error. + @callback + async def http2_error_handler(message): + """Handle http2push error. This currently logs the error. In the future, this should invalidate - the websocket and determine if a reconnect should be done. By - specification, websockets will issue a close after every error. + the http2push and determine if a reconnect should be done. """ - email: Text = login_obj.email - errors = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocketerror"] + email: str = login_obj.email + errors = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2error"] _LOGGER.debug( - "%s: Received websocket error #%i %s: type %s", + "%s: Received http2push error #%i %s: type %s", hide_email(email), errors, message, type(message), ) - hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocket"] = None + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2"] = None if not login_obj.close_requested and ( - login_obj.session.closed or message == "" + login_obj.session.closed or isinstance(message, AlexapyLoginError) ): - hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocketerror"] = 5 - _LOGGER.debug("%s: Immediate abort on EoFstream", hide_email(email)) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2error"] = 5 + _LOGGER.debug("%s: Login error detected.", hide_email(email)) + hass.bus.async_fire( + "alexa_media_relogin_required", + event_data={"email": hide_email(email), "url": login_obj.url}, + ) return - hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocketerror"] = errors + 1 + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2error"] = errors + 1 _LOGGER.debug("Setting up Alexa devices for %s", hide_email(login_obj.email)) config = config_entry.data @@ -1148,9 +1213,9 @@ async def ws_error_handler(message): else config.get(CONF_SCAN_INTERVAL) ) hass.data[DATA_ALEXAMEDIA]["accounts"][email]["login_obj"] = login_obj - websocket_enabled = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ - "websocket" - ] = await ws_connect() + http2_enabled = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ + "http2" + ] = await http2_connect() coordinator = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get("coordinator") if coordinator is None: _LOGGER.debug("%s: Creating coordinator", hide_email(email)) @@ -1164,13 +1229,13 @@ async def ws_error_handler(message): update_method=async_update_data, # Polling interval. Will only be polled if there are subscribers. update_interval=timedelta( - seconds=scan_interval * 10 if websocket_enabled else scan_interval + seconds=scan_interval * 10 if http2_enabled else scan_interval ), ) else: _LOGGER.debug("%s: Reusing coordinator", hide_email(email)) coordinator.update_interval = timedelta( - seconds=scan_interval * 10 if websocket_enabled else scan_interval + seconds=scan_interval * 10 if http2_enabled else scan_interval ) # Fetch initial data so we have data when entities subscribe _LOGGER.debug("%s: Refreshing coordinator", hide_email(email)) @@ -1236,7 +1301,7 @@ async def async_unload_entry(hass, entry) -> bool: return True -async def close_connections(hass, email: Text) -> None: +async def close_connections(hass, email: str) -> None: """Clear open aiohttp connections for email.""" if ( email not in hass.data[DATA_ALEXAMEDIA]["accounts"] @@ -1284,7 +1349,7 @@ async def test_login_status(hass, config_entry, login) -> bool: account = config_entry.data _LOGGER.debug("Logging in: %s %s", obfuscate(account), in_progess_instances(hass)) _LOGGER.debug("Login stats: %s", login.stats) - message: Text = f"Reauthenticate {login.email} on the [Integrations](/config/integrations) page. " + message: str = f"Reauthenticate {login.email} on the [Integrations](/config/integrations) page. " if login.stats.get("login_timestamp") != datetime(1, 1, 1): elaspsed_time: str = str(datetime.now() - login.stats.get("login_timestamp")) api_calls: int = login.stats.get("api_calls") @@ -1325,7 +1390,6 @@ async def test_login_status(hass, config_entry, login) -> bool: CONF_SCAN_INTERVAL: account[CONF_SCAN_INTERVAL].total_seconds() if isinstance(account[CONF_SCAN_INTERVAL], timedelta) else account[CONF_SCAN_INTERVAL], - CONF_COOKIES_TXT: account.get(CONF_COOKIES_TXT, ""), CONF_OTPSECRET: account.get(CONF_OTPSECRET, ""), }, ) diff --git a/custom_components/alexa_media/alarm_control_panel.py b/custom_components/alexa_media/alarm_control_panel.py index 4ffe93004..c5ae861d6 100644 --- a/custom_components/alexa_media/alarm_control_panel.py +++ b/custom_components/alexa_media/alarm_control_panel.py @@ -8,7 +8,7 @@ """ from asyncio import sleep import logging -from typing import Dict, List, Optional, Text # noqa pylint: disable=unused-import +from typing import List, Optional from alexapy import hide_email, hide_serial from homeassistant.const import ( @@ -50,7 +50,13 @@ async def async_setup_platform( ) -> bool: """Set up the Alexa alarm control panel platform.""" devices = [] # type: List[AlexaAlarmControlPanel] - account = config[CONF_EMAIL] if config else discovery_info["config"][CONF_EMAIL] + account = None + if config: + account = config.get(CONF_EMAIL) + if account is None and discovery_info: + account = discovery_info.get("config", {}).get(CONF_EMAIL) + if account is None: + raise ConfigEntryNotReady include_filter = config.get(CONF_INCLUDE_DEVICES, []) exclude_filter = config.get(CONF_EXCLUDE_DEVICES, []) account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] @@ -132,7 +138,6 @@ class AlexaAlarmControlPanel(AlarmControlPanel, AlexaMedia, CoordinatorEntity): """Implementation of Alexa Media Player alarm control panel.""" def __init__(self, login, coordinator, guard_entity, media_players=None) -> None: - # pylint: disable=unexpected-keyword-arg """Initialize the Alexa device.""" AlexaMedia.__init__(self, None, login) CoordinatorEntity.__init__(self, coordinator) @@ -144,7 +149,7 @@ def __init__(self, login, coordinator, guard_entity, media_players=None) -> None self._guard_entity_id = guard_entity["id"] self._friendly_name = "Alexa Guard " + self._appliance_id[-5:] self._media_players = {} or media_players - self._attrs: Dict[Text, Text] = {} + self._attrs: dict[str, str] = {} _LOGGER.debug( "%s: Guard Discovered %s: %s %s", self.account, @@ -154,8 +159,9 @@ def __init__(self, login, coordinator, guard_entity, media_players=None) -> None ) @_catch_login_errors - async def _async_alarm_set(self, command: Text = "", code=None) -> None: - # pylint: disable=unexpected-keyword-arg + async def _async_alarm_set( + self, command: str = "", code=None # pylint: disable=unused-argument + ) -> None: """Send command.""" try: if not self.enabled: @@ -187,14 +193,16 @@ async def _async_alarm_set(self, command: Text = "", code=None) -> None: ) await self.coordinator.async_request_refresh() - async def async_alarm_disarm(self, code=None) -> None: - # pylint: disable=unexpected-keyword-arg + async def async_alarm_disarm( + self, code=None # pylint:disable=unused-argument + ) -> None: """Send disarm command.""" await self._async_alarm_set(STATE_ALARM_DISARMED) - async def async_alarm_arm_away(self, code=None) -> None: + async def async_alarm_arm_away( + self, code=None # pylint:disable=unused-argument + ) -> None: """Send arm away command.""" - # pylint: disable=unexpected-keyword-arg await self._async_alarm_set(STATE_ALARM_ARMED_AWAY) @property @@ -215,14 +223,14 @@ def state(self): ) if _state == "ARMED_AWAY": return STATE_ALARM_ARMED_AWAY - elif _state == "ARMED_STAY": - return STATE_ALARM_DISARMED - else: + if _state == "ARMED_STAY": return STATE_ALARM_DISARMED + return STATE_ALARM_DISARMED @property def supported_features(self) -> int: """Return the list of supported features.""" + # pylint: disable=import-outside-toplevel try: from homeassistant.components.alarm_control_panel import ( SUPPORT_ALARM_ARM_AWAY, @@ -233,12 +241,18 @@ def supported_features(self) -> int: @property def assumed_state(self) -> bool: + """Return assumed state. + + Returns + bool: Whether the state is assumed + + """ last_refresh_success = ( self.coordinator.data and self._guard_entity_id in self.coordinator.data ) return not last_refresh_success @property - def device_state_attributes(self): + def extra_state_attributes(self): """Return the state attributes.""" return self._attrs diff --git a/custom_components/alexa_media/alexa_entity.py b/custom_components/alexa_media/alexa_entity.py index a028902c5..00fc70ab0 100644 --- a/custom_components/alexa_media/alexa_entity.py +++ b/custom_components/alexa_media/alexa_entity.py @@ -10,7 +10,7 @@ import json import logging import re -from typing import Any, Dict, List, Optional, Text, Tuple, TypedDict, Union +from typing import Any, Optional, TypedDict, Union from alexapy import AlexaAPI, AlexaLogin, hide_serial from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -19,14 +19,14 @@ def has_capability( - appliance: Dict[Text, Any], interface_name: Text, property_name: Text + appliance: dict[str, Any], interface_name: str, property_name: str ) -> bool: """Determine if an appliance from the Alexa network details offers a particular interface with enough support that is worth adding to Home Assistant. Args: - appliance(Dict[Text, Any]): An appliance from a call to AlexaAPI.get_network_details - interface_name(Text): One of the interfaces documented by the Alexa Smart Home Skills API - property_name(Text): The property that matches the interface name. + appliance(dict[str, Any]): An appliance from a call to AlexaAPI.get_network_details + interface_name(str): One of the interfaces documented by the Alexa Smart Home Skills API + property_name(str): The property that matches the interface name. """ for cap in appliance["capabilities"]: @@ -42,7 +42,7 @@ def has_capability( return False -def is_hue_v1(appliance: Dict[Text, Any]) -> bool: +def is_hue_v1(appliance: dict[str, Any]) -> bool: """Determine if an appliance is managed via the Philips Hue v1 Hub. This check catches old Philips Hue bulbs and hubs, but critically, it also catches things pretending to be older @@ -51,7 +51,12 @@ def is_hue_v1(appliance: Dict[Text, Any]) -> bool: return appliance.get("manufacturerName") == "Royal Philips Electronics" -def is_local(appliance: Dict[Text, Any]) -> bool: +def is_skill(appliance: dict[str, Any]) -> bool: + namespace = appliance.get("driverIdentity", {}).get("namespace", "") + return namespace and namespace == "SKILL" + + +def is_local(appliance: dict[str, Any]) -> bool: """Test whether locally connected. This is mainly present to prevent loops with the official Alexa integration. @@ -66,8 +71,12 @@ def is_local(appliance: Dict[Text, Any]) -> bool: # This catches the Echo/AVS devices. connectedVia isn't reliable in this case. # Only the first appears to get that set. if "ALEXA_VOICE_ENABLED" in appliance.get("applianceTypes", []): - namespace = appliance.get("driverIdentity", {}).get("namespace", "") - return namespace and namespace != "SKILL" + return not is_skill(appliance) + + # Ledvance bulbs connected via bluetooth are hard to detect as locally connected + # There is probably a better way, but this works for now. + if appliance.get("manufacturerName") == "Ledvance": + return not is_skill(appliance) # Zigbee devices are guaranteed to be local and have a particular pattern of id zigbee_pattern = re.compile( @@ -76,30 +85,50 @@ def is_local(appliance: Dict[Text, Any]) -> bool: return zigbee_pattern.fullmatch(appliance.get("applianceId", "")) is not None -def is_alexa_guard(appliance: Dict[Text, Any]) -> bool: +def is_alexa_guard(appliance: dict[str, Any]) -> bool: """Is the given appliance the guard alarm system of an echo.""" return appliance["modelName"] == "REDROCK_GUARD_PANEL" and has_capability( appliance, "Alexa.SecurityPanelController", "armState" ) -def is_temperature_sensor(appliance: Dict[Text, Any]) -> bool: +def is_temperature_sensor(appliance: dict[str, Any]) -> bool: """Is the given appliance the temperature sensor of an Echo.""" return is_local(appliance) and has_capability( appliance, "Alexa.TemperatureSensor", "temperature" ) -def is_light(appliance: Dict[Text, Any]) -> bool: +# Checks if air quality sensor +def is_air_quality_sensor(appliance: dict[str, Any]) -> bool: + """Is the given appliance the Air Quality Sensor.""" + return ( + appliance["friendlyDescription"] == "Amazon Indoor Air Quality Monitor" + and "AIR_QUALITY_MONITOR" in appliance.get("applianceTypes", []) + and has_capability(appliance, "Alexa.TemperatureSensor", "temperature") + and has_capability(appliance, "Alexa.RangeController", "rangeValue") + ) + + +def is_light(appliance: dict[str, Any]) -> bool: """Is the given appliance a light controlled locally by an Echo.""" return ( is_local(appliance) - and "LIGHT" in appliance["applianceTypes"] + and "LIGHT" in appliance.get("applianceTypes", []) and has_capability(appliance, "Alexa.PowerController", "powerState") ) -def get_friendliest_name(appliance: Dict[Text, Any]) -> Text: +def is_contact_sensor(appliance: dict[str, Any]) -> bool: + """Is the given appliance a contact sensor controlled locally by an Echo.""" + return ( + is_local(appliance) + and "CONTACT_SENSOR" in appliance.get("applianceTypes", []) + and has_capability(appliance, "Alexa.ContactSensor", "detectionState") + ) + + +def get_friendliest_name(appliance: dict[str, Any]) -> str: """Find the best friendly name. Alexa seems to store manual renames in aliases. Prefer that one.""" aliases = appliance.get("aliases", []) for alias in aliases: @@ -109,7 +138,7 @@ def get_friendliest_name(appliance: Dict[Text, Any]) -> Text: return appliance["friendlyName"] -def get_device_serial(appliance: Dict[Text, Any]) -> Optional[Text]: +def get_device_serial(appliance: dict[str, Any]) -> Optional[str]: """Find the device serial id if it is present.""" alexa_device_id_list = appliance.get("alexaDeviceIdentifierList", []) for alexa_device_id in alexa_device_id_list: @@ -121,9 +150,9 @@ def get_device_serial(appliance: Dict[Text, Any]) -> Optional[Text]: class AlexaEntity(TypedDict): """Class for Alexaentity.""" - id: Text - appliance_id: Text - name: Text + id: str + appliance_id: str + name: str is_hue_v1: bool @@ -138,23 +167,41 @@ class AlexaLightEntity(AlexaEntity): class AlexaTemperatureEntity(AlexaEntity): """Class for AlexaTemperatureEntity.""" - device_serial: Text + device_serial: str + + +class AlexaAirQualityEntity(AlexaEntity): + """Class for AlexaAirQualityEntity.""" + + device_serial: str + + +class AlexaBinaryEntity(AlexaEntity): + """Class for AlexaBinaryEntity.""" + + battery_level: bool class AlexaEntities(TypedDict): """Class for holding entities.""" - light: List[AlexaLightEntity] - guard: List[AlexaEntity] - temperature: List[AlexaTemperatureEntity] + light: list[AlexaLightEntity] + guard: list[AlexaEntity] + temperature: list[AlexaTemperatureEntity] + air_quality: list[AlexaAirQualityEntity] + binary_sensor: list[AlexaBinaryEntity] -def parse_alexa_entities(network_details: Optional[Dict[Text, Any]]) -> AlexaEntities: +def parse_alexa_entities(network_details: Optional[dict[str, Any]]) -> AlexaEntities: + # pylint: disable=too-many-locals """Turn the network details into a list of useful entities with the important details extracted.""" lights = [] guards = [] temperature_sensors = [] + air_quality_sensors = [] + contact_sensors = [] location_details = network_details["locationDetails"]["locationDetails"] + # pylint: disable=too-many-nested-blocks for location in location_details.values(): amazon_bridge_details = location["amazonBridgeDetails"]["amazonBridgeDetails"] for bridge in amazon_bridge_details.values(): @@ -174,6 +221,44 @@ def parse_alexa_entities(network_details: Optional[Dict[Text, Any]]) -> AlexaEnt serial if serial else appliance["entityId"] ) temperature_sensors.append(processed_appliance) + # Code for Amazon Smart Air Quality Monitor + elif is_air_quality_sensor(appliance): + serial = get_device_serial(appliance) + processed_appliance["device_serial"] = ( + serial if serial else appliance["entityId"] + ) + # create array of air quality sensors. We must store the instance id against + # the assetId so we know which sensors are which. + sensors = [] + if ( + appliance["friendlyDescription"] + == "Amazon Indoor Air Quality Monitor" + ): + for cap in appliance["capabilities"]: + instance = cap.get("instance") + if instance: + friendlyName = cap["resources"].get("friendlyNames") + for entry in friendlyName: + assetId = entry["value"].get("assetId") + if assetId and assetId.startswith( + "Alexa.AirQuality" + ): + unit = cap["configuration"]["unitOfMeasure"] + sensor = { + "sensorType": assetId, + "instance": instance, + "unit": unit, + } + sensors.append(sensor) + _LOGGER.debug( + "AIAQM sensor detected %s", sensor + ) + processed_appliance["sensors"] = sensors + + # Add as both temperature and air quality sensor + temperature_sensors.append(processed_appliance) + air_quality_sensors.append(processed_appliance) + elif is_light(appliance): processed_appliance["brightness"] = has_capability( appliance, "Alexa.BrightnessController", "brightness" @@ -187,23 +272,36 @@ def parse_alexa_entities(network_details: Optional[Dict[Text, Any]]) -> AlexaEnt "colorTemperatureInKelvin", ) lights.append(processed_appliance) + elif is_contact_sensor(appliance): + processed_appliance["battery_level"] = has_capability( + appliance, "Alexa.BatteryLevelSensor", "batteryLevel" + ) + contact_sensors.append(processed_appliance) + else: + _LOGGER.debug("Found unsupported device %s", appliance) - return {"light": lights, "guard": guards, "temperature": temperature_sensors} + return { + "light": lights, + "guard": guards, + "temperature": temperature_sensors, + "air_quality": air_quality_sensors, + "binary_sensor": contact_sensors, + } class AlexaCapabilityState(TypedDict): """Class for AlexaCapabilityState.""" - name: Text - namespace: Text - value: Union[int, Text, TypedDict] + name: str + namespace: str + value: Union[int, str, TypedDict] -AlexaEntityData = Dict[Text, List[AlexaCapabilityState]] +AlexaEntityData = dict[str, list[AlexaCapabilityState]] async def get_entity_data( - login_obj: AlexaLogin, entity_ids: List[Text] + login_obj: AlexaLogin, entity_ids: list[str] ) -> AlexaEntityData: """Get and process the entity data into a more usable format.""" @@ -223,8 +321,8 @@ async def get_entity_data( def parse_temperature_from_coordinator( - coordinator: DataUpdateCoordinator, entity_id: Text -) -> Optional[Text]: + coordinator: DataUpdateCoordinator, entity_id: str +) -> Optional[str]: """Get the temperature of an entity from the coordinator data.""" value = parse_value_from_coordinator( coordinator, entity_id, "Alexa.TemperatureSensor", "temperature" @@ -232,8 +330,22 @@ def parse_temperature_from_coordinator( return value.get("value") if value and "value" in value else None +def parse_air_quality_from_coordinator( + coordinator: DataUpdateCoordinator, entity_id: str, instance_id: str +) -> Optional[str]: + """Get the air quality of an entity from the coordinator data.""" + value = parse_value_from_coordinator( + coordinator, + entity_id, + "Alexa.RangeController", + "rangeValue", + instance=instance_id, + ) + return value + + def parse_brightness_from_coordinator( - coordinator: DataUpdateCoordinator, entity_id: Text, since: datetime + coordinator: DataUpdateCoordinator, entity_id: str, since: datetime ) -> Optional[int]: """Get the brightness in the range 0-100.""" return parse_value_from_coordinator( @@ -242,9 +354,9 @@ def parse_brightness_from_coordinator( def parse_color_temp_from_coordinator( - coordinator: DataUpdateCoordinator, entity_id: Text, since: datetime + coordinator: DataUpdateCoordinator, entity_id: str, since: datetime ) -> Optional[int]: - """Get the color temperature in kelvin""" + """Get the color temperature in kelvin.""" return parse_value_from_coordinator( coordinator, entity_id, @@ -255,9 +367,9 @@ def parse_color_temp_from_coordinator( def parse_color_from_coordinator( - coordinator: DataUpdateCoordinator, entity_id: Text, since: datetime -) -> Optional[Tuple[float, float, float]]: - """Get the color as a tuple of (hue, saturation, brightness)""" + coordinator: DataUpdateCoordinator, entity_id: str, since: datetime +) -> Optional[tuple[float, float, float]]: + """Get the color as a tuple of (hue, saturation, brightness).""" value = parse_value_from_coordinator( coordinator, entity_id, "Alexa.ColorController", "color", since ) @@ -269,8 +381,8 @@ def parse_color_from_coordinator( def parse_power_from_coordinator( - coordinator: DataUpdateCoordinator, entity_id: Text, since: datetime -) -> Optional[Text]: + coordinator: DataUpdateCoordinator, entity_id: str, since: datetime +) -> Optional[str]: """Get the power state of the entity.""" return parse_value_from_coordinator( coordinator, entity_id, "Alexa.PowerController", "powerState", since @@ -278,20 +390,30 @@ def parse_power_from_coordinator( def parse_guard_state_from_coordinator( - coordinator: DataUpdateCoordinator, entity_id: Text -) -> Optional[Text]: + coordinator: DataUpdateCoordinator, entity_id: str +) -> Optional[str]: """Get the guard state from the coordinator data.""" return parse_value_from_coordinator( coordinator, entity_id, "Alexa.SecurityPanelController", "armState" ) +def parse_detection_state_from_coordinator( + coordinator: DataUpdateCoordinator, entity_id: str +) -> Optional[bool]: + """Get the detection state from the coordinator data.""" + return parse_value_from_coordinator( + coordinator, entity_id, "Alexa.ContactSensor", "detectionState" + ) + + def parse_value_from_coordinator( coordinator: DataUpdateCoordinator, - entity_id: Text, - namespace: Text, - name: Text, + entity_id: str, + namespace: str, + name: str, since: Optional[datetime] = None, + instance: str = None, ) -> Any: """Parse out values from coordinator for Alexa Entities.""" if coordinator.data and entity_id in coordinator.data: @@ -299,22 +421,22 @@ def parse_value_from_coordinator( if ( cap_state.get("namespace") == namespace and cap_state.get("name") == name + and (cap_state.get("instance") == instance or instance is None) ): if is_cap_state_still_acceptable(cap_state, since): return cap_state.get("value") - else: - _LOGGER.debug( - "Coordinator data for %s is too old to be returned.", - hide_serial(entity_id), - ) - return None + _LOGGER.debug( + "Coordinator data for %s is too old to be returned.", + hide_serial(entity_id), + ) + return None else: _LOGGER.debug("Coordinator has no data for %s", hide_serial(entity_id)) return None def is_cap_state_still_acceptable( - cap_state: Dict[Text, Any], since: Optional[datetime] + cap_state: dict[str, Any], since: Optional[datetime] ) -> bool: """Determine if a particular capability state is still usable given its age.""" if since is not None: diff --git a/custom_components/alexa_media/alexa_media.py b/custom_components/alexa_media/alexa_media.py index 16b9c6256..7c96ac96f 100644 --- a/custom_components/alexa_media/alexa_media.py +++ b/custom_components/alexa_media/alexa_media.py @@ -8,7 +8,6 @@ """ import logging -from typing import Dict, Text # noqa pylint: disable=unused-import from alexapy import AlexaAPI, hide_email @@ -21,7 +20,6 @@ class AlexaMedia: """Implementation of Alexa Media Base object.""" def __init__(self, device, login) -> None: - # pylint: disable=unexpected-keyword-arg """Initialize the Alexa device.""" # Class info diff --git a/custom_components/alexa_media/binary_sensor.py b/custom_components/alexa_media/binary_sensor.py new file mode 100644 index 000000000..73296b7f7 --- /dev/null +++ b/custom_components/alexa_media/binary_sensor.py @@ -0,0 +1,128 @@ +""" +Alexa Devices Sensors. + +SPDX-License-Identifier: Apache-2.0 + +For more details about this platform, please refer to the documentation at +https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639 +""" + +import logging + +from alexapy import hide_serial +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import ( + CONF_EMAIL, + CONF_EXCLUDE_DEVICES, + CONF_INCLUDE_DEVICES, + DATA_ALEXAMEDIA, + hide_email, +) +from .alexa_entity import parse_detection_state_from_coordinator +from .const import CONF_EXTENDED_ENTITY_DISCOVERY +from .helpers import add_devices + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Set up the Alexa sensor platform.""" + devices: list[BinarySensorEntity] = [] + account = None + if config: + account = config.get(CONF_EMAIL) + if account is None and discovery_info: + account = discovery_info.get("config", {}).get(CONF_EMAIL) + if account is None: + raise ConfigEntryNotReady + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] + include_filter = config.get(CONF_INCLUDE_DEVICES, []) + exclude_filter = config.get(CONF_EXCLUDE_DEVICES, []) + coordinator = account_dict["coordinator"] + binary_entities = account_dict.get("devices", {}).get("binary_sensor", []) + if binary_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY): + for binary_entity in binary_entities: + _LOGGER.debug( + "Creating entity %s for a binary_sensor with name %s", + hide_serial(binary_entity["id"]), + binary_entity["name"], + ) + contact_sensor = AlexaContact(coordinator, binary_entity) + account_dict["entities"]["binary_sensor"].append(contact_sensor) + devices.append(contact_sensor) + + return await add_devices( + hide_email(account), + devices, + add_devices_callback, + include_filter, + exclude_filter, + ) + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the Alexa sensor platform by config_entry.""" + return await async_setup_platform( + hass, config_entry.data, async_add_devices, discovery_info=None + ) + + +async def async_unload_entry(hass, entry) -> bool: + """Unload a config entry.""" + account = entry.data[CONF_EMAIL] + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] + _LOGGER.debug("Attempting to unload binary sensors") + for binary_sensor in account_dict["entities"]["binary_sensor"]: + await binary_sensor.async_remove() + return True + + +class AlexaContact(CoordinatorEntity, BinarySensorEntity): + """A contact sensor controlled by an Echo.""" + + _attr_device_class = BinarySensorDeviceClass.DOOR + + def __init__(self, coordinator: CoordinatorEntity, details: dict): + """Initialize alexa contact sensor. + + Args + coordinator (CoordinatorEntity): Coordinator + details (dict): Details dictionary + + """ + super().__init__(coordinator) + self.alexa_entity_id = details["id"] + self._name = details["name"] + + @property + def name(self): + """Return name.""" + return self._name + + @property + def unique_id(self): + """Return unique id.""" + return self.alexa_entity_id + + @property + def is_on(self): + """Return whether on.""" + detection = parse_detection_state_from_coordinator( + self.coordinator, self.alexa_entity_id + ) + + return detection == "DETECTED" if detection is not None else None + + @property + def assumed_state(self) -> bool: + """Return assumed state.""" + last_refresh_success = ( + self.coordinator.data and self.alexa_entity_id in self.coordinator.data + ) + return not last_refresh_success diff --git a/custom_components/alexa_media/config_flow.py b/custom_components/alexa_media/config_flow.py index e70baae34..37f7b3ac6 100644 --- a/custom_components/alexa_media/config_flow.py +++ b/custom_components/alexa_media/config_flow.py @@ -12,10 +12,9 @@ from datetime import timedelta from functools import reduce import logging -import re -from typing import Any, Dict, List, Optional, Text +from typing import Any, Optional -from aiohttp import ClientConnectionError, ClientSession, web, web_response +from aiohttp import ClientConnectionError, ClientSession, InvalidURL, web, web_response from aiohttp.web_exceptions import HTTPBadRequest from alexapy import ( AlexaLogin, @@ -32,9 +31,9 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import UnknownFlow from homeassistant.exceptions import Unauthorized -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.util import slugify +import httpx import voluptuous as vol from yarl import URL @@ -43,24 +42,24 @@ AUTH_CALLBACK_PATH, AUTH_PROXY_NAME, AUTH_PROXY_PATH, - CONF_COOKIES_TXT, CONF_DEBUG, CONF_EXCLUDE_DEVICES, CONF_EXTENDED_ENTITY_DISCOVERY, CONF_HASS_URL, CONF_INCLUDE_DEVICES, CONF_OAUTH, - CONF_OAUTH_LOGIN, CONF_OTPSECRET, - CONF_PROXY, + CONF_PROXY_WARNING, + CONF_PUBLIC_URL, CONF_QUEUE_DELAY, CONF_SECURITYCODE, CONF_TOTP_REGISTER, DATA_ALEXAMEDIA, DEFAULT_EXTENDED_ENTITY_DISCOVERY, + DEFAULT_PUBLIC_URL, DEFAULT_QUEUE_DELAY, DOMAIN, - HTTP_COOKIE_HEADER, + ISSUE_URL, STARTUP, ) from .helpers import calculate_uuid @@ -91,7 +90,7 @@ class AlexaMediaFlowHandler(config_entries.ConfigFlow): def _update_ord_dict(self, old_dict: OrderedDict, new_dict: dict) -> OrderedDict: result: OrderedDict = OrderedDict() - for k, v in old_dict.items(): + for k, v in old_dict.items(): # pylint: disable=invalid-name for key, value in new_dict.items(): if k == key: result.update([(key, value)]) @@ -106,13 +105,12 @@ def __init__(self): _LOGGER.info(STARTUP) _LOGGER.info("Loaded alexapy==%s", alexapy_version) self.login = None - self.securitycode: Optional[Text] = None + self.securitycode: Optional[str] = None self.automatic_steps: int = 0 self.config = OrderedDict() self.proxy_schema = None self.data_schema = OrderedDict( [ - (vol.Optional(CONF_PROXY, default=False), bool), (vol.Required(CONF_EMAIL), str), (vol.Required(CONF_PASSWORD), str), (vol.Required(CONF_URL, default="amazon.com"), str), @@ -122,73 +120,25 @@ def __init__(self): (vol.Optional(CONF_INCLUDE_DEVICES, default=""), str), (vol.Optional(CONF_EXCLUDE_DEVICES, default=""), str), (vol.Optional(CONF_SCAN_INTERVAL, default=60), int), - (vol.Optional(CONF_COOKIES_TXT, default=""), str), - (vol.Optional(CONF_OAUTH_LOGIN, default=True), bool), - ] - ) - self.captcha_schema = OrderedDict( - [ - (vol.Optional(CONF_PROXY, default=False), bool), - (vol.Required(CONF_PASSWORD), str), - ( - vol.Optional( - CONF_SECURITYCODE, - default=self.securitycode if self.securitycode else "", - ), - str, - ), - (vol.Required("captcha"), str), - ] - ) - self.twofactor_schema = OrderedDict( - [ - (vol.Optional(CONF_PROXY, default=False), bool), - ( - vol.Required( - CONF_SECURITYCODE, - default=self.securitycode if self.securitycode else "", - ), - str, - ), - ] - ) - self.claimspicker_schema = OrderedDict( - [ - (vol.Optional(CONF_PROXY, default=False), bool), - ( - vol.Required("claimsoption", default=0), - vol.All(cv.positive_int, vol.Clamp(min=0)), - ), - ] - ) - self.authselect_schema = OrderedDict( - [ - (vol.Optional(CONF_PROXY, default=False), bool), - ( - vol.Required("authselectoption", default=0), - vol.All(cv.positive_int, vol.Clamp(min=0)), - ), - ] - ) - self.verificationcode_schema = OrderedDict( - [ - (vol.Optional(CONF_PROXY, default=False), bool), - (vol.Required("verificationcode"), str), ] ) self.totp_register = OrderedDict( [(vol.Optional(CONF_TOTP_REGISTER, default=False), bool)] ) + self.proxy_warning = OrderedDict( + [(vol.Optional(CONF_PROXY_WARNING, default=False), bool)] + ) async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" return await self.async_step_user_legacy(import_config) async def async_step_user(self, user_input=None): + # pylint: disable=too-many-branches """Provide a proxy for login.""" self._save_user_input_to_config(user_input=user_input) try: - hass_url: Text = get_url(self.hass, prefer_external=True) + hass_url: str = get_url(self.hass, prefer_external=True) except NoURLAvailableError: hass_url = "" self.proxy_schema = OrderedDict( @@ -249,17 +199,6 @@ async def async_step_user(self, user_input=None): ), int, ), - ( - vol.Optional(CONF_PROXY, default=self.config.get(CONF_PROXY, True)), - bool, - ), - ( - vol.Optional( - CONF_OAUTH_LOGIN, - default=self.config.get(CONF_OAUTH_LOGIN, True), - ), - bool, - ), ] ) if not user_input: @@ -268,12 +207,6 @@ async def async_step_user(self, user_input=None): data_schema=vol.Schema(self.proxy_schema), description_placeholders={"message": ""}, ) - if user_input and not user_input.get(CONF_PROXY): - return self.async_show_form( - step_id="user_legacy", - data_schema=vol.Schema(self._update_schema_defaults()), - description_placeholders={"message": ""}, - ) if self.login is None: try: self.login = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ @@ -295,8 +228,9 @@ async def async_step_user(self, user_input=None): outputpath=self.hass.config.path, debug=self.config[CONF_DEBUG], otp_secret=self.config.get(CONF_OTPSECRET, ""), + oauth=self.config.get(CONF_OAUTH, {}), uuid=uuid, - oauth_login=self.config.get(CONF_OAUTH_LOGIN, True), + oauth_login=True, ) else: _LOGGER.debug("Using existing login") @@ -312,35 +246,51 @@ async def async_step_user(self, user_input=None): errors={"base": "2fa_key_invalid"}, description_placeholders={"message": ""}, ) - hass_url: Text = user_input.get(CONF_HASS_URL) + hass_url: str = user_input.get(CONF_HASS_URL) + if hass_url is None: + try: + hass_url = get_url(self.hass, prefer_external=True) + except NoURLAvailableError: + _LOGGER.debug( + "No Home Assistant URL found in config or detected; forcing user form" + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(self.proxy_schema), + description_placeholders={"message": ""}, + ) hass_url_valid: bool = False + hass_url_error: str = "" async with ClientSession() as session: try: async with session.get(hass_url) as resp: hass_url_valid = resp.status == 200 - except ClientConnectionError: + except ClientConnectionError as err: hass_url_valid = False + hass_url_error = str(err) + except InvalidURL as err: + hass_url_valid = False + hass_url_error = str(err.__cause__) if not hass_url_valid: _LOGGER.debug( "Unable to connect to provided Home Assistant url: %s", hass_url ) return self.async_show_form( - step_id="user", - errors={"base": "hass_url_invalid"}, - description_placeholders={"message": ""}, - ) - if not self.proxy: - self.proxy = AlexaProxy( - self.login, str(URL(hass_url).with_path(AUTH_PROXY_PATH)) + step_id="proxy_warning", + data_schema=vol.Schema(self.proxy_warning), + errors={}, + description_placeholders={ + "email": self.login.email, + "hass_url": hass_url, + "error": hass_url_error, + }, ) - # Swap the login object - self.proxy.change_login(self.login) if ( user_input and user_input.get(CONF_OTPSECRET) and user_input.get(CONF_OTPSECRET).replace(" ", "") ): - otp: Text = self.login.get_totp_token() + otp: str = self.login.get_totp_token() if otp: _LOGGER.debug("Generating OTP from %s", otp) return self.async_show_form( @@ -357,11 +307,26 @@ async def async_step_user(self, user_input=None): async def async_step_start_proxy(self, user_input=None): """Start proxy for login.""" + # pylint: disable=unused-argument _LOGGER.debug( "Starting proxy for %s - %s", hide_email(self.login.email), self.login.url, ) + if not self.proxy: + try: + self.proxy = AlexaProxy( + self.login, + str(URL(self.config.get(CONF_HASS_URL)).with_path(AUTH_PROXY_PATH)), + ) + except ValueError as ex: + return self.async_show_form( + step_id="user", + errors={"base": "invalid_url"}, + description_placeholders={"message": str(ex)}, + ) + # Swap the login object + self.proxy.change_login(self.login) if not self.proxy_view: self.proxy_view = AlexaMediaAuthorizationProxyView(self.proxy.all_handler) else: @@ -370,7 +335,7 @@ async def async_step_start_proxy(self, user_input=None): self.hass.http.register_view(AlexaMediaAuthorizationCallbackView()) self.hass.http.register_view(self.proxy_view) callback_url = ( - URL(self.config["hass_url"]) + URL(self.config[CONF_HASS_URL]) .with_path(AUTH_CALLBACK_PATH) .with_query({"flow_id": self.flow_id}) ) @@ -378,21 +343,12 @@ async def async_step_start_proxy(self, user_input=None): proxy_url = self.proxy.access_url().with_query( {"config_flow_id": self.flow_id, "callback_url": str(callback_url)} ) - if self.login.lastreq: - self.proxy.last_resp = self.login.lastreq - self.proxy.session.cookie_jar.update_cookies( - self.login._session.cookie_jar.filter_cookies( - self.proxy._host_url.with_path("/") - ) - ) - proxy_url = ( - self.proxy.access_url().with_path(AUTH_PROXY_PATH) / "resume" - ).with_query( - {"config_flow_id": self.flow_id, "callback_url": str(callback_url)} - ) + self.login._session.cookie_jar.clear() # pylint: disable=protected-access + self.login.proxy_url = proxy_url return self.async_external_step(step_id="check_proxy", url=str(proxy_url)) async def async_step_check_proxy(self, user_input=None): + # pylint: disable=unused-argument """Check status of proxy for login.""" _LOGGER.debug( "Checking proxy response for %s - %s", @@ -403,6 +359,7 @@ async def async_step_check_proxy(self, user_input=None): return self.async_external_step_done(next_step_id="finish_proxy") async def async_step_finish_proxy(self, user_input=None): + # pylint: disable=unused-argument """Finish auth.""" if await self.login.test_loggedin(): await self.login.finalize_login() @@ -419,7 +376,7 @@ async def async_step_user_legacy(self, user_input=None): if not user_input: self.automatic_steps = 0 return self.async_show_form( - step_id="user_legacy", + step_id="user", data_schema=vol.Schema(self.data_schema), description_placeholders={"message": ""}, ) @@ -434,13 +391,11 @@ async def async_step_user_legacy(self, user_input=None): _LOGGER.debug("Existing account found") self.automatic_steps = 0 return self.async_show_form( - step_id="user_legacy", + step_id="user", data_schema=vol.Schema(self.data_schema), errors={CONF_EMAIL: "identifier_exists"}, description_placeholders={"message": ""}, ) - if user_input and user_input.get(CONF_PROXY): - return await self.async_step_user(user_input=None) if self.login is None: try: self.login = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ @@ -463,7 +418,7 @@ async def async_step_user_legacy(self, user_input=None): debug=self.config[CONF_DEBUG], otp_secret=self.config.get(CONF_OTPSECRET, ""), uuid=uuid, - oauth_login=self.config.get(CONF_OAUTH_LOGIN, True), + oauth_login=True, ) else: _LOGGER.debug("Using existing login") @@ -473,7 +428,7 @@ async def async_step_user_legacy(self, user_input=None): and user_input.get(CONF_OTPSECRET) and user_input.get(CONF_OTPSECRET).replace(" ", "") ): - otp: Text = self.login.get_totp_token() + otp: str = self.login.get_totp_token() if otp: _LOGGER.debug("Generating OTP from %s", otp) return self.async_show_form( @@ -487,7 +442,7 @@ async def async_step_user_legacy(self, user_input=None): }, ) return self.async_show_form( - step_id="user_legacy", + step_id="user", errors={"base": "2fa_key_invalid"}, description_placeholders={"message": ""}, ) @@ -496,9 +451,6 @@ async def async_step_user_legacy(self, user_input=None): return await self._test_login() _LOGGER.debug("Trying to login %s", self.login.status) await self.login.login( - cookies=await self.login.load_cookie( - cookies_txt=self.config.get(CONF_COOKIES_TXT, "") - ), data=self.config, ) return await self._test_login() @@ -516,7 +468,7 @@ async def async_step_user_legacy(self, user_input=None): errors={"base": "2fa_key_invalid"}, description_placeholders={"message": ""}, ) - except BaseException as ex: # pylyint: disable=broad-except + except BaseException as ex: # pylint: disable=broad-except _LOGGER.warning("Unknown error: %s", ex) if self.config[CONF_DEBUG]: raise @@ -524,23 +476,28 @@ async def async_step_user_legacy(self, user_input=None): return self.async_show_form( step_id="user_legacy", errors={"base": "unknown_error"}, - description_placeholders={"message": ""}, + description_placeholders={"message": str(ex)}, ) - async def async_step_captcha(self, user_input=None): - """Handle the input processing of the config flow.""" - return await self.async_step_process("captcha", user_input) - - async def async_step_twofactor(self, user_input=None): - """Handle the input processing of the config flow.""" - return await self.async_step_process("two_factor", user_input) + async def async_step_proxy_warning(self, user_input=None): + """Handle the proxy_warning for the config flow.""" + self._save_user_input_to_config(user_input=user_input) + if user_input and user_input.get(CONF_PROXY_WARNING) is False: + _LOGGER.debug("User is not accepting warning, go back") + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(self.proxy_schema), + description_placeholders={"message": ""}, + ) + _LOGGER.debug("User is ignoring proxy warning; starting proxy anyway") + return await self.async_step_start_proxy(user_input) async def async_step_totp_register(self, user_input=None): """Handle the input processing of the config flow.""" self._save_user_input_to_config(user_input=user_input) - if user_input and user_input.get("registered") is False: + if user_input and user_input.get(CONF_TOTP_REGISTER) is False: _LOGGER.debug("Not registered, regenerating") - otp: Text = self.login.get_totp_token() + otp: str = self.login.get_totp_token() if otp: _LOGGER.debug("Generating OTP from %s", otp) return self.async_show_form( @@ -553,25 +510,7 @@ async def async_step_totp_register(self, user_input=None): "message": otp, }, ) - if self.proxy: - return await self.async_step_start_proxy(user_input) - return await self.async_step_process("totp_register", self.config) - - async def async_step_claimspicker(self, user_input=None): - """Handle the input processing of the config flow.""" - return await self.async_step_process("claimspicker", user_input) - - async def async_step_authselect(self, user_input=None): - """Handle the input processing of the config flow.""" - return await self.async_step_process("authselect", user_input) - - async def async_step_verificationcode(self, user_input=None): - """Handle the input processing of the config flow.""" - return await self.async_step_process("verificationcode", user_input) - - async def async_step_action_required(self, user_input=None): - """Handle the input processing of the config flow.""" - return await self.async_step_process("action_required", user_input) + return await self.async_step_start_proxy(user_input) async def async_step_process(self, step_id, user_input=None): """Handle the input processing of the config flow.""" @@ -581,28 +520,8 @@ async def async_step_process(self, step_id, user_input=None): obfuscate(user_input), ) self._save_user_input_to_config(user_input=user_input) - if user_input and user_input.get(CONF_PROXY): - return await self.async_step_user(user_input=None) if user_input: - try: - await self.login.login(data=user_input) - except AlexapyConnectionError: - self.automatic_steps = 0 - return self.async_show_form( - step_id=step_id, - errors={"base": "connection_error"}, - description_placeholders={"message": ""}, - ) - except BaseException as ex: # pylint: disable=broad-except - _LOGGER.warning("Unknown error: %s", ex) - if self.config[CONF_DEBUG]: - raise - self.automatic_steps = 0 - return self.async_show_form( - step_id=step_id, - errors={"base": "unknown_error"}, - description_placeholders={"message": ""}, - ) + return await self.async_step_user(user_input=None) return await self._test_login() async def async_step_reauth(self, user_input=None): @@ -633,7 +552,7 @@ async def async_step_reauth(self, user_input=None): seconds_since_login, ) return self.async_show_form( - step_id="user_legacy", + step_id="user", data_schema=vol.Schema(reauth_schema), description_placeholders={"message": "REAUTH"}, ) @@ -642,7 +561,6 @@ async def async_step_reauth(self, user_input=None): return await self.async_step_user_legacy(self.config) async def _test_login(self): - # pylint: disable=too-many-statements, too-many-return-statements login = self.login email = login.email _LOGGER.debug("Testing login status: %s", login.status) @@ -652,19 +570,22 @@ async def _test_login(self): self.config.pop("reauth") if self.config.get(CONF_SECURITYCODE): self.config.pop(CONF_SECURITYCODE) - if self.config.get(CONF_PROXY): - self.config.pop(CONF_PROXY) if self.config.get("hass_url"): self.config.pop("hass_url") self.config[CONF_OAUTH] = { "access_token": login.access_token, "refresh_token": login.refresh_token, "expires_in": login.expires_in, + "mac_dms": login.mac_dms, + "code_verifier": login.code_verifier, + "authorization_code": login.authorization_code, } self.hass.data.setdefault( DATA_ALEXAMEDIA, - {"accounts": {}, "config_flows": {}}, + {"accounts": {}, "config_flows": {}, "notify_service": None}, ) + self.hass.data[DATA_ALEXAMEDIA].setdefault("accounts", {}) + self.hass.data[DATA_ALEXAMEDIA].setdefault("config_flows", {}) if existing_entry: self.hass.config_entries.async_update_entry( existing_entry, data=self.config @@ -677,6 +598,12 @@ async def _test_login(self): self.hass.components.persistent_notification.async_dismiss( f"alexa_media_{slugify(email)}{slugify(login.url[7:])}" ) + if not self.hass.data[DATA_ALEXAMEDIA]["accounts"].get( + self.config[CONF_EMAIL] + ): + self.hass.data[DATA_ALEXAMEDIA]["accounts"][ + self.config[CONF_EMAIL] + ] = {} self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.config[CONF_EMAIL]][ "login_obj" ] = self.login @@ -691,40 +618,12 @@ async def _test_login(self): return self.async_create_entry( title=f"{login.email} - {login.url}", data=self.config ) - if login.status and login.status.get("captcha_required"): - new_schema = self._update_ord_dict( - self.captcha_schema, - { - vol.Required( - CONF_PASSWORD, default=self.config[CONF_PASSWORD] - ): str, - vol.Optional( - CONF_SECURITYCODE, - default=self.securitycode if self.securitycode else "", - ): str, - }, - ) - _LOGGER.debug("Creating config_flow to request captcha") - self.automatic_steps = 0 - return self.async_show_form( - step_id="captcha", - data_schema=vol.Schema(new_schema), - errors={}, - description_placeholders={ - "email": login.email, - "url": login.url, - "captcha_image": "[![captcha]({0})]({0})".format( - login.status["captcha_image_url"] - ), - "message": f" \n> {login.status.get('error_message','')}", - }, - ) if login.status and login.status.get("securitycode_required"): _LOGGER.debug( "Creating config_flow to request 2FA. Saved security code %s", self.securitycode, ) - generated_securitycode: Text = login.get_totp_token() + generated_securitycode: str = login.get_totp_token() if ( self.securitycode or generated_securitycode ) and self.automatic_steps < 2: @@ -740,100 +639,13 @@ async def _test_login(self): self.automatic_steps += 1 await sleep(5) if generated_securitycode: - return await self.async_step_twofactor( + return await self.async_step_user_legacy( user_input={CONF_SECURITYCODE: generated_securitycode} ) - return await self.async_step_twofactor( + return await self.async_step_user_legacy( user_input={CONF_SECURITYCODE: self.securitycode} ) - self.twofactor_schema = OrderedDict( - [ - (vol.Optional(CONF_PROXY, default=False), bool), - ( - vol.Required( - CONF_SECURITYCODE, - default=self.securitycode if self.securitycode else "", - ), - str, - ), - ] - ) - self.automatic_steps = 0 - return self.async_show_form( - step_id="twofactor", - data_schema=vol.Schema(self.twofactor_schema), - errors={}, - description_placeholders={ - "email": login.email, - "url": login.url, - "message": f" \n> {login.status.get('error_message','')}", - }, - ) - if login.status and login.status.get("claimspicker_required"): - error_message = f" \n> {login.status.get('error_message', '')}" - _LOGGER.debug("Creating config_flow to select verification method") - claimspicker_message = login.status["claimspicker_message"] - self.automatic_steps = 0 - return self.async_show_form( - step_id="claimspicker", - data_schema=vol.Schema(self.claimspicker_schema), - errors={}, - description_placeholders={ - "email": login.email, - "url": login.url, - "message": " \n> {} \n> {}".format( - claimspicker_message, error_message - ), - }, - ) - if login.status and login.status.get("authselect_required"): - _LOGGER.debug("Creating config_flow to select OTA method") - error_message = login.status.get("error_message", "") - authselect_message = login.status["authselect_message"] - self.automatic_steps = 0 - return self.async_show_form( - step_id="authselect", - data_schema=vol.Schema(self.authselect_schema), - description_placeholders={ - "email": login.email, - "url": login.url, - "message": " \n> {} \n> {}".format( - authselect_message, error_message - ), - }, - ) - if login.status and login.status.get("verificationcode_required"): - _LOGGER.debug("Creating config_flow to enter verification code") - self.automatic_steps = 0 - return self.async_show_form( - step_id="verificationcode", - data_schema=vol.Schema(self.verificationcode_schema), - ) - if ( - login.status - and login.status.get("force_get") - and not login.status.get("ap_error_href") - ): - _LOGGER.debug("Creating config_flow to wait for user action") - self.automatic_steps = 0 - return self.async_show_form( - step_id="action_required", - data_schema=vol.Schema( - OrderedDict([(vol.Optional(CONF_PROXY, default=False), bool)]) - ), - description_placeholders={ - "email": login.email, - "url": login.url, - "message": f" \n>{login.status.get('message','')} \n", - }, - ) if login.status and (login.status.get("login_failed")): - if login.oauth_login: - _LOGGER.debug("Trying non-oauth login") - await login.reset() - login.oauth_login = False - await login.login() - return await self._test_login() _LOGGER.debug("Login failed: %s", login.status.get("login_failed")) await login.close() self.hass.components.persistent_notification.async_dismiss( @@ -860,17 +672,9 @@ async def _test_login(self): _LOGGER.debug( "Done with automatic resubmission for error_message 'valid email'; returning error message", ) - self.automatic_steps = 0 - return self.async_show_form( - step_id="user_legacy", - data_schema=vol.Schema(new_schema), - description_placeholders={ - "message": f" \n> {login.status.get('error_message','')}" - }, - ) self.automatic_steps = 0 return self.async_show_form( - step_id="user_legacy", + step_id="user", data_schema=vol.Schema(new_schema), description_placeholders={ "message": f" \n> {login.status.get('error_message','')}" @@ -878,6 +682,7 @@ async def _test_login(self): ) def _save_user_input_to_config(self, user_input=None) -> None: + # pylint: disable=too-many-branches """Process user_input to save to self.config. user_input can be a dictionary of strings or an internally @@ -886,10 +691,6 @@ def _save_user_input_to_config(self, user_input=None) -> None: """ if user_input is None: return - if CONF_PROXY in user_input: - self.config[CONF_PROXY] = user_input[CONF_PROXY] - if CONF_OAUTH_LOGIN in user_input: - self.config[CONF_OAUTH_LOGIN] = user_input[CONF_OAUTH_LOGIN] if CONF_HASS_URL in user_input: self.config[CONF_HASS_URL] = user_input[CONF_HASS_URL] self.securitycode = user_input.get(CONF_SECURITYCODE) @@ -936,27 +737,6 @@ def _save_user_input_to_config(self, user_input=None) -> None: ) else: self.config[CONF_EXCLUDE_DEVICES] = user_input[CONF_EXCLUDE_DEVICES] - if ( - user_input.get(CONF_COOKIES_TXT) - and f"{HTTP_COOKIE_HEADER}\n" != user_input[CONF_COOKIES_TXT] - ): - fixed_cookies_txt = re.sub( - r" ", - r"\n", - re.sub( - r"#.*\n", - r"", - re.sub( - r"# ((?:.(?!# ))+)$", - r"\1", - re.sub(r" #", r"\n#", user_input[CONF_COOKIES_TXT]), - ), - ), - ) - if not fixed_cookies_txt.startswith(HTTP_COOKIE_HEADER): - fixed_cookies_txt = f"{HTTP_COOKIE_HEADER}\n{fixed_cookies_txt}" - self.config[CONF_COOKIES_TXT] = fixed_cookies_txt - _LOGGER.debug("Setting cookies to:\n%s", fixed_cookies_txt) def _update_schema_defaults(self) -> Any: new_schema = self._update_ord_dict( @@ -991,12 +771,6 @@ def _update_schema_defaults(self) -> Any: vol.Optional( CONF_SCAN_INTERVAL, default=self.config.get(CONF_SCAN_INTERVAL, 60) ): int, - vol.Optional( - CONF_COOKIES_TXT, default=self.config.get(CONF_COOKIES_TXT, "") - ): str, - vol.Optional( - CONF_OAUTH_LOGIN, default=self.config.get(CONF_OAUTH_LOGIN, True) - ): bool, }, ) return new_schema @@ -1028,6 +802,12 @@ async def async_step_init(self, user_input=None): CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY ), ): vol.All(vol.Coerce(float), vol.Clamp(min=0)), + vol.Optional( + CONF_PUBLIC_URL, + default=self.config_entry.options.get( + CONF_PUBLIC_URL, DEFAULT_PUBLIC_URL + ), + ): str, vol.Required( CONF_EXTENDED_ENTITY_DISCOVERY, default=self.config_entry.options.get( @@ -1066,12 +846,12 @@ async def get(self, request: web.Request): class AlexaMediaAuthorizationProxyView(HomeAssistantView): """Handle proxy connections.""" - url: Text = AUTH_PROXY_PATH - extra_urls: List[Text] = [f"{AUTH_PROXY_PATH}/{{tail:.*}}"] - name: Text = AUTH_PROXY_NAME + url: str = AUTH_PROXY_PATH + extra_urls: list[str] = [f"{AUTH_PROXY_PATH}/{{tail:.*}}"] + name: str = AUTH_PROXY_NAME requires_auth: bool = False handler: web.RequestHandler = None - known_ips: Dict[Text, datetime.datetime] = {} + known_ips: dict[str, datetime.datetime] = {} auth_seconds: int = 300 def __init__(self, handler: web.RequestHandler): @@ -1113,7 +893,16 @@ async def wrapped(request, **kwargs): if not success: raise Unauthorized() cls.known_ips[request.remote] = datetime.datetime.now() - return await cls.handler(request, **kwargs) + try: + return await cls.handler(request, **kwargs) + except httpx.ConnectError as ex: # pylyint: disable=broad-except + _LOGGER.warning("Detected Connection error: %s", ex) + return web_response.Response( + headers={"content-type": "text/html"}, + text="Connection Error! Please try refreshing. " + + "If this persists, please report this error to " + + f"here:
{ex}
", + ) return wrapped diff --git a/custom_components/alexa_media/configurator.py b/custom_components/alexa_media/configurator.py deleted file mode 100644 index 5b75a53a5..000000000 --- a/custom_components/alexa_media/configurator.py +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# SPDX-License-Identifier: Apache-2.0 -""" -Support to interface with Alexa Devices. - -For more details about this platform, please refer to the documentation at -https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639 -""" -import logging -from typing import Text - -from . import hide_email -from .const import DATA_ALEXAMEDIA - -_LOGGER = logging.getLogger(__name__) - - -async def test_login_status(hass, config_entry, login, alexa_setup_callback) -> bool: - """Test the login status and spawn requests for info.""" - - async def request_configuration(hass, config_entry, login): - """Request configuration steps from the user using the configurator.""" - - async def configuration_callback(callback_data): - """Handle the submitted configuration.""" - await hass.async_add_job( - setup_platform_callback, hass, config_entry, login, callback_data - ) - - configurator = hass.components.configurator - status = login.status - email = login.email - # links = "" - footer = "" - if "error_message" in status and status["error_message"]: - footer = ( - "\nNOTE: Actual Amazon error message in red below. " - "Remember password will be provided automatically" - " and Amazon error message normally appears first!" - ) - # if login.links: - # links = '\n\nGo to link with link# (e.g. link0)\n' + login.links - # Get Captcha - if ( - status - and "captcha_image_url" in status - and status["captcha_image_url"] is not None - ): - config_id = configurator.async_request_config( - "Alexa Media Player - Captcha - {}".format(email), - configuration_callback, - description=( - "Please enter the text for the captcha." - " Please hit confirm to reload image." - # + links - + footer - ), - description_image=status["captcha_image_url"], - submit_caption="Confirm", - fields=[{"id": "captcha", "name": "Captcha"}], - ) - elif ( - status - and "securitycode_required" in status - and status["securitycode_required"] - ): # Get 2FA code - config_id = configurator.async_request_config( - "Alexa Media Player - 2FA - {}".format(email), - configuration_callback, - description=( - "Please enter your Two-Factor Security code." - # + links - + footer - ), - submit_caption="Confirm", - fields=[{"id": "securitycode", "name": "Security Code"}], - ) - elif ( - status - and "claimspicker_required" in status - and status["claimspicker_required"] - ): # Get picker method - options = status["claimspicker_message"] - if options: - config_id = configurator.async_request_config( - "Alexa Media Player - Verification Method - {}".format(email), - configuration_callback, - description=( - "Please select the verification method by number. " - "(e.g., `0` or `1`).\n{}".format(options) - # + links - + footer - ), - submit_caption="Confirm", - fields=[{"id": "claimsoption", "name": "Option"}], - ) - else: - await configuration_callback({}) - elif ( - status and "authselect_required" in status and status["authselect_required"] - ): # Get picker method - options = status["authselect_message"] - if options: - config_id = configurator.async_request_config( - "Alexa Media Player - OTP Method - {}".format(email), - configuration_callback, - description=( - "Please select the OTP method by number. " - "(e.g., `0`, `1`).
{}".format(options) - # + links - + footer - ), - submit_caption="Confirm", - fields=[{"id": "authselectoption", "name": "Option"}], - ) - else: - await configuration_callback({}) - elif ( - status - and "verificationcode_required" in status - and status["verificationcode_required"] - ): # Get picker method - config_id = configurator.async_request_config( - "Alexa Media Player - Verification Code - {}".format(email), - configuration_callback, - description=( - "Please enter received verification code." - # + links - + footer - ), - submit_caption="Confirm", - fields=[{"id": "verificationcode", "name": "Verification Code"}], - ) - else: # Check login - config_id = configurator.async_request_config( - "Alexa Media Player - Begin - {}".format(email), - configuration_callback, - description=("Please hit confirm to begin login attempt."), - submit_caption="Confirm", - fields=[], - ) - hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"].append(config_id) - if "error_message" in status and status["error_message"]: - configurator.async_notify_errors(config_id, status["error_message"]) - if len(hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"]) > 1: - configurator.async_request_done( - (hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"]).pop(0) - ) - - async def setup_platform_callback(hass, config_entry, login, callback_data): - """Handle response from configurator. - - Args: - callback_data (json): Returned data from configurator passed through - request_configuration and configuration_callback - - """ - _LOGGER.debug( - ( - "Configurator closed for Status: %s\n" - " got captcha: %s securitycode: %s" - " Claimsoption: %s AuthSelectOption: %s " - " VerificationCode: %s" - ), - login.status, - callback_data.get("captcha"), - callback_data.get("securitycode"), - callback_data.get("claimsoption"), - callback_data.get("authselectoption"), - callback_data.get("verificationcode"), - ) - await login.login(data=callback_data) - await test_login_status(hass, config_entry, login, alexa_setup_callback) - - _LOGGER.debug("Testing login status: %s", login.status) - if "login_successful" in login.status and login.status["login_successful"]: - _LOGGER.debug("Setting up Alexa devices for %s", hide_email(login.email)) - await clear_configurator(hass, login.email) - await hass.async_add_job(alexa_setup_callback, hass, config_entry, login) - return True - if "captcha_required" in login.status and login.status["captcha_required"]: - _LOGGER.debug("Creating configurator to request captcha") - elif ( - "securitycode_required" in login.status - and login.status["securitycode_required"] - ): - _LOGGER.debug("Creating configurator to request 2FA") - elif ( - "claimspicker_required" in login.status - and login.status["claimspicker_required"] - ): - _LOGGER.debug("Creating configurator to select verification option") - elif "authselect_required" in login.status and login.status["authselect_required"]: - _LOGGER.debug("Creating configurator to select OTA option") - elif ( - "verificationcode_required" in login.status - and login.status["verificationcode_required"] - ): - _LOGGER.debug("Creating configurator to enter verification code") - elif "login_failed" in login.status and login.status["login_failed"]: - _LOGGER.debug("Creating configurator to start new login attempt") - await hass.async_add_job(request_configuration, hass, config_entry, login) - - -async def clear_configurator(hass, email: Text) -> None: - """Clear open configurators for email.""" - if email not in hass.data[DATA_ALEXAMEDIA]["accounts"]: - return - if "configurator" in hass.data[DATA_ALEXAMEDIA]["accounts"][email]: - for config_id in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"]: - configurator = hass.components.configurator - configurator.async_request_done(config_id) - hass.data[DATA_ALEXAMEDIA]["accounts"][email]["configurator"] = [] diff --git a/custom_components/alexa_media/const.py b/custom_components/alexa_media/const.py index c812ed3f3..d860be1b9 100644 --- a/custom_components/alexa_media/const.py +++ b/custom_components/alexa_media/const.py @@ -8,9 +8,16 @@ """ from datetime import timedelta -__version__ = "3.10.8" +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, +) + +__version__ = "4.6.5" PROJECT_URL = "https://github.com/custom-components/alexa_media_player/" ISSUE_URL = f"{PROJECT_URL}issues" +NOTIFY_URL = f"{PROJECT_URL}wiki/Configuration%3A-Notification-Component#use-the-notifyalexa_media-service" DOMAIN = "alexa_media" DATA_ALEXAMEDIA = "alexa_media" @@ -29,29 +36,31 @@ "sensor", "alarm_control_panel", "light", + "binary_sensor", ] HTTP_COOKIE_HEADER = "# HTTP Cookie File" CONF_ACCOUNTS = "accounts" -CONF_COOKIES_TXT = "cookies_txt" CONF_DEBUG = "debug" CONF_HASS_URL = "hass_url" CONF_INCLUDE_DEVICES = "include_devices" CONF_EXCLUDE_DEVICES = "exclude_devices" CONF_QUEUE_DELAY = "queue_delay" +CONF_PUBLIC_URL = "public_url" CONF_EXTENDED_ENTITY_DISCOVERY = "extended_entity_discovery" CONF_SECURITYCODE = "securitycode" CONF_OTPSECRET = "otp_secret" CONF_PROXY = "proxy" +CONF_PROXY_WARNING = "proxy_warning" CONF_TOTP_REGISTER = "registered" CONF_OAUTH = "oauth" -CONF_OAUTH_LOGIN = "oauth_login" DATA_LISTENER = "listener" EXCEPTION_TEMPLATE = "An exception of type {0} occurred. Arguments:\n{1!r}" DEFAULT_EXTENDED_ENTITY_DISCOVERY = False DEFAULT_QUEUE_DELAY = 1.5 +DEFAULT_PUBLIC_URL = "" SERVICE_CLEAR_HISTORY = "clear_history" SERVICE_UPDATE_LAST_CALLED = "update_last_called" SERVICE_FORCE_LOGOUT = "force_logout" @@ -59,6 +68,7 @@ RECURRING_PATTERN = { None: "Never Repeat", "P1D": "Every day", + "P1M": "Every month", "XXXX-WE": "Weekends", "XXXX-WD": "Weekdays", "XXXX-WXX-1": "Every Monday", @@ -70,6 +80,15 @@ "XXXX-WXX-7": "Every Sunday", } +RECURRING_DAY = { + "MO": 1, + "TU": 2, + "WE": 3, + "TH": 4, + "FR": 5, + "SA": 6, + "SU": 7, +} RECURRING_PATTERN_ISO_SET = { None: {}, "P1D": {1, 2, 3, 4, 5, 6, 7}, @@ -87,19 +106,32 @@ ATTR_MESSAGE = "message" ATTR_EMAIL = "email" ATTR_NUM_ENTRIES = "entries" -STARTUP = """ +STARTUP = f""" ------------------------------------------------------------------- -{} -Version: {} +{DOMAIN} +Version: {__version__} This is a custom component If you have any issues with this you need to open an issue here: -{} +{ISSUE_URL} ------------------------------------------------------------------- -""".format( - DOMAIN, __version__, ISSUE_URL -) +""" AUTH_CALLBACK_PATH = "/auth/alexamedia/callback" AUTH_CALLBACK_NAME = "auth:alexamedia:callback" AUTH_PROXY_PATH = "/auth/alexamedia/proxy" AUTH_PROXY_NAME = "auth:alexamedia:proxy" + +ALEXA_UNIT_CONVERSION = { + "Alexa.Unit.Percent": PERCENTAGE, + "Alexa.Unit.PartsPerMillion": CONCENTRATION_PARTS_PER_MILLION, + "Alexa.Unit.Density.MicroGramsPerCubicMeter": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, +} + +ALEXA_ICON_CONVERSION = { + "Alexa.AirQuality.CarbonMonoxide": "mdi:molecule-co", + "Alexa.AirQuality.Humidity": "mdi:water-percent", + "Alexa.AirQuality.IndoorAirQuality": "mdi:numeric", +} +ALEXA_ICON_DEFAULT = "mdi:molecule" + +UPLOAD_PATH = "www/alexa_tts" diff --git a/custom_components/alexa_media/helpers.py b/custom_components/alexa_media/helpers.py index 9964178a3..69434af89 100644 --- a/custom_components/alexa_media/helpers.py +++ b/custom_components/alexa_media/helpers.py @@ -6,14 +6,16 @@ For more details about this platform, please refer to the documentation at https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639 """ +import asyncio +import functools import hashlib import logging -from typing import Any, Callable, Dict, List, Optional, Text +from typing import Any, Callable, Optional from alexapy import AlexapyLoginCloseRequested, AlexapyLoginError, hide_email from alexapy.alexalogin import AlexaLogin from homeassistant.const import CONF_EMAIL, CONF_URL -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConditionErrorMessage from homeassistant.helpers.entity_component import EntityComponent import wrapt @@ -23,11 +25,11 @@ async def add_devices( - account: Text, - devices: List[EntityComponent], + account: str, + devices: list[EntityComponent], add_devices_callback: Callable, - include_filter: Optional[List[Text]] = None, - exclude_filter: Optional[List[Text]] = None, + include_filter: Optional[list[str]] = None, + exclude_filter: Optional[list[str]] = None, ) -> bool: """Add devices using add_devices_callback.""" include_filter = [] or include_filter @@ -49,8 +51,8 @@ async def add_devices( try: add_devices_callback(devices, False) return True - except HomeAssistantError as exception_: - message = exception_.message # type: str + except ConditionErrorMessage as exception_: + message: str = exception_.message if message.startswith("Entity id already exists"): _LOGGER.debug("%s: Device already added: %s", account, message) else: @@ -84,6 +86,7 @@ def retry_async( The delay in seconds between retries. catch_exceptions : bool Whether exceptions should be caught and treated as failures or thrown. + Returns ------- def @@ -92,9 +95,6 @@ def retry_async( """ def wrap(func) -> Callable: - import asyncio - import functools - @functools.wraps(func) async def wrapper(*args, **kwargs) -> Any: _LOGGER.debug( @@ -110,7 +110,7 @@ async def wrapper(*args, **kwargs) -> Any: next_try: int = 0 while not result and retries < limit: if retries != 0: - next_try = delay * 2 ** retries + next_try = delay * 2**retries await asyncio.sleep(next_try) retries += 1 try: @@ -168,7 +168,7 @@ async def _catch_login_errors(func, instance, args, kwargs) -> Any: # _LOGGER.debug("Func %s instance %s %s %s", func, instance, args, kwargs) if instance: if hasattr(instance, "_login"): - login = instance._login + login = instance._login # pylint: disable=protected-access hass = instance.hass else: for arg in all_args: @@ -222,8 +222,8 @@ def report_relogin_required(hass, login, email) -> bool: return False -def _existing_serials(hass, login_obj) -> List: - email: Text = login_obj.email +def _existing_serials(hass, login_obj) -> list: + email: str = login_obj.email existing_serials = ( list( hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][ @@ -250,13 +250,13 @@ def _existing_serials(hass, login_obj) -> List: return existing_serials -async def calculate_uuid(hass, email: Text, url: Text) -> dict: +async def calculate_uuid(hass, email: str, url: str) -> dict: """Return uuid and index of email/url. Args hass (bool): Hass entity - url (Text): url for account - email (Text): email for account + url (str): url for account + email (str): email for account Returns dict: dictionary with uuid and index @@ -285,33 +285,25 @@ async def calculate_uuid(hass, email: Text, url: Text) -> dict: def alarm_just_dismissed( - alarm: Dict[Text, Any], - previous_status: Optional[Text], - previous_version: Optional[Text], + alarm: dict[str, Any], + previous_status: Optional[str], + previous_version: Optional[str], ) -> bool: """Given the previous state of an alarm, determine if it has just been dismissed.""" - if previous_status not in ("SNOOZED", "ON"): + if ( + previous_status not in ("SNOOZED", "ON") # The alarm had to be in a status that supported being dismissed - return False - - if previous_version is None: + or previous_version is None # The alarm was probably just created - return False - - if not alarm: + or not alarm # The alarm that was probably just deleted. - return False - - if alarm.get("status") not in ("OFF", "ON"): + or alarm.get("status") not in ("OFF", "ON") # A dismissed alarm is guaranteed to be turned off(one-off alarm) or left on(recurring alarm) - return False - - if previous_version == alarm.get("version"): + or previous_version == alarm.get("version") # A dismissal always has a changed version. - return False - - if int(alarm.get("version", "0")) > 1 + int(previous_version): + or int(alarm.get("version", "0")) > 1 + int(previous_version) + ): # This is an absurd thing to check, but it solves many, many edge cases. # Experimentally, when an alarm is dismissed, the version always increases by 1 # When an alarm is edited either via app or voice, its version always increases by 2+ diff --git a/custom_components/alexa_media/light.py b/custom_components/alexa_media/light.py index 369cf0710..b8bd529c5 100644 --- a/custom_components/alexa_media/light.py +++ b/custom_components/alexa_media/light.py @@ -9,13 +9,7 @@ import datetime import logging from math import sqrt -from typing import ( # noqa pylint: disable=unused-import - Callable, - List, - Optional, - Text, - Tuple, -) +from typing import Optional from alexapy import AlexaAPI, hide_serial from homeassistant.components.light import ( @@ -27,6 +21,7 @@ SUPPORT_COLOR_TEMP, LightEntity, ) +from homeassistant.exceptions import ConfigEntryNotReady try: from homeassistant.components.light import ( @@ -74,8 +69,14 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the Alexa sensor platform.""" - devices: List[LightEntity] = [] - account = config[CONF_EMAIL] if config else discovery_info["config"][CONF_EMAIL] + devices: list[LightEntity] = [] + account = None + if config: + account = config.get(CONF_EMAIL) + if account is None and discovery_info: + account = discovery_info.get("config", {}).get(CONF_EMAIL) + if account is None: + raise ConfigEntryNotReady account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] include_filter = config.get(CONF_INCLUDE_DEVICES, []) exclude_filter = config.get(CONF_EXCLUDE_DEVICES, []) @@ -85,20 +86,20 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf ) light_entities = account_dict.get("devices", {}).get("light", []) if light_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY): - for le in light_entities: - if not (le["is_hue_v1"] and hue_emulated_enabled): + for light_entity in light_entities: + if not (light_entity["is_hue_v1"] and hue_emulated_enabled): _LOGGER.debug( "Creating entity %s for a light with name %s", - hide_serial(le["id"]), - le["name"], + hide_serial(light_entity["id"]), + light_entity["name"], ) - light = AlexaLight(coordinator, account_dict["login_obj"], le) + light = AlexaLight(coordinator, account_dict["login_obj"], light_entity) account_dict["entities"]["light"].append(light) devices.append(light) else: _LOGGER.debug( "Light '%s' has not been added because it may originate from emulated_hue", - le["name"], + light_entity["name"], ) return await add_devices( @@ -127,23 +128,24 @@ async def async_unload_entry(hass, entry) -> bool: return True -def color_modes(details): +def color_modes(details) -> list: + """Return list of color modes.""" if details["color"] and details["color_temperature"]: return [COLOR_MODE_HS, COLOR_MODE_COLOR_TEMP] - elif details["color"]: + if details["color"]: return [COLOR_MODE_HS] - elif details["color_temperature"]: + if details["color_temperature"]: return [COLOR_MODE_COLOR_TEMP] - elif details["brightness"]: + if details["brightness"]: return [COLOR_MODE_BRIGHTNESS] - else: - return [COLOR_MODE_ONOFF] + return [COLOR_MODE_ONOFF] class AlexaLight(CoordinatorEntity, LightEntity): """A light controlled by an Echo.""" def __init__(self, coordinator, login, details): + """Initialize alexa light entity.""" super().__init__(coordinator) self.alexa_entity_id = details["id"] self._name = details["name"] @@ -163,14 +165,17 @@ def __init__(self, coordinator, login, details): @property def name(self): + """Return name.""" return self._name @property def unique_id(self): + """Return unique id.""" return self.alexa_entity_id @property def supported_features(self): + """Return supported features.""" # The HA documentation marks every single feature that Alexa lights can support as deprecated. # The new alternative is the supported_color_modes and color_mode properties(HA 2021.4) # This SHOULD just need to return 0 according to the light entity docs. @@ -178,95 +183,99 @@ def supported_features(self): # So, continue to provide a backwards compatible method here until HA is fixed and the min HA version is raised. if COLOR_MODE_BRIGHTNESS in self._color_modes: return SUPPORT_BRIGHTNESS - elif ( + if ( COLOR_MODE_HS in self._color_modes and COLOR_MODE_COLOR_TEMP in self._color_modes ): return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP - elif COLOR_MODE_HS in self._color_modes: + if COLOR_MODE_HS in self._color_modes: return SUPPORT_BRIGHTNESS | SUPPORT_COLOR - elif COLOR_MODE_COLOR_TEMP in self._color_modes: + if COLOR_MODE_COLOR_TEMP in self._color_modes: return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP - else: - - return 0 + return 0 @property def color_mode(self): + """Return color mode.""" if ( COLOR_MODE_HS in self._color_modes and COLOR_MODE_COLOR_TEMP in self._color_modes ): - hs = self.hs_color - if hs is None or (hs[0] == 0 and hs[1] == 0): + hs_color = self.hs_color + if hs_color is None or (hs_color[0] == 0 and hs_color[1] == 0): # (0,0) is white. When white, color temp is the better plan. return COLOR_MODE_COLOR_TEMP - else: - return COLOR_MODE_HS - else: - return self._color_modes[0] + return COLOR_MODE_HS + return self._color_modes[0] @property def supported_color_modes(self): + """Return supported color modes.""" return self._color_modes @property def is_on(self): + """Return whether on.""" power = parse_power_from_coordinator( self.coordinator, self.alexa_entity_id, self._requested_state_at ) if power is None: return self._requested_power if self._requested_power is not None else False - else: - return power == "ON" + return power == "ON" @property def brightness(self): + """Return brightness.""" bright = parse_brightness_from_coordinator( self.coordinator, self.alexa_entity_id, self._requested_state_at ) if bright is None: return self._requested_ha_brightness - else: - return alexa_brightness_to_ha(bright) + return alexa_brightness_to_ha(bright) @property def min_mireds(self): + """Return min mireds.""" return 143 @property def max_mireds(self): + """Return max mireds.""" return 454 @property def color_temp(self): + """Return color temperature.""" kelvin = parse_color_temp_from_coordinator( self.coordinator, self.alexa_entity_id, self._requested_state_at ) if kelvin is None: return self._requested_mired - else: - return alexa_kelvin_to_mired(kelvin) + return alexa_kelvin_to_mired(kelvin) @property def hs_color(self): + """Return hs color.""" hsb = parse_color_from_coordinator( self.coordinator, self.alexa_entity_id, self._requested_state_at ) if hsb is None: return self._requested_hs - else: - adjusted_hs, color_name = hsb_to_alexa_color(hsb) - return adjusted_hs + ( + adjusted_hs, + color_name, # pylint:disable=unused-variable + ) = hsb_to_alexa_color(hsb) + return adjusted_hs @property def assumed_state(self) -> bool: + """Return whether state is assumed.""" last_refresh_success = ( self.coordinator.data and self.alexa_entity_id in self.coordinator.data ) return not last_refresh_success - async def _set_state(self, power_on, brightness=None, mired=None, hs=None): + async def _set_state(self, power_on, brightness=None, mired=None, hs_color=None): # This is "rounding" on mired to the closest value Alexa is willing to acknowledge the existence of. # The alternative implementation would be to use effects instead. # That is far more non-standard, and would lock users out of things like the Flux integration. @@ -278,7 +287,7 @@ async def _set_state(self, power_on, brightness=None, mired=None, hs=None): # This is "rounding" on HS color to closest value Alexa supports. # The alexa color list is short, but covers a pretty broad spectrum. # Like for mired above, this sounds bad but works ok in practice. - adjusted_hs, color_name = hs_to_alexa_color(hs) + adjusted_hs, color_name = hs_to_alexa_color(hs_color) else: # If a color temperature is being set, it is not possible to also adjust the color. adjusted_hs = None @@ -317,35 +326,36 @@ async def _set_state(self, power_on, brightness=None, mired=None, hs=None): self.async_write_ha_state() async def async_turn_on(self, **kwargs): + """Turn on.""" brightness = None mired = None - hs = None + hs_color = None if COLOR_MODE_ONOFF not in self._color_modes and ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] if COLOR_MODE_COLOR_TEMP in self._color_modes and ATTR_COLOR_TEMP in kwargs: mired = kwargs[ATTR_COLOR_TEMP] if COLOR_MODE_HS in self._color_modes and ATTR_HS_COLOR in kwargs: - hs = kwargs[ATTR_HS_COLOR] - await self._set_state(True, brightness, mired, hs) + hs_color = kwargs[ATTR_HS_COLOR] + await self._set_state(True, brightness, mired, hs_color) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): # pylint:disable=unused-argument + """Turn off.""" await self._set_state(False) -def mired_to_alexa(mired: Optional[float]) -> Tuple[Optional[float], Optional[Text]]: +def mired_to_alexa(mired: Optional[float]) -> tuple[Optional[float], Optional[str]]: """Convert a given color temperature in mired to the closest available value that Alexa has support for.""" if mired is None: return None, None - elif mired <= 162.5: + if mired <= 162.5: return 143, "cool_white" - elif mired <= 216: + if mired <= 216: return 182, "daylight_white" - elif mired <= 310: + if mired <= 310: return 250, "white" - elif mired <= 412: + if mired <= 412: return 370, "soft_white" - else: - return 454, "warm_white" + return 454, "warm_white" def alexa_kelvin_to_mired(kelvin: float) -> float: @@ -354,11 +364,13 @@ def alexa_kelvin_to_mired(kelvin: float) -> float: return mired_to_alexa(raw_mired)[0] -def ha_brightness_to_alexa(ha: Optional[float]) -> Optional[float]: - return (ha / 255 * 100) if ha is not None else None +def ha_brightness_to_alexa(ha_brightness: Optional[float]) -> Optional[float]: + """Convert HA brightness to alexa brightness.""" + return (ha_brightness / 255 * 100) if ha_brightness is not None else None def alexa_brightness_to_ha(alexa: Optional[float]) -> Optional[float]: + """Convert Alexa brightness to HA brightness.""" return (alexa / 100 * 255) if alexa is not None else None @@ -508,8 +520,9 @@ def alexa_brightness_to_ha(alexa: Optional[float]) -> Optional[float]: } -def red_mean(color1: Tuple[int, int, int], color2: Tuple[int, int, int]) -> float: +def red_mean(color1: tuple[int, int, int], color2: tuple[int, int, int]) -> float: """Get an approximate 'distance' between two colors using red mean. + Wikipedia says this method is "one of the better low-cost approximations". """ r_avg = (color2[0] + color1[0]) / 2 @@ -522,14 +535,14 @@ def red_mean(color1: Tuple[int, int, int], color2: Tuple[int, int, int]) -> floa return sqrt(r_term + g_term + b_term) -def alexa_color_name_to_rgb(color_name: Text) -> Tuple[int, int, int]: - """Convert an alexa color name into RGB""" +def alexa_color_name_to_rgb(color_name: str) -> tuple[int, int, int]: + """Convert an alexa color name into RGB.""" return color_name_to_rgb(color_name.replace("_", "")) def rgb_to_alexa_color( - rgb: Tuple[int, int, int] -) -> Tuple[Optional[Tuple[float, float]], Optional[Text]]: + rgb: tuple[int, int, int] +) -> tuple[Optional[tuple[float, float]], Optional[str]]: """Convert a given RGB value into the closest Alexa color.""" (name, alexa_rgb) = min( ALEXA_COLORS.items(), @@ -540,18 +553,18 @@ def rgb_to_alexa_color( def hs_to_alexa_color( - hs: Optional[Tuple[float, float]] -) -> Tuple[Optional[Tuple[float, float]], Optional[Text]]: + hs_color: Optional[tuple[float, float]] +) -> tuple[Optional[tuple[float, float]], Optional[str]]: """Convert a given hue/saturation value into the closest Alexa color.""" - if hs is None: + if hs_color is None: return None, None - hue, saturation = hs + hue, saturation = hs_color return rgb_to_alexa_color(color_hs_to_RGB(hue, saturation)) def hsb_to_alexa_color( - hsb: Optional[Tuple[float, float, float]] -) -> Tuple[Optional[Tuple[float, float]], Optional[Text]]: + hsb: Optional[tuple[float, float, float]] +) -> tuple[Optional[tuple[float, float]], Optional[str]]: """Convert a given hue/saturation/brightness value into the closest Alexa color.""" if hsb is None: return None, None diff --git a/custom_components/alexa_media/manifest.json b/custom_components/alexa_media/manifest.json index c506a1db7..39a354c42 100644 --- a/custom_components/alexa_media/manifest.json +++ b/custom_components/alexa_media/manifest.json @@ -1,12 +1,13 @@ { "domain": "alexa_media", "name": "Alexa Media Player", - "version": "3.10.8", + "codeowners": ["@alandtse", "@keatontaylor"], "config_flow": true, + "dependencies": ["persistent_notification", "http"], "documentation": "https://github.com/custom-components/alexa_media_player/wiki", + "iot_class": "cloud_polling", "issue_tracker": "https://github.com/custom-components/alexa_media_player/issues", - "dependencies": ["persistent_notification", "http"], - "codeowners": ["@keatontaylor", "@alandtse"], - "requirements": ["alexapy==1.25.1", "packaging~=20.3", "wrapt~=1.12.1"], - "iot_class": "cloud_polling" + "loggers": ["alexapy", "authcaptureproxy"], + "requirements": ["alexapy==1.27.4", "packaging>=20.3", "wrapt>=1.12.1"], + "version": "4.6.5" } diff --git a/custom_components/alexa_media/media_player.py b/custom_components/alexa_media/media_player.py index 3b2e78ab8..68f047aa3 100644 --- a/custom_components/alexa_media/media_player.py +++ b/custom_components/alexa_media/media_player.py @@ -8,11 +8,18 @@ """ import asyncio import logging +import os import re -from typing import List, Optional, Text # noqa pylint: disable=unused-import +import subprocess +from typing import List, Optional +import urllib.request -from alexapy import AlexaAPI from homeassistant import util +from homeassistant.components import media_source +from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, + async_process_play_media_url, +) from homeassistant.components.media_player.const import ( MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, @@ -38,6 +45,7 @@ STATE_STANDBY, STATE_UNAVAILABLE, ) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -45,8 +53,10 @@ from homeassistant.util import slugify from . import ( + CONF_PUBLIC_URL, CONF_QUEUE_DELAY, DATA_ALEXAMEDIA, + DEFAULT_PUBLIC_URL, DEFAULT_QUEUE_DELAY, DOMAIN as ALEXA_DOMAIN, hide_email, @@ -58,6 +68,7 @@ MIN_TIME_BETWEEN_FORCED_SCANS, MIN_TIME_BETWEEN_SCANS, PLAY_SCAN_INTERVAL, + UPLOAD_PATH, ) from .helpers import _catch_login_errors, add_devices @@ -88,12 +99,38 @@ DEPENDENCIES = [ALEXA_DOMAIN] +async def create_www_directory(hass: HomeAssistant): + """Create www directory.""" + paths = [ + hass.config.path("www"), # http://homeassistant.local:8123/local + hass.config.path( + UPLOAD_PATH + ), # http://homeassistant.local:8123/local/alexa_tts + ] + + def mkdir() -> None: + """Create a directory.""" + for path in paths: + if not os.path.exists(path): + _LOGGER.debug("Creating directory: %s", path) + os.makedirs(path, exist_ok=True) + + await hass.async_add_executor_job(mkdir) + + # @retry_async(limit=5, delay=2, catch_exceptions=True) async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): - # pylint: disable=unused-argument """Set up the Alexa media player platform.""" + await create_www_directory(hass) + devices = [] # type: List[AlexaClient] - account = config[CONF_EMAIL] if config else discovery_info["config"][CONF_EMAIL] + account = None + if config: + account = config.get(CONF_EMAIL) + if account is None and discovery_info: + account = discovery_info.get("config", {}).get(CONF_EMAIL) + if account is None: + raise ConfigEntryNotReady account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] entry_setup = len(account_dict["entities"]["media_player"]) alexa_client = None @@ -188,7 +225,6 @@ class AlexaClient(MediaPlayerDevice, AlexaMedia): """Representation of a Alexa device.""" def __init__(self, device, login, second_account_index=0): - # pylint: disable=unused-argument """Initialize the Alexa device.""" super().__init__(self, login) @@ -277,9 +313,13 @@ async def async_will_remove_from_hass(self): "coordinator" ) if coordinator: - coordinator.async_remove_listener(self.update) + try: + coordinator.async_remove_listener(self.update) + except AttributeError: + pass # ignore missing listener async def _handle_event(self, event): + # pylint: disable=too-many-branches,too-many-statements """Handle events. This will update last_called and player_state events. @@ -375,7 +415,6 @@ async def _refresh_if_no_audiopush(already_refreshed=False): and self._last_called_timestamp != event["last_called_change"]["timestamp"] ): - _LOGGER.debug( "%s: %s is last_called: %s", hide_email(self._login.email), @@ -393,7 +432,7 @@ async def _refresh_if_no_audiopush(already_refreshed=False): if self.hass and self.async_schedule_update_ha_state: email = self._login.email force_refresh = not ( - self.hass.data[DATA_ALEXAMEDIA]["accounts"][email]["websocket"] + self.hass.data[DATA_ALEXAMEDIA]["accounts"][email]["http2"] ) self.async_schedule_update_ha_state(force_refresh=force_refresh) elif "bluetooth_change" in event: @@ -517,6 +556,7 @@ def _set_authentication_details(self, auth): @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) @_catch_login_errors async def refresh(self, device=None, skip_api: bool = False): + # pylint: disable=too-many-branches,too-many-statements """Refresh device data. This is a per device refresh and for many Alexa devices can result in @@ -591,7 +631,7 @@ async def refresh(self, device=None, skip_api: bool = False): if playing_parents: if len(playing_parents) > 1: _LOGGER.warning( - "Found multiple playing parents " "please file an issue" + "Found multiple playing parents please file an issue" ) parent = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ self._login.email @@ -741,7 +781,7 @@ async def async_select_source(self, source): await self.alexa_api.set_bluetooth(devices["address"]) self._source = source if not ( - self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["websocket"] + self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["http2"] ): await self.async_update() @@ -908,15 +948,15 @@ async def async_update(self): await self.refresh( # pylint: disable=unexpected-keyword-arg device, no_throttle=True ) - websocket_enabled = ( - self.hass.data[DATA_ALEXAMEDIA]["accounts"].get(email, {}).get("websocket") + push_enabled = ( + self.hass.data[DATA_ALEXAMEDIA]["accounts"].get(email, {}).get("http2") ) if ( self.state in [STATE_PLAYING] and # only enable polling if websocket not connected ( - not websocket_enabled + not push_enabled or not seen_commands or not ( "PUSH_AUDIO_PLAYER_STATE" in seen_commands @@ -941,11 +981,11 @@ async def async_update(self): async_call_later( self.hass, PLAY_SCAN_INTERVAL, - lambda _: self.async_schedule_update_ha_state(force_refresh=True), + self.async_schedule_update_ha_state, ) elif self._should_poll: # Not playing, one last poll self._should_poll = False - if not websocket_enabled: + if not push_enabled: _LOGGER.debug( "%s: Disabling polling and scheduling last update in" " 300 seconds for %s", @@ -955,7 +995,7 @@ async def async_update(self): async_call_later( self.hass, 300, - lambda _: self.async_schedule_update_ha_state(force_refresh=True), + self.async_schedule_update_ha_state, ) else: _LOGGER.debug( @@ -999,7 +1039,7 @@ def media_position_updated_at(self): return self._last_update @property - def media_image_url(self) -> Optional[Text]: + def media_image_url(self) -> Optional[str]: """Return the image URL of current playing media.""" if self._media_image_url: return re.sub("\\(", "%28", re.sub("\\)", "%29", self._media_image_url)) @@ -1078,7 +1118,7 @@ async def async_set_volume_level(self, volume): await self.alexa_api.set_volume(volume) self._media_vol_level = volume if not ( - self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["websocket"] + self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["http2"] ): await self.async_update() @@ -1126,7 +1166,7 @@ async def async_mute_volume(self, mute): else: await self.alexa_api.set_volume(50) if not ( - self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["websocket"] + self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["http2"] ): await self.async_update() @@ -1143,7 +1183,7 @@ async def async_media_play(self): else: await self.alexa_api.play() if not ( - self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["websocket"] + self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["http2"] ): await self.async_update() @@ -1160,7 +1200,7 @@ async def async_media_pause(self): else: await self.alexa_api.pause() if not ( - self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["websocket"] + self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["http2"] ): await self.async_update() @@ -1189,7 +1229,7 @@ async def async_media_stop(self): ][CONF_QUEUE_DELAY], ) if not ( - self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["websocket"] + self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["http2"] ): await self.async_update() @@ -1227,7 +1267,7 @@ async def async_media_next_track(self): else: await self.alexa_api.next() if not ( - self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["websocket"] + self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["http2"] ): await self.async_update() @@ -1244,7 +1284,7 @@ async def async_media_previous_track(self): else: await self.alexa_api.previous() if not ( - self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["websocket"] + self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["http2"] ): await self.async_update() @@ -1307,25 +1347,91 @@ async def async_send_dropin_notification(self, message, **kwargs): message, customer_id=self._customer_id, **kwargs ) + @_catch_login_errors + async def async_play_tts_cloud_say(self, public_url, media_id, **kwargs): + file_name = media_id + if media_source.is_media_source_id(media_id): + media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) + file_name = media.url[media.url.rindex("/") : media.url.rindex(".")] + media_id = async_process_play_media_url(self.hass, media.url) + + if kwargs.get(ATTR_MEDIA_ANNOUNCE): + input_file_path = self.hass.config.path( + f"{UPLOAD_PATH}{file_name}_input.mp3" + ) + output_file_name = f"{file_name}_output.mp3" + output_file_path = self.hass.config.path(f"{UPLOAD_PATH}{output_file_name}") + + # file might already exist -> the same tts is cached from previous calls + if not os.path.exists(output_file_path): + await self.hass.async_add_executor_job( + urllib.request.urlretrieve, media_id, input_file_path + ) + command = [ + "ffmpeg", + "-i", + input_file_path, + "-ac", + "2", + "-codec:a", + "libmp3lame", + "-b:a", + "48k", + "-ar", + "24000", + output_file_path, + ] + if subprocess.run(command, check=True).returncode != 0: + _LOGGER.error( + "%s: %s:ffmpeg command FAILED converting %s to %s", + hide_email(self._login.email), + self, + input_file_path, + output_file_path, + ) + + _LOGGER.debug( + "%s: %s:Playing %slocal/alexa_tts%s", + hide_email(self._login.email), + self, + public_url, + output_file_name, + ) + await self.async_send_tts( + f"