Merge branch 'dev' into fuelcell

This commit is contained in:
Christopher Fenner
2023-12-11 08:46:29 +01:00
committed by GitHub
974 changed files with 24572 additions and 8651 deletions

View File

@@ -404,6 +404,9 @@ omit =
homeassistant/components/fjaraskupan/sensor.py homeassistant/components/fjaraskupan/sensor.py
homeassistant/components/fleetgo/device_tracker.py homeassistant/components/fleetgo/device_tracker.py
homeassistant/components/flexit/climate.py homeassistant/components/flexit/climate.py
homeassistant/components/flexit_bacnet/__init__.py
homeassistant/components/flexit_bacnet/const.py
homeassistant/components/flexit_bacnet/climate.py
homeassistant/components/flic/binary_sensor.py homeassistant/components/flic/binary_sensor.py
homeassistant/components/flick_electric/__init__.py homeassistant/components/flick_electric/__init__.py
homeassistant/components/flick_electric/sensor.py homeassistant/components/flick_electric/sensor.py
@@ -633,8 +636,6 @@ omit =
homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/browse_media.py
homeassistant/components/kodi/media_player.py homeassistant/components/kodi/media_player.py
homeassistant/components/kodi/notify.py homeassistant/components/kodi/notify.py
homeassistant/components/komfovent/__init__.py
homeassistant/components/komfovent/climate.py
homeassistant/components/konnected/__init__.py homeassistant/components/konnected/__init__.py
homeassistant/components/konnected/panel.py homeassistant/components/konnected/panel.py
homeassistant/components/konnected/switch.py homeassistant/components/konnected/switch.py
@@ -902,6 +903,9 @@ omit =
homeassistant/components/opple/light.py homeassistant/components/opple/light.py
homeassistant/components/oru/* homeassistant/components/oru/*
homeassistant/components/orvibo/switch.py homeassistant/components/orvibo/switch.py
homeassistant/components/osoenergy/__init__.py
homeassistant/components/osoenergy/const.py
homeassistant/components/osoenergy/water_heater.py
homeassistant/components/osramlightify/light.py homeassistant/components/osramlightify/light.py
homeassistant/components/otp/sensor.py homeassistant/components/otp/sensor.py
homeassistant/components/overkiz/__init__.py homeassistant/components/overkiz/__init__.py

View File

@@ -29,7 +29,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@@ -59,7 +59,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@@ -124,7 +124,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}

View File

@@ -36,7 +36,7 @@ env:
CACHE_VERSION: 5 CACHE_VERSION: 5
PIP_CACHE_VERSION: 4 PIP_CACHE_VERSION: 4
MYPY_CACHE_VERSION: 6 MYPY_CACHE_VERSION: 6
HA_SHORT_VERSION: "2023.12" HA_SHORT_VERSION: "2024.1"
DEFAULT_PYTHON: "3.11" DEFAULT_PYTHON: "3.11"
ALL_PYTHON_VERSIONS: "['3.11', '3.12']" ALL_PYTHON_VERSIONS: "['3.11', '3.12']"
# 10.3 is the oldest supported version # 10.3 is the oldest supported version
@@ -225,7 +225,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@@ -269,7 +269,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@@ -309,7 +309,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@@ -348,7 +348,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@@ -443,7 +443,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@@ -511,7 +511,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@@ -543,7 +543,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@@ -576,7 +576,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@@ -620,7 +620,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@@ -702,7 +702,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@@ -854,7 +854,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@@ -978,7 +978,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true

View File

@@ -29,11 +29,11 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2.22.8 uses: github/codeql-action/init@v2.22.9
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2.22.8 uses: github/codeql-action/analyze@v2.22.9
with: with:
category: "/language:python" category: "/language:python"

View File

@@ -11,16 +11,16 @@ jobs:
if: github.repository_owner == 'home-assistant' if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# The 90 day stale policy for PRs # The 60 day stale policy for PRs
# Used for: # Used for:
# - PRs # - PRs
# - No PRs marked as no-stale # - No PRs marked as no-stale
# - No issues (-1) # - No issues (-1)
- name: 90 days stale PRs policy - name: 60 days stale PRs policy
uses: actions/stale@v8.0.0 uses: actions/stale@v9.0.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90 days-before-stale: 60
days-before-close: 7 days-before-close: 7
days-before-issue-stale: -1 days-before-issue-stale: -1
days-before-issue-close: -1 days-before-issue-close: -1
@@ -33,7 +33,11 @@ jobs:
pull request has been automatically marked as stale because of that pull request has been automatically marked as stale because of that
and will be closed if no further activity occurs within 7 days. and will be closed if no further activity occurs within 7 days.
Thank you for your contributions. If you are the author of this PR, please leave a comment if you want
to keep it open. Also, please rebase your PR onto the latest dev
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
# Generate a token for the GitHub App, we use this method to avoid # Generate a token for the GitHub App, we use this method to avoid
# hitting API limits for our GitHub actions + have a higher rate limit. # hitting API limits for our GitHub actions + have a higher rate limit.
@@ -53,7 +57,7 @@ jobs:
# - No issues marked as no-stale or help-wanted # - No issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: 90 days stale issues - name: 90 days stale issues
uses: actions/stale@v8.0.0 uses: actions/stale@v9.0.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90 days-before-stale: 90
@@ -83,7 +87,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted # - No Issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: Needs more information stale issues policy - name: Needs more information stale issues policy
uses: actions/stale@v8.0.0 uses: actions/stale@v9.0.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information" only-labels: "needs-more-information"

View File

@@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1 uses: actions/setup-python@v5.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}

View File

@@ -120,6 +120,7 @@ homeassistant.components.energy.*
homeassistant.components.esphome.* homeassistant.components.esphome.*
homeassistant.components.event.* homeassistant.components.event.*
homeassistant.components.evil_genius_labs.* homeassistant.components.evil_genius_labs.*
homeassistant.components.faa_delays.*
homeassistant.components.fan.* homeassistant.components.fan.*
homeassistant.components.fastdotcom.* homeassistant.components.fastdotcom.*
homeassistant.components.feedreader.* homeassistant.components.feedreader.*
@@ -127,6 +128,7 @@ homeassistant.components.file_upload.*
homeassistant.components.filesize.* homeassistant.components.filesize.*
homeassistant.components.filter.* homeassistant.components.filter.*
homeassistant.components.fitbit.* homeassistant.components.fitbit.*
homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.* homeassistant.components.flux_led.*
homeassistant.components.forecast_solar.* homeassistant.components.forecast_solar.*
homeassistant.components.fritz.* homeassistant.components.fritz.*
@@ -150,6 +152,7 @@ homeassistant.components.hardkernel.*
homeassistant.components.hardware.* homeassistant.components.hardware.*
homeassistant.components.here_travel_time.* homeassistant.components.here_travel_time.*
homeassistant.components.history.* homeassistant.components.history.*
homeassistant.components.holiday.*
homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.exposed_entities
homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant.triggers.event
homeassistant.components.homeassistant_alerts.* homeassistant.components.homeassistant_alerts.*
@@ -264,6 +267,7 @@ homeassistant.components.proximity.*
homeassistant.components.prusalink.* homeassistant.components.prusalink.*
homeassistant.components.pure_energie.* homeassistant.components.pure_energie.*
homeassistant.components.purpleair.* homeassistant.components.purpleair.*
homeassistant.components.pushbullet.*
homeassistant.components.pvoutput.* homeassistant.components.pvoutput.*
homeassistant.components.qnap_qsw.* homeassistant.components.qnap_qsw.*
homeassistant.components.radarr.* homeassistant.components.radarr.*
@@ -313,6 +317,7 @@ homeassistant.components.statistics.*
homeassistant.components.steamist.* homeassistant.components.steamist.*
homeassistant.components.stookalert.* homeassistant.components.stookalert.*
homeassistant.components.stream.* homeassistant.components.stream.*
homeassistant.components.streamlabswater.*
homeassistant.components.sun.* homeassistant.components.sun.*
homeassistant.components.surepetcare.* homeassistant.components.surepetcare.*
homeassistant.components.switch.* homeassistant.components.switch.*

View File

@@ -86,6 +86,8 @@ build.json @home-assistant/supervisor
/tests/components/anova/ @Lash-L /tests/components/anova/ @Lash-L
/homeassistant/components/anthemav/ @hyralex /homeassistant/components/anthemav/ @hyralex
/tests/components/anthemav/ @hyralex /tests/components/anthemav/ @hyralex
/homeassistant/components/aosmith/ @bdr99
/tests/components/aosmith/ @bdr99
/homeassistant/components/apache_kafka/ @bachya /homeassistant/components/apache_kafka/ @bachya
/tests/components/apache_kafka/ @bachya /tests/components/apache_kafka/ @bachya
/homeassistant/components/apcupsd/ @yuxincs /homeassistant/components/apcupsd/ @yuxincs
@@ -205,8 +207,8 @@ build.json @home-assistant/supervisor
/tests/components/cloud/ @home-assistant/cloud /tests/components/cloud/ @home-assistant/cloud
/homeassistant/components/cloudflare/ @ludeeus @ctalkington /homeassistant/components/cloudflare/ @ludeeus @ctalkington
/tests/components/cloudflare/ @ludeeus @ctalkington /tests/components/cloudflare/ @ludeeus @ctalkington
/homeassistant/components/co2signal/ @jpbede /homeassistant/components/co2signal/ @jpbede @VIKTORVAV99
/tests/components/co2signal/ @jpbede /tests/components/co2signal/ @jpbede @VIKTORVAV99
/homeassistant/components/coinbase/ @tombrien /homeassistant/components/coinbase/ @tombrien
/tests/components/coinbase/ @tombrien /tests/components/coinbase/ @tombrien
/homeassistant/components/color_extractor/ @GenericStudent /homeassistant/components/color_extractor/ @GenericStudent
@@ -395,6 +397,8 @@ build.json @home-assistant/supervisor
/tests/components/fivem/ @Sander0542 /tests/components/fivem/ @Sander0542
/homeassistant/components/fjaraskupan/ @elupus /homeassistant/components/fjaraskupan/ @elupus
/tests/components/fjaraskupan/ @elupus /tests/components/fjaraskupan/ @elupus
/homeassistant/components/flexit_bacnet/ @lellky @piotrbulinski
/tests/components/flexit_bacnet/ @lellky @piotrbulinski
/homeassistant/components/flick_electric/ @ZephireNZ /homeassistant/components/flick_electric/ @ZephireNZ
/tests/components/flick_electric/ @ZephireNZ /tests/components/flick_electric/ @ZephireNZ
/homeassistant/components/flipr/ @cnico /homeassistant/components/flipr/ @cnico
@@ -520,6 +524,8 @@ build.json @home-assistant/supervisor
/tests/components/hive/ @Rendili @KJonline /tests/components/hive/ @Rendili @KJonline
/homeassistant/components/hlk_sw16/ @jameshilliard /homeassistant/components/hlk_sw16/ @jameshilliard
/tests/components/hlk_sw16/ @jameshilliard /tests/components/hlk_sw16/ @jameshilliard
/homeassistant/components/holiday/ @jrieger
/tests/components/holiday/ @jrieger
/homeassistant/components/home_connect/ @DavidMStraub /homeassistant/components/home_connect/ @DavidMStraub
/tests/components/home_connect/ @DavidMStraub /tests/components/home_connect/ @DavidMStraub
/homeassistant/components/home_plus_control/ @chemaaa /homeassistant/components/home_plus_control/ @chemaaa
@@ -663,8 +669,6 @@ build.json @home-assistant/supervisor
/tests/components/knx/ @Julius2342 @farmio @marvin-w /tests/components/knx/ @Julius2342 @farmio @marvin-w
/homeassistant/components/kodi/ @OnFreund /homeassistant/components/kodi/ @OnFreund
/tests/components/kodi/ @OnFreund /tests/components/kodi/ @OnFreund
/homeassistant/components/komfovent/ @ProstoSanja
/tests/components/komfovent/ @ProstoSanja
/homeassistant/components/konnected/ @heythisisnate /homeassistant/components/konnected/ @heythisisnate
/tests/components/konnected/ @heythisisnate /tests/components/konnected/ @heythisisnate
/homeassistant/components/kostal_plenticore/ @stegm /homeassistant/components/kostal_plenticore/ @stegm
@@ -928,6 +932,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/oralb/ @bdraco @Lash-L /homeassistant/components/oralb/ @bdraco @Lash-L
/tests/components/oralb/ @bdraco @Lash-L /tests/components/oralb/ @bdraco @Lash-L
/homeassistant/components/oru/ @bvlaicu /homeassistant/components/oru/ @bvlaicu
/homeassistant/components/osoenergy/ @osohotwateriot
/tests/components/osoenergy/ @osohotwateriot
/homeassistant/components/otbr/ @home-assistant/core /homeassistant/components/otbr/ @home-assistant/core
/tests/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core
/homeassistant/components/ourgroceries/ @OnFreund /homeassistant/components/ourgroceries/ @OnFreund
@@ -1247,6 +1253,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/suez_water/ @ooii /homeassistant/components/suez_water/ @ooii
/homeassistant/components/sun/ @Swamp-Ig /homeassistant/components/sun/ @Swamp-Ig
/tests/components/sun/ @Swamp-Ig /tests/components/sun/ @Swamp-Ig
/homeassistant/components/sunweg/ @rokam
/tests/components/sunweg/ @rokam
/homeassistant/components/supla/ @mwegrzynek /homeassistant/components/supla/ @mwegrzynek
/homeassistant/components/surepetcare/ @benleb @danielhiversen /homeassistant/components/surepetcare/ @benleb @danielhiversen
/tests/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen
@@ -1295,6 +1303,8 @@ build.json @home-assistant/supervisor
/tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core /tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/homeassistant/components/tesla_wall_connector/ @einarhauks /homeassistant/components/tesla_wall_connector/ @einarhauks
/tests/components/tesla_wall_connector/ @einarhauks /tests/components/tesla_wall_connector/ @einarhauks
/homeassistant/components/tessie/ @Bre77
/tests/components/tessie/ @Bre77
/homeassistant/components/text/ @home-assistant/core /homeassistant/components/text/ @home-assistant/core
/tests/components/text/ @home-assistant/core /tests/components/text/ @home-assistant/core
/homeassistant/components/tfiac/ @fredrike @mellado /homeassistant/components/tfiac/ @fredrike @mellado
@@ -1398,8 +1408,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/versasense/ @imstevenxyz
/homeassistant/components/version/ @ludeeus /homeassistant/components/version/ @ludeeus
/tests/components/version/ @ludeeus /tests/components/version/ @ludeeus
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja
/homeassistant/components/vicare/ @CFenner /homeassistant/components/vicare/ @CFenner
/tests/components/vicare/ @CFenner /tests/components/vicare/ @CFenner
/homeassistant/components/vilfo/ @ManneW /homeassistant/components/vilfo/ @ManneW

View File

@@ -1,9 +1,12 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM ARG BUILD_FROM
FROM ${BUILD_FROM} FROM ${BUILD_FROM}
# Synchronize with homeassistant/core.py:async_stop # Synchronize with homeassistant/core.py:async_stop
ENV \ ENV \
S6_SERVICES_GRACETIME=220000 S6_SERVICES_GRACETIME=240000
ARG QEMU_CPU ARG QEMU_CPU

View File

@@ -27,6 +27,7 @@ from .const import (
from .exceptions import HomeAssistantError from .exceptions import HomeAssistantError
from .helpers import ( from .helpers import (
area_registry, area_registry,
config_validation as cv,
device_registry, device_registry,
entity, entity,
entity_registry, entity_registry,
@@ -473,7 +474,9 @@ async def async_mount_local_lib_path(config_dir: str) -> str:
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
"""Get domains of components to set up.""" """Get domains of components to set up."""
# Filter out the repeating and common config section [homeassistant] # Filter out the repeating and common config section [homeassistant]
domains = {key.partition(" ")[0] for key in config if key != core.DOMAIN} domains = {
domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN
}
# Add config entry domains # Add config entry domains
if not hass.config.recovery_mode: if not hass.config.recovery_mode:

View File

@@ -0,0 +1,5 @@
{
"domain": "flexit",
"name": "Flexit",
"integrations": ["flexit", "flexit_bacnet"]
}

View File

@@ -6,6 +6,9 @@
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]" "port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The IP address of the Agent DVR server."
} }
} }
}, },

View File

@@ -3,7 +3,6 @@ from typing import Final
DOMAIN: Final = "airq" DOMAIN: Final = "airq"
MANUFACTURER: Final = "CorantGmbH" MANUFACTURER: Final = "CorantGmbH"
TARGET_ROUTE: Final = "average"
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³"
ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³" ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³"
UPDATE_INTERVAL: float = 10.0 UPDATE_INTERVAL: float = 10.0

View File

@@ -13,7 +13,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL from .const import DOMAIN, MANUFACTURER, UPDATE_INTERVAL
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -56,6 +56,4 @@ class AirQCoordinator(DataUpdateCoordinator):
hw_version=info["hw_version"], hw_version=info["hw_version"],
) )
) )
return await self.airq.get_latest_data()
data = await self.airq.get(TARGET_ROUTE)
return self.airq.drop_uncertainties_from_data(data)

View File

@@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioairq"], "loggers": ["aioairq"],
"requirements": ["aioairq==0.2.4"] "requirements": ["aioairq==0.3.1"]
} }

View File

@@ -14,7 +14,7 @@
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
}, },
"data_description": { "data_description": {
"host": "The hostname or IP address of the device running your AirTouch controller." "host": "The hostname or IP address of your AirTouch controller."
} }
} }
} }

View File

@@ -14,7 +14,7 @@
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
}, },
"data_description": { "data_description": {
"ip_address": "The hostname or IP address of the device running your AirVisual Pro." "ip_address": "The hostname or IP address of your AirVisual Pro device."
} }
} }
}, },

View File

@@ -16,7 +16,7 @@
"device_path": "Device Path" "device_path": "Device Path"
}, },
"data_description": { "data_description": {
"host": "The hostname or IP address of the machine connected to the AlarmDecoder device.", "host": "The hostname or IP address of the AlarmDecoder device that is connected to your alarm panel.",
"port": "The port on which AlarmDecoder is accessible (for example, 10000)" "port": "The port on which AlarmDecoder is accessible (for example, 10000)"
} }
} }

View File

@@ -36,6 +36,15 @@ CONF_FLASH_BRIEFINGS = "flash_briefings"
CONF_SMART_HOME = "smart_home" CONF_SMART_HOME = "smart_home"
DEFAULT_LOCALE = "en-US" DEFAULT_LOCALE = "en-US"
# Alexa Smart Home API send events gateway endpoints
# https://developer.amazon.com/en-US/docs/alexa/smarthome/send-events.html#endpoints
VALID_ENDPOINTS = [
"https://api.amazonalexa.com/v3/events",
"https://api.eu.amazonalexa.com/v3/events",
"https://api.fe.amazonalexa.com/v3/events",
]
ALEXA_ENTITY_SCHEMA = vol.Schema( ALEXA_ENTITY_SCHEMA = vol.Schema(
{ {
vol.Optional(CONF_DESCRIPTION): cv.string, vol.Optional(CONF_DESCRIPTION): cv.string,
@@ -46,7 +55,7 @@ ALEXA_ENTITY_SCHEMA = vol.Schema(
SMART_HOME_SCHEMA = vol.Schema( SMART_HOME_SCHEMA = vol.Schema(
{ {
vol.Optional(CONF_ENDPOINT): cv.string, vol.Optional(CONF_ENDPOINT): vol.All(vol.Lower, vol.In(VALID_ENDPOINTS)),
vol.Optional(CONF_CLIENT_ID): cv.string, vol.Optional(CONF_CLIENT_ID): cv.string,
vol.Optional(CONF_CLIENT_SECRET): cv.string, vol.Optional(CONF_CLIENT_SECRET): cv.string,
vol.Optional(CONF_LOCALE, default=DEFAULT_LOCALE): vol.In( vol.Optional(CONF_LOCALE, default=DEFAULT_LOCALE): vol.In(

View File

@@ -1304,13 +1304,14 @@ async def async_api_set_range(
service = None service = None
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
range_value = directive.payload["rangeValue"] range_value = directive.payload["rangeValue"]
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
# Cover Position # Cover Position
if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
range_value = int(range_value) range_value = int(range_value)
if range_value == 0: if supported & cover.CoverEntityFeature.CLOSE and range_value == 0:
service = cover.SERVICE_CLOSE_COVER service = cover.SERVICE_CLOSE_COVER
elif range_value == 100: elif supported & cover.CoverEntityFeature.OPEN and range_value == 100:
service = cover.SERVICE_OPEN_COVER service = cover.SERVICE_OPEN_COVER
else: else:
service = cover.SERVICE_SET_COVER_POSITION service = cover.SERVICE_SET_COVER_POSITION
@@ -1319,9 +1320,9 @@ async def async_api_set_range(
# Cover Tilt # Cover Tilt
elif instance == f"{cover.DOMAIN}.tilt": elif instance == f"{cover.DOMAIN}.tilt":
range_value = int(range_value) range_value = int(range_value)
if range_value == 0: if supported & cover.CoverEntityFeature.CLOSE_TILT and range_value == 0:
service = cover.SERVICE_CLOSE_COVER_TILT service = cover.SERVICE_CLOSE_COVER_TILT
elif range_value == 100: elif supported & cover.CoverEntityFeature.OPEN_TILT and range_value == 100:
service = cover.SERVICE_OPEN_COVER_TILT service = cover.SERVICE_OPEN_COVER_TILT
else: else:
service = cover.SERVICE_SET_COVER_TILT_POSITION service = cover.SERVICE_SET_COVER_TILT_POSITION
@@ -1332,13 +1333,11 @@ async def async_api_set_range(
range_value = int(range_value) range_value = int(range_value)
if range_value == 0: if range_value == 0:
service = fan.SERVICE_TURN_OFF service = fan.SERVICE_TURN_OFF
elif supported & fan.FanEntityFeature.SET_SPEED:
service = fan.SERVICE_SET_PERCENTAGE
data[fan.ATTR_PERCENTAGE] = range_value
else: else:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) service = fan.SERVICE_TURN_ON
if supported and fan.FanEntityFeature.SET_SPEED:
service = fan.SERVICE_SET_PERCENTAGE
data[fan.ATTR_PERCENTAGE] = range_value
else:
service = fan.SERVICE_TURN_ON
# Humidifier target humidity # Humidifier target humidity
elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}": elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}":

View File

@@ -7,6 +7,9 @@
"port": "[%key:common::config_flow::data::port%]", "port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"host": "The IP address of the device running the Android IP Webcam app. The IP address is shown in the app once you start the server."
} }
} }
}, },

View File

@@ -0,0 +1,53 @@
"""The A. O. Smith integration."""
from __future__ import annotations
from dataclasses import dataclass
from py_aosmith import AOSmithAPIClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from .const import DOMAIN
from .coordinator import AOSmithCoordinator
PLATFORMS: list[Platform] = [Platform.WATER_HEATER]
@dataclass
class AOSmithData:
"""Data for the A. O. Smith integration."""
coordinator: AOSmithCoordinator
client: AOSmithAPIClient
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up A. O. Smith from a config entry."""
email = entry.data[CONF_EMAIL]
password = entry.data[CONF_PASSWORD]
session = aiohttp_client.async_get_clientsession(hass)
client = AOSmithAPIClient(email, password, session)
coordinator = AOSmithCoordinator(hass, client)
# Fetch initial data so we have data when entities subscribe
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AOSmithData(
coordinator=coordinator, client=client
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@@ -0,0 +1,111 @@
"""Config flow for A. O. Smith integration."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from py_aosmith import AOSmithAPIClient, AOSmithInvalidCredentialsException
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import aiohttp_client
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for A. O. Smith."""
VERSION = 1
_reauth_email: str | None
def __init__(self):
"""Start the config flow."""
self._reauth_email = None
async def _async_validate_credentials(
self, email: str, password: str
) -> str | None:
"""Validate the credentials. Return an error string, or None if successful."""
session = aiohttp_client.async_get_clientsession(self.hass)
client = AOSmithAPIClient(email, password, session)
try:
await client.get_devices()
except AOSmithInvalidCredentialsException:
return "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
return "unknown"
return None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
unique_id = user_input[CONF_EMAIL].lower()
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
error = await self._async_validate_credentials(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
if error is None:
return self.async_create_entry(
title=user_input[CONF_EMAIL], data=user_input
)
errors["base"] = error
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_EMAIL, default=self._reauth_email): str,
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors,
)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth if the user credentials have changed."""
self._reauth_email = entry_data[CONF_EMAIL]
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle user's reauth credentials."""
errors: dict[str, str] = {}
if user_input is not None and self._reauth_email is not None:
email = self._reauth_email
password = user_input[CONF_PASSWORD]
entry_id = self.context["entry_id"]
if entry := self.hass.config_entries.async_get_entry(entry_id):
error = await self._async_validate_credentials(email, password)
if error is None:
self.hass.config_entries.async_update_entry(
entry,
data=entry.data | user_input,
)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
errors["base"] = error
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
description_placeholders={CONF_EMAIL: self._reauth_email},
errors=errors,
)

View File

@@ -0,0 +1,16 @@
"""Constants for the A. O. Smith integration."""
from datetime import timedelta
DOMAIN = "aosmith"
AOSMITH_MODE_ELECTRIC = "ELECTRIC"
AOSMITH_MODE_HEAT_PUMP = "HEAT_PUMP"
AOSMITH_MODE_HYBRID = "HYBRID"
AOSMITH_MODE_VACATION = "VACATION"
# Update interval to be used for normal background updates.
REGULAR_INTERVAL = timedelta(seconds=30)
# Update interval to be used while a mode or setpoint change is in progress.
FAST_INTERVAL = timedelta(seconds=1)

View File

@@ -0,0 +1,51 @@
"""The data update coordinator for the A. O. Smith integration."""
import logging
from typing import Any
from py_aosmith import (
AOSmithAPIClient,
AOSmithInvalidCredentialsException,
AOSmithUnknownException,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, FAST_INTERVAL, REGULAR_INTERVAL
_LOGGER = logging.getLogger(__name__)
class AOSmithCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
"""Custom data update coordinator for A. O. Smith integration."""
def __init__(self, hass: HomeAssistant, client: AOSmithAPIClient) -> None:
"""Initialize the coordinator."""
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=REGULAR_INTERVAL)
self.client = client
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
"""Fetch latest data from API."""
try:
devices = await self.client.get_devices()
except AOSmithInvalidCredentialsException as err:
raise ConfigEntryAuthFailed from err
except AOSmithUnknownException as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
mode_pending = any(
device.get("data", {}).get("modePending") for device in devices
)
setpoint_pending = any(
device.get("data", {}).get("temperatureSetpointPending")
for device in devices
)
if mode_pending or setpoint_pending:
self.update_interval = FAST_INTERVAL
else:
self.update_interval = REGULAR_INTERVAL
return {device.get("junctionId"): device for device in devices}

View File

@@ -0,0 +1,51 @@
"""The base entity for the A. O. Smith integration."""
from py_aosmith import AOSmithAPIClient
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AOSmithCoordinator
class AOSmithEntity(CoordinatorEntity[AOSmithCoordinator]):
"""Base entity for A. O. Smith."""
_attr_has_entity_name = True
def __init__(self, coordinator: AOSmithCoordinator, junction_id: str) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.junction_id = junction_id
self._attr_device_info = DeviceInfo(
manufacturer="A. O. Smith",
name=self.device.get("name"),
model=self.device.get("model"),
serial_number=self.device.get("serial"),
suggested_area=self.device.get("install", {}).get("location"),
identifiers={(DOMAIN, junction_id)},
sw_version=self.device.get("data", {}).get("firmwareVersion"),
)
@property
def device(self):
"""Shortcut to get the device status from the coordinator data."""
return self.coordinator.data.get(self.junction_id)
@property
def device_data(self):
"""Shortcut to get the device data within the device status."""
device = self.device
return None if device is None else device.get("data", {})
@property
def client(self) -> AOSmithAPIClient:
"""Shortcut to get the API client."""
return self.coordinator.client
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.device_data.get("isOnline") is True

View File

@@ -0,0 +1,10 @@
{
"domain": "aosmith",
"name": "A. O. Smith",
"codeowners": ["@bdr99"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aosmith",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["py-aosmith==1.0.1"]
}

View File

@@ -0,0 +1,28 @@
{
"config": {
"step": {
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"description": "Please enter your A. O. Smith credentials."
},
"reauth_confirm": {
"description": "Please update your password for {email}",
"title": "[%key:common::config_flow::title::reauth%]",
"data": {
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
}
}

View File

@@ -0,0 +1,149 @@
"""The water heater platform for the A. O. Smith integration."""
from typing import Any
from homeassistant.components.water_heater import (
STATE_ECO,
STATE_ELECTRIC,
STATE_HEAT_PUMP,
STATE_OFF,
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AOSmithData
from .const import (
AOSMITH_MODE_ELECTRIC,
AOSMITH_MODE_HEAT_PUMP,
AOSMITH_MODE_HYBRID,
AOSMITH_MODE_VACATION,
DOMAIN,
)
from .coordinator import AOSmithCoordinator
from .entity import AOSmithEntity
MODE_HA_TO_AOSMITH = {
STATE_OFF: AOSMITH_MODE_VACATION,
STATE_ECO: AOSMITH_MODE_HYBRID,
STATE_ELECTRIC: AOSMITH_MODE_ELECTRIC,
STATE_HEAT_PUMP: AOSMITH_MODE_HEAT_PUMP,
}
MODE_AOSMITH_TO_HA = {
AOSMITH_MODE_ELECTRIC: STATE_ELECTRIC,
AOSMITH_MODE_HEAT_PUMP: STATE_HEAT_PUMP,
AOSMITH_MODE_HYBRID: STATE_ECO,
AOSMITH_MODE_VACATION: STATE_OFF,
}
# Operation mode to use when exiting away mode
DEFAULT_OPERATION_MODE = AOSMITH_MODE_HYBRID
DEFAULT_SUPPORT_FLAGS = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up A. O. Smith water heater platform."""
data: AOSmithData = hass.data[DOMAIN][entry.entry_id]
entities = []
for junction_id in data.coordinator.data:
entities.append(AOSmithWaterHeaterEntity(data.coordinator, junction_id))
async_add_entities(entities)
class AOSmithWaterHeaterEntity(AOSmithEntity, WaterHeaterEntity):
"""The water heater entity for the A. O. Smith integration."""
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
_attr_min_temp = 95
def __init__(self, coordinator: AOSmithCoordinator, junction_id: str) -> None:
"""Initialize the entity."""
super().__init__(coordinator, junction_id)
self._attr_unique_id = junction_id
@property
def operation_list(self) -> list[str]:
"""Return the list of supported operation modes."""
op_modes = []
for mode_dict in self.device_data.get("modes", []):
mode_name = mode_dict.get("mode")
ha_mode = MODE_AOSMITH_TO_HA.get(mode_name)
# Filtering out STATE_OFF since it is handled by away mode
if ha_mode is not None and ha_mode != STATE_OFF:
op_modes.append(ha_mode)
return op_modes
@property
def supported_features(self) -> WaterHeaterEntityFeature:
"""Return the list of supported features."""
supports_vacation_mode = any(
mode_dict.get("mode") == AOSMITH_MODE_VACATION
for mode_dict in self.device_data.get("modes", [])
)
if supports_vacation_mode:
return DEFAULT_SUPPORT_FLAGS | WaterHeaterEntityFeature.AWAY_MODE
return DEFAULT_SUPPORT_FLAGS
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
return self.device_data.get("temperatureSetpoint")
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
return self.device_data.get("temperatureSetpointMaximum")
@property
def current_operation(self) -> str:
"""Return the current operation mode."""
return MODE_AOSMITH_TO_HA.get(self.device_data.get("mode"), STATE_OFF)
@property
def is_away_mode_on(self):
"""Return True if away mode is on."""
return self.device_data.get("mode") == AOSMITH_MODE_VACATION
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new target operation mode."""
aosmith_mode = MODE_HA_TO_AOSMITH.get(operation_mode)
if aosmith_mode is not None:
await self.client.update_mode(self.junction_id, aosmith_mode)
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
temperature = kwargs.get("temperature")
await self.client.update_setpoint(self.junction_id, temperature)
await self.coordinator.async_request_refresh()
async def async_turn_away_mode_on(self) -> None:
"""Turn away mode on."""
await self.client.update_mode(self.junction_id, AOSMITH_MODE_VACATION)
await self.coordinator.async_request_refresh()
async def async_turn_away_mode_off(self) -> None:
"""Turn away mode off."""
await self.client.update_mode(self.junction_id, DEFAULT_OPERATION_MODE)
await self.coordinator.async_request_refresh()

View File

@@ -7,8 +7,9 @@ from datetime import timedelta
import logging import logging
from typing import Final from typing import Final
from apcaccess import status import aioapcaccess
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@@ -32,6 +33,8 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]):
updates from the server. updates from the server.
""" """
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: def __init__(self, hass: HomeAssistant, host: str, port: int) -> None:
"""Initialize the data object.""" """Initialize the data object."""
super().__init__( super().__init__(
@@ -70,13 +73,10 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]):
return self.data.get("SERIALNO") return self.data.get("SERIALNO")
@property @property
def device_info(self) -> DeviceInfo | None: def device_info(self) -> DeviceInfo:
"""Return the DeviceInfo of this APC UPS, if serial number is available.""" """Return the DeviceInfo of this APC UPS, if serial number is available."""
if not self.ups_serial_no:
return None
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, self.ups_serial_no)}, identifiers={(DOMAIN, self.ups_serial_no or self.config_entry.entry_id)},
model=self.ups_model, model=self.ups_model,
manufacturer="APC", manufacturer="APC",
name=self.ups_name if self.ups_name else "APC UPS", name=self.ups_name if self.ups_name else "APC UPS",
@@ -90,13 +90,8 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]):
Note that the result dict uses upper case for each resource, where our Note that the result dict uses upper case for each resource, where our
integration uses lower cases as keys internally. integration uses lower cases as keys internally.
""" """
async with asyncio.timeout(10): async with asyncio.timeout(10):
try: try:
raw = await self.hass.async_add_executor_job( return await aioapcaccess.request_status(self._host, self._port)
status.get, self._host, self._port except (OSError, asyncio.IncompleteReadError) as error:
)
result: OrderedDict[str, str] = status.parse(raw)
return result
except OSError as error:
raise UpdateFailed(error) from error raise UpdateFailed(error) from error

View File

@@ -7,5 +7,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["apcaccess"], "loggers": ["apcaccess"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["apcaccess==0.0.13"] "requirements": ["aioapcaccess==0.4.2"]
} }

View File

@@ -41,7 +41,6 @@ from homeassistant.exceptions import (
Unauthorized, Unauthorized,
) )
from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.aiohttp_compat import enable_compression
from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.event import EventStateChangedData
from homeassistant.helpers.json import json_dumps from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.service import async_get_all_descriptions
@@ -218,9 +217,11 @@ class APIStatesView(HomeAssistantView):
if entity_perm(state.entity_id, "read") if entity_perm(state.entity_id, "read")
) )
response = web.Response( response = web.Response(
body=f'[{",".join(states)}]', content_type=CONTENT_TYPE_JSON body=f'[{",".join(states)}]',
content_type=CONTENT_TYPE_JSON,
zlib_executor_size=32768,
) )
enable_compression(response) response.enable_compression()
return response return response
@@ -390,17 +391,14 @@ class APIDomainServicesView(HomeAssistantView):
) )
try: try:
async with timeout(SERVICE_WAIT_TIMEOUT): # shield the service call from cancellation on connection drop
# shield the service call from cancellation on connection drop await shield(
await shield( hass.services.async_call(
hass.services.async_call( domain, service, data, blocking=True, context=context
domain, service, data, blocking=True, context=context
)
) )
)
except (vol.Invalid, ServiceNotFound) as ex: except (vol.Invalid, ServiceNotFound) as ex:
raise HTTPBadRequest() from ex raise HTTPBadRequest() from ex
except TimeoutError:
pass
finally: finally:
cancel_listen() cancel_listen()

View File

@@ -9,7 +9,7 @@ from dataclasses import asdict, dataclass, field
from enum import StrEnum from enum import StrEnum
import logging import logging
from pathlib import Path from pathlib import Path
from queue import Queue from queue import Empty, Queue
from threading import Thread from threading import Thread
import time import time
from typing import TYPE_CHECKING, Any, Final, cast from typing import TYPE_CHECKING, Any, Final, cast
@@ -1010,8 +1010,8 @@ class PipelineRun:
self.tts_engine = engine self.tts_engine = engine
self.tts_options = tts_options self.tts_options = tts_options
async def text_to_speech(self, tts_input: str) -> str: async def text_to_speech(self, tts_input: str) -> None:
"""Run text-to-speech portion of pipeline. Returns URL of TTS audio.""" """Run text-to-speech portion of pipeline."""
self.process_event( self.process_event(
PipelineEvent( PipelineEvent(
PipelineEventType.TTS_START, PipelineEventType.TTS_START,
@@ -1024,43 +1024,40 @@ class PipelineRun:
) )
) )
try: if tts_input := tts_input.strip():
# Synthesize audio and get URL try:
tts_media_id = tts_generate_media_source_id( # Synthesize audio and get URL
self.hass, tts_media_id = tts_generate_media_source_id(
tts_input, self.hass,
engine=self.tts_engine, tts_input,
language=self.pipeline.tts_language, engine=self.tts_engine,
options=self.tts_options, language=self.pipeline.tts_language,
) options=self.tts_options,
tts_media = await media_source.async_resolve_media( )
self.hass, tts_media = await media_source.async_resolve_media(
tts_media_id, self.hass,
None, tts_media_id,
) None,
except Exception as src_error: )
_LOGGER.exception("Unexpected error during text-to-speech") except Exception as src_error:
raise TextToSpeechError( _LOGGER.exception("Unexpected error during text-to-speech")
code="tts-failed", raise TextToSpeechError(
message="Unexpected error during text-to-speech", code="tts-failed",
) from src_error message="Unexpected error during text-to-speech",
) from src_error
_LOGGER.debug("TTS result %s", tts_media) _LOGGER.debug("TTS result %s", tts_media)
tts_output = {
"media_id": tts_media_id,
**asdict(tts_media),
}
else:
tts_output = {}
self.process_event( self.process_event(
PipelineEvent( PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output})
PipelineEventType.TTS_END,
{
"tts_output": {
"media_id": tts_media_id,
**asdict(tts_media),
}
},
)
) )
return tts_media.url
def _capture_chunk(self, audio_bytes: bytes | None) -> None: def _capture_chunk(self, audio_bytes: bytes | None) -> None:
"""Forward audio chunk to various capturing mechanisms.""" """Forward audio chunk to various capturing mechanisms."""
if self.debug_recording_queue is not None: if self.debug_recording_queue is not None:
@@ -1247,6 +1244,8 @@ def _pipeline_debug_recording_thread_proc(
# Chunk of 16-bit mono audio at 16Khz # Chunk of 16-bit mono audio at 16Khz
if wav_writer is not None: if wav_writer is not None:
wav_writer.writeframes(message) wav_writer.writeframes(message)
except Empty:
pass # occurs when pipeline has unexpected error
except Exception: # pylint: disable=broad-exception-caught except Exception: # pylint: disable=broad-exception-caught
_LOGGER.exception("Unexpected error in debug recording thread") _LOGGER.exception("Unexpected error in debug recording thread")
finally: finally:

View File

@@ -55,7 +55,9 @@ _LOGGER = logging.getLogger(__name__)
_AsusWrtBridgeT = TypeVar("_AsusWrtBridgeT", bound="AsusWrtBridge") _AsusWrtBridgeT = TypeVar("_AsusWrtBridgeT", bound="AsusWrtBridge")
_FuncType = Callable[[_AsusWrtBridgeT], Awaitable[list[Any] | dict[str, Any]]] _FuncType = Callable[
[_AsusWrtBridgeT], Awaitable[list[Any] | tuple[Any] | dict[str, Any]]
]
_ReturnFuncType = Callable[[_AsusWrtBridgeT], Coroutine[Any, Any, dict[str, Any]]] _ReturnFuncType = Callable[[_AsusWrtBridgeT], Coroutine[Any, Any, dict[str, Any]]]
@@ -81,7 +83,7 @@ def handle_errors_and_zip(
if isinstance(data, dict): if isinstance(data, dict):
return dict(zip(keys, list(data.values()))) return dict(zip(keys, list(data.values())))
if not isinstance(data, list): if not isinstance(data, (list, tuple)):
raise UpdateFailed("Received invalid data type") raise UpdateFailed("Received invalid data type")
return dict(zip(keys, data)) return dict(zip(keys, data))

View File

@@ -2,7 +2,6 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "AsusWRT",
"description": "Set required parameter to connect to your router", "description": "Set required parameter to connect to your router",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
@@ -11,10 +10,12 @@
"ssh_key": "Path to your SSH key file (instead of password)", "ssh_key": "Path to your SSH key file (instead of password)",
"protocol": "Communication protocol to use", "protocol": "Communication protocol to use",
"port": "Port (leave empty for protocol default)" "port": "Port (leave empty for protocol default)"
},
"data_description": {
"host": "The hostname or IP address of your ASUSWRT router."
} }
}, },
"legacy": { "legacy": {
"title": "AsusWRT",
"description": "Set required parameters to connect to your router", "description": "Set required parameters to connect to your router",
"data": { "data": {
"mode": "Router operating mode" "mode": "Router operating mode"
@@ -37,7 +38,6 @@
"options": { "options": {
"step": { "step": {
"init": { "init": {
"title": "AsusWRT Options",
"data": { "data": {
"consider_home": "Seconds to wait before considering a device away", "consider_home": "Seconds to wait before considering a device away",
"track_unknown": "Track unknown / unnamed devices", "track_unknown": "Track unknown / unnamed devices",

View File

@@ -2,10 +2,13 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Connect to the device", "description": "Connect to the device",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]" "port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of the Atag device."
} }
} }
}, },

View File

@@ -76,6 +76,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]):
power_watts = self.client.measure(3, True) power_watts = self.client.measure(3, True)
temperature_c = self.client.measure(21) temperature_c = self.client.measure(21)
energy_wh = self.client.cumulated_energy(5) energy_wh = self.client.cumulated_energy(5)
[alarm, *_] = self.client.alarms()
except AuroraTimeoutError: except AuroraTimeoutError:
self.available = False self.available = False
_LOGGER.debug("No response from inverter (could be dark)") _LOGGER.debug("No response from inverter (could be dark)")
@@ -86,6 +87,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]):
data["instantaneouspower"] = round(power_watts, 1) data["instantaneouspower"] = round(power_watts, 1)
data["temp"] = round(temperature_c, 1) data["temp"] = round(temperature_c, 1)
data["totalenergy"] = round(energy_wh / 1000, 2) data["totalenergy"] = round(energy_wh / 1000, 2)
data["alarm"] = alarm
self.available = True self.available = True
finally: finally:

View File

@@ -5,6 +5,8 @@ from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
from aurorapy.mapping import Mapping as AuroraMapping
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
@@ -36,8 +38,16 @@ from .const import (
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ALARM_STATES = list(AuroraMapping.ALARM_STATES.values())
SENSOR_TYPES = [ SENSOR_TYPES = [
SensorEntityDescription(
key="alarm",
device_class=SensorDeviceClass.ENUM,
options=ALARM_STATES,
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="alarm",
),
SensorEntityDescription( SensorEntityDescription(
key="instantaneouspower", key="instantaneouspower",
device_class=SensorDeviceClass.POWER, device_class=SensorDeviceClass.POWER,

View File

@@ -21,11 +21,14 @@
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"alarm": {
"name": "Alarm status"
},
"power_output": { "power_output": {
"name": "Power Output" "name": "Power output"
}, },
"total_energy": { "total_energy": {
"name": "Total Energy" "name": "Total energy"
} }
} }
} }

View File

@@ -11,7 +11,7 @@ from voluptuous.humanize import humanize_error
from homeassistant.components import blueprint from homeassistant.components import blueprint
from homeassistant.components.trace import TRACE_CONFIG_SCHEMA from homeassistant.components.trace import TRACE_CONFIG_SCHEMA
from homeassistant.config import config_without_domain from homeassistant.config import config_per_platform, config_without_domain
from homeassistant.const import ( from homeassistant.const import (
CONF_ALIAS, CONF_ALIAS,
CONF_CONDITION, CONF_CONDITION,
@@ -21,7 +21,7 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_per_platform, config_validation as cv, script from homeassistant.helpers import config_validation as cv, script
from homeassistant.helpers.condition import async_validate_conditions_config from homeassistant.helpers.condition import async_validate_conditions_config
from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.helpers.trigger import async_validate_trigger_config
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType

View File

@@ -3,12 +3,16 @@
"flow_title": "{name} ({host})", "flow_title": "{name} ({host})",
"step": { "step": {
"user": { "user": {
"title": "Set up Axis device", "description": "Set up an Axis device",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]" "port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of the Axis device.",
"username": "The user name you set up on your Axis device. It is recommended to create a user specifically for Home Assistant."
} }
} }
}, },

View File

@@ -93,8 +93,6 @@ class BAFFan(BAFEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan.""" """Set the preset mode of the fan."""
if preset_mode != PRESET_MODE_AUTO:
raise ValueError(f"Invalid preset mode: {preset_mode}")
self._device.fan_mode = OffOnAuto.AUTO self._device.fan_mode = OffOnAuto.AUTO
async def async_set_direction(self, direction: str) -> None: async def async_set_direction(self, direction: str) -> None:

View File

@@ -2,9 +2,12 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Connect to the Balboa Wi-Fi device", "description": "Connect to the Balboa Wi-Fi device",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "Hostname or IP address of your Balboa Spa Wi-Fi Device. For example, 192.168.1.58."
} }
} }
}, },

View File

@@ -25,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS
from .coordinator import BlinkUpdateCoordinator from .coordinator import BlinkUpdateCoordinator
from .services import async_setup_services from .services import setup_services
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -74,7 +74,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Blink.""" """Set up Blink."""
await async_setup_services(hass) setup_services(hass)
return True return True

View File

@@ -1,8 +1,6 @@
"""Services for the Blink integration.""" """Services for the Blink integration."""
from __future__ import annotations from __future__ import annotations
import logging
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.config_entries import ConfigEntry, ConfigEntryState
@@ -14,7 +12,7 @@ from homeassistant.const import (
CONF_PIN, CONF_PIN,
) )
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.device_registry as dr import homeassistant.helpers.device_registry as dr
@@ -27,56 +25,72 @@ from .const import (
) )
from .coordinator import BlinkUpdateCoordinator from .coordinator import BlinkUpdateCoordinator
_LOGGER = logging.getLogger(__name__) SERVICE_UPDATE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
}
)
SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema(
{ {
vol.Required(ATTR_DEVICE_ID): cv.ensure_list, vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
vol.Required(CONF_NAME): cv.string, vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_FILENAME): cv.string, vol.Required(CONF_FILENAME): cv.string,
} }
) )
SERVICE_SEND_PIN_SCHEMA = vol.Schema( SERVICE_SEND_PIN_SCHEMA = vol.Schema(
{vol.Required(ATTR_DEVICE_ID): cv.ensure_list, vol.Optional(CONF_PIN): cv.string} {
vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_PIN): cv.string,
}
) )
SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema(
{ {
vol.Required(ATTR_DEVICE_ID): cv.ensure_list, vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
vol.Required(CONF_NAME): cv.string, vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_FILE_PATH): cv.string, vol.Required(CONF_FILE_PATH): cv.string,
} }
) )
async def async_setup_services(hass: HomeAssistant) -> None: def setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Blink integration.""" """Set up the services for the Blink integration."""
async def collect_coordinators( def collect_coordinators(
device_ids: list[str], device_ids: list[str],
) -> list[BlinkUpdateCoordinator]: ) -> list[BlinkUpdateCoordinator]:
config_entries = list[ConfigEntry]() config_entries: list[ConfigEntry] = []
registry = dr.async_get(hass) registry = dr.async_get(hass)
for target in device_ids: for target in device_ids:
device = registry.async_get(target) device = registry.async_get(target)
if device: if device:
device_entries = list[ConfigEntry]() device_entries: list[ConfigEntry] = []
for entry_id in device.config_entries: for entry_id in device.config_entries:
entry = hass.config_entries.async_get_entry(entry_id) entry = hass.config_entries.async_get_entry(entry_id)
if entry and entry.domain == DOMAIN: if entry and entry.domain == DOMAIN:
device_entries.append(entry) device_entries.append(entry)
if not device_entries: if not device_entries:
raise HomeAssistantError( raise ServiceValidationError(
f"Device '{target}' is not a {DOMAIN} device" translation_domain=DOMAIN,
translation_key="invalid_device",
translation_placeholders={"target": target, "domain": DOMAIN},
) )
config_entries.extend(device_entries) config_entries.extend(device_entries)
else: else:
raise HomeAssistantError( raise HomeAssistantError(
f"Device '{target}' not found in device registry" translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"target": target},
) )
coordinators = list[BlinkUpdateCoordinator]()
coordinators: list[BlinkUpdateCoordinator] = []
for config_entry in config_entries: for config_entry in config_entries:
if config_entry.state != ConfigEntryState.LOADED: if config_entry.state != ConfigEntryState.LOADED:
raise HomeAssistantError(f"{config_entry.title} is not loaded") raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="not_loaded",
translation_placeholders={"target": config_entry.title},
)
coordinators.append(hass.data[DOMAIN][config_entry.entry_id]) coordinators.append(hass.data[DOMAIN][config_entry.entry_id])
return coordinators return coordinators
@@ -85,24 +99,36 @@ async def async_setup_services(hass: HomeAssistant) -> None:
camera_name = call.data[CONF_NAME] camera_name = call.data[CONF_NAME]
video_path = call.data[CONF_FILENAME] video_path = call.data[CONF_FILENAME]
if not hass.config.is_allowed_path(video_path): if not hass.config.is_allowed_path(video_path):
_LOGGER.error("Can't write %s, no access to path!", video_path) raise ServiceValidationError(
return translation_domain=DOMAIN,
for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): translation_key="no_path",
translation_placeholders={"target": video_path},
)
for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]):
all_cameras = coordinator.api.cameras all_cameras = coordinator.api.cameras
if camera_name in all_cameras: if camera_name in all_cameras:
try: try:
await all_cameras[camera_name].video_to_file(video_path) await all_cameras[camera_name].video_to_file(video_path)
except OSError as err: except OSError as err:
_LOGGER.error("Can't write image to file: %s", err) raise ServiceValidationError(
str(err),
translation_domain=DOMAIN,
translation_key="cant_write",
) from err
async def async_handle_save_recent_clips_service(call: ServiceCall) -> None: async def async_handle_save_recent_clips_service(call: ServiceCall) -> None:
"""Save multiple recent clips to output directory.""" """Save multiple recent clips to output directory."""
camera_name = call.data[CONF_NAME] camera_name = call.data[CONF_NAME]
clips_dir = call.data[CONF_FILE_PATH] clips_dir = call.data[CONF_FILE_PATH]
if not hass.config.is_allowed_path(clips_dir): if not hass.config.is_allowed_path(clips_dir):
_LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) raise ServiceValidationError(
return translation_domain=DOMAIN,
for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): translation_key="no_path",
translation_placeholders={"target": clips_dir},
)
for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]):
all_cameras = coordinator.api.cameras all_cameras = coordinator.api.cameras
if camera_name in all_cameras: if camera_name in all_cameras:
try: try:
@@ -110,11 +136,15 @@ async def async_setup_services(hass: HomeAssistant) -> None:
output_dir=clips_dir output_dir=clips_dir
) )
except OSError as err: except OSError as err:
_LOGGER.error("Can't write recent clips to directory: %s", err) raise ServiceValidationError(
str(err),
translation_domain=DOMAIN,
translation_key="cant_write",
) from err
async def send_pin(call: ServiceCall): async def send_pin(call: ServiceCall):
"""Call blink to send new pin.""" """Call blink to send new pin."""
for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]):
await coordinator.api.auth.send_auth_key( await coordinator.api.auth.send_auth_key(
coordinator.api, coordinator.api,
call.data[CONF_PIN], call.data[CONF_PIN],
@@ -122,12 +152,12 @@ async def async_setup_services(hass: HomeAssistant) -> None:
async def blink_refresh(call: ServiceCall): async def blink_refresh(call: ServiceCall):
"""Call blink to refresh info.""" """Call blink to refresh info."""
for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]):
await coordinator.api.refresh(force_cache=True) await coordinator.api.refresh(force_cache=True)
# Register all the above services # Register all the above services
service_mapping = [ service_mapping = [
(blink_refresh, SERVICE_REFRESH, None), (blink_refresh, SERVICE_REFRESH, SERVICE_UPDATE_SCHEMA),
( (
async_handle_save_video_service, async_handle_save_video_service,
SERVICE_SAVE_VIDEO, SERVICE_SAVE_VIDEO,

View File

@@ -1,14 +1,28 @@
# Describes the format for available Blink services # Describes the format for available Blink services
blink_update: blink_update:
fields:
device_id:
required: true
selector:
device:
integration: blink
trigger_camera: trigger_camera:
target: fields:
entity: device_id:
integration: blink required: true
domain: camera selector:
device:
integration: blink
save_video: save_video:
fields: fields:
device_id:
required: true
selector:
device:
integration: blink
name: name:
required: true required: true
example: "Living Room" example: "Living Room"
@@ -22,6 +36,11 @@ save_video:
save_recent_clips: save_recent_clips:
fields: fields:
device_id:
required: true
selector:
device:
integration: blink
name: name:
required: true required: true
example: "Living Room" example: "Living Room"
@@ -35,6 +54,11 @@ save_recent_clips:
send_pin: send_pin:
fields: fields:
device_id:
required: true
selector:
device:
integration: blink
pin: pin:
example: "abc123" example: "abc123"
selector: selector:

View File

@@ -57,11 +57,23 @@
"services": { "services": {
"blink_update": { "blink_update": {
"name": "Update", "name": "Update",
"description": "Forces a refresh." "description": "Forces a refresh.",
"fields": {
"device_id": {
"name": "Device ID",
"description": "The Blink device id."
}
}
}, },
"trigger_camera": { "trigger_camera": {
"name": "Trigger camera", "name": "Trigger camera",
"description": "Requests camera to take new image." "description": "Requests camera to take new image.",
"fields": {
"device_id": {
"name": "Device ID",
"description": "The Blink device id."
}
}
}, },
"save_video": { "save_video": {
"name": "Save video", "name": "Save video",
@@ -74,6 +86,10 @@
"filename": { "filename": {
"name": "File name", "name": "File name",
"description": "Filename to writable path (directory may need to be included in allowlist_external_dirs in config)." "description": "Filename to writable path (directory may need to be included in allowlist_external_dirs in config)."
},
"device_id": {
"name": "Device ID",
"description": "The Blink device id."
} }
} }
}, },
@@ -88,6 +104,10 @@
"file_path": { "file_path": {
"name": "Output directory", "name": "Output directory",
"description": "Directory name of writable path (directory may need to be included in allowlist_external_dirs in config)." "description": "Directory name of writable path (directory may need to be included in allowlist_external_dirs in config)."
},
"device_id": {
"name": "Device ID",
"description": "The Blink device id."
} }
} }
}, },
@@ -98,8 +118,29 @@
"pin": { "pin": {
"name": "Pin", "name": "Pin",
"description": "PIN received from blink. Leave empty if you only received a verification email." "description": "PIN received from blink. Leave empty if you only received a verification email."
},
"device_id": {
"name": "Device ID",
"description": "The Blink device id."
} }
} }
} }
},
"exceptions": {
"invalid_device": {
"message": "Device '{target}' is not a {domain} device"
},
"device_not_found": {
"message": "Device '{target}' not found in device registry"
},
"no_path": {
"message": "Can't write to directory {target}, no access to path!"
},
"cant_write": {
"message": "Can't write to file"
},
"not_loaded": {
"message": "{target} is not loaded"
}
} }
} }

View File

@@ -215,7 +215,7 @@ class DomainBlueprints:
def _load_blueprint(self, blueprint_path) -> Blueprint: def _load_blueprint(self, blueprint_path) -> Blueprint:
"""Load a blueprint.""" """Load a blueprint."""
try: try:
blueprint_data = yaml.load_yaml(self.blueprint_folder / blueprint_path) blueprint_data = yaml.load_yaml_dict(self.blueprint_folder / blueprint_path)
except FileNotFoundError as err: except FileNotFoundError as err:
raise FailedToLoad( raise FailedToLoad(
self.domain, self.domain,
@@ -225,7 +225,6 @@ class DomainBlueprints:
except HomeAssistantError as err: except HomeAssistantError as err:
raise FailedToLoad(self.domain, blueprint_path, err) from err raise FailedToLoad(self.domain, blueprint_path, err) from err
assert isinstance(blueprint_data, dict)
return Blueprint( return Blueprint(
blueprint_data, expected_domain=self.domain, path=blueprint_path blueprint_data, expected_domain=self.domain, path=blueprint_path
) )

View File

@@ -21,6 +21,12 @@ from bluetooth_adapters import (
adapter_unique_name, adapter_unique_name,
get_adapters, get_adapters,
) )
from habluetooth import (
BluetoothScanningMode,
HaBluetoothConnector,
HaScanner,
ScannerStartError,
)
from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak
from homeassistant.components import usb from homeassistant.components import usb
@@ -59,7 +65,11 @@ from .api import (
async_set_fallback_availability_interval, async_set_fallback_availability_interval,
async_track_unavailable, async_track_unavailable,
) )
from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice from .base_scanner import (
BaseHaScanner,
BluetoothScannerDevice,
HomeAssistantRemoteScanner,
)
from .const import ( from .const import (
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
CONF_ADAPTER, CONF_ADAPTER,
@@ -71,15 +81,9 @@ from .const import (
LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS,
SOURCE_LOCAL, SOURCE_LOCAL,
) )
from .manager import BluetoothManager from .manager import MONOTONIC_TIME, HomeAssistantBluetoothManager
from .match import BluetoothCallbackMatcher, IntegrationMatcher from .match import BluetoothCallbackMatcher, IntegrationMatcher
from .models import ( from .models import BluetoothCallback, BluetoothChange
BluetoothCallback,
BluetoothChange,
BluetoothScanningMode,
HaBluetoothConnector,
)
from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError
from .storage import BluetoothStorage from .storage import BluetoothStorage
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -103,7 +107,7 @@ __all__ = [
"async_scanner_count", "async_scanner_count",
"async_scanner_devices_by_address", "async_scanner_devices_by_address",
"BaseHaScanner", "BaseHaScanner",
"BaseHaRemoteScanner", "HomeAssistantRemoteScanner",
"BluetoothCallbackMatcher", "BluetoothCallbackMatcher",
"BluetoothChange", "BluetoothChange",
"BluetoothServiceInfo", "BluetoothServiceInfo",
@@ -139,11 +143,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await bluetooth_storage.async_setup() await bluetooth_storage.async_setup()
slot_manager = BleakSlotManager() slot_manager = BleakSlotManager()
await slot_manager.async_setup() await slot_manager.async_setup()
manager = BluetoothManager( manager = HomeAssistantBluetoothManager(
hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager
) )
await manager.async_setup() await manager.async_setup()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop) hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, lambda event: manager.async_stop()
)
hass.data[DATA_MANAGER] = models.MANAGER = manager hass.data[DATA_MANAGER] = models.MANAGER = manager
adapters = await manager.async_get_bluetooth_adapters() adapters = await manager.async_get_bluetooth_adapters()
@@ -280,8 +286,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
passive = entry.options.get(CONF_PASSIVE) passive = entry.options.get(CONF_PASSIVE)
mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE
new_info_callback = async_get_advertisement_callback(hass) new_info_callback = async_get_advertisement_callback(hass)
manager: BluetoothManager = hass.data[DATA_MANAGER] manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER]
scanner = HaScanner(hass, mode, adapter, address, new_info_callback) scanner = HaScanner(mode, adapter, address, new_info_callback)
try: try:
scanner.async_setup() scanner.async_setup()
except RuntimeError as err: except RuntimeError as err:

View File

@@ -9,10 +9,10 @@ import logging
from typing import Any, Generic, TypeVar from typing import Any, Generic, TypeVar
from bleak import BleakError from bleak import BleakError
from bluetooth_data_tools import monotonic_time_coarse
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.util.dt import monotonic_time_coarse
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
from .passive_update_coordinator import PassiveBluetoothDataUpdateCoordinator from .passive_update_coordinator import PassiveBluetoothDataUpdateCoordinator

View File

@@ -9,10 +9,10 @@ import logging
from typing import Any, Generic, TypeVar from typing import Any, Generic, TypeVar
from bleak import BleakError from bleak import BleakError
from bluetooth_data_tools import monotonic_time_coarse
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.util.dt import monotonic_time_coarse
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
from .passive_update_processor import PassiveBluetoothProcessorCoordinator from .passive_update_processor import PassiveBluetoothProcessorCoordinator

View File

@@ -1,82 +0,0 @@
"""The bluetooth integration advertisement tracker."""
from __future__ import annotations
from typing import Any
from homeassistant.core import callback
from .models import BluetoothServiceInfoBleak
ADVERTISING_TIMES_NEEDED = 16
# Each scanner may buffer incoming packets so
# we need to give a bit of leeway before we
# mark a device unavailable
TRACKER_BUFFERING_WOBBLE_SECONDS = 5
class AdvertisementTracker:
"""Tracker to determine the interval that a device is advertising."""
__slots__ = ("intervals", "fallback_intervals", "sources", "_timings")
def __init__(self) -> None:
"""Initialize the tracker."""
self.intervals: dict[str, float] = {}
self.fallback_intervals: dict[str, float] = {}
self.sources: dict[str, str] = {}
self._timings: dict[str, list[float]] = {}
@callback
def async_diagnostics(self) -> dict[str, dict[str, Any]]:
"""Return diagnostics."""
return {
"intervals": self.intervals,
"fallback_intervals": self.fallback_intervals,
"sources": self.sources,
"timings": self._timings,
}
@callback
def async_collect(self, service_info: BluetoothServiceInfoBleak) -> None:
"""Collect timings for the tracker.
For performance reasons, it is the responsibility of the
caller to check if the device already has an interval set or
the source has changed before calling this function.
"""
address = service_info.address
self.sources[address] = service_info.source
timings = self._timings.setdefault(address, [])
timings.append(service_info.time)
if len(timings) != ADVERTISING_TIMES_NEEDED:
return
max_time_between_advertisements = timings[1] - timings[0]
for i in range(2, len(timings)):
time_between_advertisements = timings[i] - timings[i - 1]
if time_between_advertisements > max_time_between_advertisements:
max_time_between_advertisements = time_between_advertisements
# We now know the maximum time between advertisements
self.intervals[address] = max_time_between_advertisements
del self._timings[address]
@callback
def async_remove_address(self, address: str) -> None:
"""Remove the tracker."""
self.intervals.pop(address, None)
self.sources.pop(address, None)
self._timings.pop(address, None)
@callback
def async_remove_fallback_interval(self, address: str) -> None:
"""Remove fallback interval."""
self.fallback_intervals.pop(address, None)
@callback
def async_remove_source(self, source: str) -> None:
"""Remove the tracker."""
for address, tracked_source in list(self.sources.items()):
if tracked_source == source:
self.async_remove_address(address)

View File

@@ -9,29 +9,25 @@ from asyncio import Future
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from habluetooth import BluetoothScanningMode
from home_assistant_bluetooth import BluetoothServiceInfoBleak from home_assistant_bluetooth import BluetoothServiceInfoBleak
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .base_scanner import BaseHaScanner, BluetoothScannerDevice
from .const import DATA_MANAGER from .const import DATA_MANAGER
from .manager import BluetoothManager from .manager import HomeAssistantBluetoothManager
from .match import BluetoothCallbackMatcher from .match import BluetoothCallbackMatcher
from .models import ( from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback
BluetoothCallback,
BluetoothChange,
BluetoothScanningMode,
ProcessAdvertisementCallback,
)
from .wrappers import HaBleakScannerWrapper from .wrappers import HaBleakScannerWrapper
if TYPE_CHECKING: if TYPE_CHECKING:
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
def _get_manager(hass: HomeAssistant) -> BluetoothManager: def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager:
"""Get the bluetooth manager.""" """Get the bluetooth manager."""
return cast(BluetoothManager, hass.data[DATA_MANAGER]) return cast(HomeAssistantBluetoothManager, hass.data[DATA_MANAGER])
@hass_callback @hass_callback

View File

@@ -1,19 +1,14 @@
"""Base classes for HA Bluetooth scanners for bluetooth.""" """Base classes for HA Bluetooth scanners for bluetooth."""
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from collections.abc import Callable
from collections.abc import Callable, Generator
from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
import datetime from typing import Any
from datetime import timedelta
import logging
from typing import Any, Final
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData from bleak.backends.scanner import AdvertisementData
from bleak_retry_connector import NO_RSSI_VALUE from bluetooth_adapters import DiscoveredDeviceAdvertisementData
from bluetooth_adapters import DiscoveredDeviceAdvertisementData, adapter_human_name from habluetooth import BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector
from home_assistant_bluetooth import BluetoothServiceInfoBleak from home_assistant_bluetooth import BluetoothServiceInfoBleak
from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_STOP
@@ -23,20 +18,8 @@ from homeassistant.core import (
HomeAssistant, HomeAssistant,
callback as hass_callback, callback as hass_callback,
) )
from homeassistant.helpers.event import async_track_time_interval
import homeassistant.util.dt as dt_util
from homeassistant.util.dt import monotonic_time_coarse
from . import models from . import models
from .const import (
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
)
from .models import HaBluetoothConnector
MONOTONIC_TIME: Final = monotonic_time_coarse
_LOGGER = logging.getLogger(__name__)
@dataclass(slots=True) @dataclass(slots=True)
@@ -48,150 +31,17 @@ class BluetoothScannerDevice:
advertisement: AdvertisementData advertisement: AdvertisementData
class BaseHaScanner(ABC): class HomeAssistantRemoteScanner(BaseHaRemoteScanner):
"""Base class for Ha Scanners.""" """Home Assistant remote BLE scanner.
This is the only object that should know about
the hass object.
"""
__slots__ = ( __slots__ = (
"hass", "hass",
"adapter",
"connectable",
"source",
"connector",
"_connecting",
"name",
"scanning",
"_last_detection",
"_start_time",
"_cancel_watchdog",
)
def __init__(
self,
hass: HomeAssistant,
source: str,
adapter: str,
connector: HaBluetoothConnector | None = None,
) -> None:
"""Initialize the scanner."""
self.hass = hass
self.connectable = False
self.source = source
self.connector = connector
self._connecting = 0
self.adapter = adapter
self.name = adapter_human_name(adapter, source) if adapter != source else source
self.scanning = True
self._last_detection = 0.0
self._start_time = 0.0
self._cancel_watchdog: CALLBACK_TYPE | None = None
@hass_callback
def _async_stop_scanner_watchdog(self) -> None:
"""Stop the scanner watchdog."""
if self._cancel_watchdog:
self._cancel_watchdog()
self._cancel_watchdog = None
@hass_callback
def _async_setup_scanner_watchdog(self) -> None:
"""If something has restarted or updated, we need to restart the scanner."""
self._start_time = self._last_detection = MONOTONIC_TIME()
if not self._cancel_watchdog:
self._cancel_watchdog = async_track_time_interval(
self.hass,
self._async_scanner_watchdog,
SCANNER_WATCHDOG_INTERVAL,
name=f"{self.name} Bluetooth scanner watchdog",
)
@hass_callback
def _async_watchdog_triggered(self) -> bool:
"""Check if the watchdog has been triggered."""
time_since_last_detection = MONOTONIC_TIME() - self._last_detection
_LOGGER.debug(
"%s: Scanner watchdog time_since_last_detection: %s",
self.name,
time_since_last_detection,
)
return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT
@hass_callback
def _async_scanner_watchdog(self, now: datetime.datetime) -> None:
"""Check if the scanner is running.
Override this method if you need to do something else when the watchdog
is triggered.
"""
if self._async_watchdog_triggered():
_LOGGER.info(
(
"%s: Bluetooth scanner has gone quiet for %ss, check logs on the"
" scanner device for more information"
),
self.name,
SCANNER_WATCHDOG_TIMEOUT,
)
self.scanning = False
return
self.scanning = not self._connecting
@contextmanager
def connecting(self) -> Generator[None, None, None]:
"""Context manager to track connecting state."""
self._connecting += 1
self.scanning = not self._connecting
try:
yield
finally:
self._connecting -= 1
self.scanning = not self._connecting
@property
@abstractmethod
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
@property
@abstractmethod
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and their advertisement data."""
async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner."""
device_adv_datas = self.discovered_devices_and_advertisement_data.values()
return {
"name": self.name,
"start_time": self._start_time,
"source": self.source,
"scanning": self.scanning,
"type": self.__class__.__name__,
"last_detection": self._last_detection,
"monotonic_time": MONOTONIC_TIME(),
"discovered_devices_and_advertisement_data": [
{
"name": device.name,
"address": device.address,
"rssi": advertisement_data.rssi,
"advertisement_data": advertisement_data,
"details": device.details,
}
for device, advertisement_data in device_adv_datas
],
}
class BaseHaRemoteScanner(BaseHaScanner):
"""Base class for a Home Assistant remote BLE scanner."""
__slots__ = (
"_new_info_callback",
"_discovered_device_advertisement_datas",
"_discovered_device_timestamps",
"_details",
"_expire_seconds",
"_storage", "_storage",
"_cancel_stop",
) )
def __init__( def __init__(
@@ -204,50 +54,36 @@ class BaseHaRemoteScanner(BaseHaScanner):
connectable: bool, connectable: bool,
) -> None: ) -> None:
"""Initialize the scanner.""" """Initialize the scanner."""
super().__init__(hass, scanner_id, name, connector) self.hass = hass
self._new_info_callback = new_info_callback
self._discovered_device_advertisement_datas: dict[
str, tuple[BLEDevice, AdvertisementData]
] = {}
self._discovered_device_timestamps: dict[str, float] = {}
self.connectable = connectable
self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id}
# Scanners only care about connectable devices. The manager
# will handle taking care of availability for non-connectable devices
self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
assert models.MANAGER is not None assert models.MANAGER is not None
self._storage = models.MANAGER.storage self._storage = models.MANAGER.storage
self._cancel_stop: CALLBACK_TYPE | None = None
super().__init__(scanner_id, name, new_info_callback, connector, connectable)
@hass_callback @hass_callback
def async_setup(self) -> CALLBACK_TYPE: def async_setup(self) -> CALLBACK_TYPE:
"""Set up the scanner.""" """Set up the scanner."""
super().async_setup()
if history := self._storage.async_get_advertisement_history(self.source): if history := self._storage.async_get_advertisement_history(self.source):
self._discovered_device_advertisement_datas = ( self._discovered_device_advertisement_datas = (
history.discovered_device_advertisement_datas history.discovered_device_advertisement_datas
) )
self._discovered_device_timestamps = history.discovered_device_timestamps self._discovered_device_timestamps = history.discovered_device_timestamps
# Expire anything that is too old # Expire anything that is too old
self._async_expire_devices(dt_util.utcnow()) self._async_expire_devices()
cancel_track = async_track_time_interval( self._cancel_stop = self.hass.bus.async_listen(
self.hass,
self._async_expire_devices,
timedelta(seconds=30),
name=f"{self.name} Bluetooth scanner device expire",
)
cancel_stop = self.hass.bus.async_listen(
EVENT_HOMEASSISTANT_STOP, self._async_save_history EVENT_HOMEASSISTANT_STOP, self._async_save_history
) )
self._async_setup_scanner_watchdog() return self._unsetup
@hass_callback @hass_callback
def _cancel() -> None: def _unsetup(self) -> None:
self._async_save_history() super()._unsetup()
self._async_stop_scanner_watchdog() self._async_save_history()
cancel_track() if self._cancel_stop:
cancel_stop() self._cancel_stop()
self._cancel_stop = None
return _cancel
@hass_callback @hass_callback
def _async_save_history(self, event: Event | None = None) -> None: def _async_save_history(self, event: Event | None = None) -> None:
@@ -262,146 +98,10 @@ class BaseHaRemoteScanner(BaseHaScanner):
), ),
) )
@hass_callback
def _async_expire_devices(self, _datetime: datetime.datetime) -> None:
"""Expire old devices."""
now = MONOTONIC_TIME()
expired = [
address
for address, timestamp in self._discovered_device_timestamps.items()
if now - timestamp > self._expire_seconds
]
for address in expired:
del self._discovered_device_advertisement_datas[address]
del self._discovered_device_timestamps[address]
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
device_adv_datas = self._discovered_device_advertisement_datas.values()
return [
device_advertisement_data[0]
for device_advertisement_data in device_adv_datas
]
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and advertisement data."""
return self._discovered_device_advertisement_datas
@hass_callback
def _async_on_advertisement(
self,
address: str,
rssi: int,
local_name: str | None,
service_uuids: list[str],
service_data: dict[str, bytes],
manufacturer_data: dict[int, bytes],
tx_power: int | None,
details: dict[Any, Any],
advertisement_monotonic_time: float,
) -> None:
"""Call the registered callback."""
self.scanning = not self._connecting
self._last_detection = advertisement_monotonic_time
try:
prev_discovery = self._discovered_device_advertisement_datas[address]
except KeyError:
# We expect this is the rare case and since py3.11+ has
# near zero cost try on success, and we can avoid .get()
# which is slower than [] we use the try/except pattern.
device = BLEDevice(
address=address,
name=local_name,
details=self._details | details,
rssi=rssi, # deprecated, will be removed in newer bleak
)
else:
# Merge the new data with the old data
# to function the same as BlueZ which
# merges the dicts on PropertiesChanged
prev_device = prev_discovery[0]
prev_advertisement = prev_discovery[1]
prev_service_uuids = prev_advertisement.service_uuids
prev_service_data = prev_advertisement.service_data
prev_manufacturer_data = prev_advertisement.manufacturer_data
prev_name = prev_device.name
if prev_name and (not local_name or len(prev_name) > len(local_name)):
local_name = prev_name
if service_uuids and service_uuids != prev_service_uuids:
service_uuids = list({*service_uuids, *prev_service_uuids})
elif not service_uuids:
service_uuids = prev_service_uuids
if service_data and service_data != prev_service_data:
service_data = prev_service_data | service_data
elif not service_data:
service_data = prev_service_data
if manufacturer_data and manufacturer_data != prev_manufacturer_data:
manufacturer_data = prev_manufacturer_data | manufacturer_data
elif not manufacturer_data:
manufacturer_data = prev_manufacturer_data
#
# Bleak updates the BLEDevice via create_or_update_device.
# We need to do the same to ensure integrations that already
# have the BLEDevice object get the updated details when they
# change.
#
# https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203
#
device = prev_device
device.name = local_name
device.details = self._details | details
# pylint: disable-next=protected-access
device._rssi = rssi # deprecated, will be removed in newer bleak
advertisement_data = AdvertisementData(
local_name=None if local_name == "" else local_name,
manufacturer_data=manufacturer_data,
service_data=service_data,
service_uuids=service_uuids,
tx_power=NO_RSSI_VALUE if tx_power is None else tx_power,
rssi=rssi,
platform_data=(),
)
self._discovered_device_advertisement_datas[address] = (
device,
advertisement_data,
)
self._discovered_device_timestamps[address] = advertisement_monotonic_time
self._new_info_callback(
BluetoothServiceInfoBleak(
name=local_name or address,
address=address,
rssi=rssi,
manufacturer_data=manufacturer_data,
service_data=service_data,
service_uuids=service_uuids,
source=self.source,
device=device,
advertisement=advertisement_data,
connectable=self.connectable,
time=advertisement_monotonic_time,
)
)
async def async_diagnostics(self) -> dict[str, Any]: async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner.""" """Return diagnostic information about the scanner."""
now = MONOTONIC_TIME() diag = await super().async_diagnostics()
return await super().async_diagnostics() | { diag["storage"] = self._storage.async_get_advertisement_history_as_dict(
"storage": self._storage.async_get_advertisement_history_as_dict( self.source
self.source )
), return diag
"connectable": self.connectable,
"discovered_device_timestamps": self._discovered_device_timestamps,
"time_since_last_device_detection": {
address: now - timestamp
for address, timestamp in self._discovered_device_timestamps.items()
},
}

View File

@@ -1,9 +1,15 @@
"""Constants for the Bluetooth integration.""" """Constants for the Bluetooth integration."""
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
from typing import Final from typing import Final
from habluetooth import ( # noqa: F401
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
)
DOMAIN = "bluetooth" DOMAIN = "bluetooth"
CONF_ADAPTER = "adapter" CONF_ADAPTER = "adapter"
@@ -19,42 +25,6 @@ UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5
START_TIMEOUT = 15 START_TIMEOUT = 15
# The maximum time between advertisements for a device to be considered
# stale when the advertisement tracker cannot determine the interval.
#
# We have to set this quite high as we don't know
# when devices fall out of the ESPHome device (and other non-local scanners)'s
# stack like we do with BlueZ so its safer to assume its available
# since if it does go out of range and it is in range
# of another device the timeout is much shorter and it will
# switch over to using that adapter anyways.
#
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 60 * 15
# The maximum time between advertisements for a device to be considered
# stale when the advertisement tracker can determine the interval for
# connectable devices.
#
# BlueZ uses 180 seconds by default but we give it a bit more time
# to account for the esp32's bluetooth stack being a bit slower
# than BlueZ's.
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 195
# We must recover before we hit the 180s mark
# where the device is removed from the stack
# or the devices will go unavailable. Since
# we only check every 30s, we need this number
# to be
# 180s Time when device is removed from stack
# - 30s check interval
# - 30s scanner restart time * 2
#
SCANNER_WATCHDOG_TIMEOUT: Final = 90
# How often to check if the scanner has reached
# the SCANNER_WATCHDOG_TIMEOUT without seeing anything
SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30)
# When the linux kernel is configured with # When the linux kernel is configured with
# CONFIG_FW_LOADER_USER_HELPER_FALLBACK it # CONFIG_FW_LOADER_USER_HELPER_FALLBACK it

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from datetime import datetime, timedelta
import itertools import itertools
import logging import logging
from typing import TYPE_CHECKING, Any, Final from typing import TYPE_CHECKING, Any, Final
@@ -16,6 +15,8 @@ from bluetooth_adapters import (
AdapterDetails, AdapterDetails,
BluetoothAdapters, BluetoothAdapters,
) )
from bluetooth_data_tools import monotonic_time_coarse
from habluetooth import TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.const import EVENT_LOGGING_CHANGED
@@ -26,13 +27,7 @@ from homeassistant.core import (
callback as hass_callback, callback as hass_callback,
) )
from homeassistant.helpers import discovery_flow from homeassistant.helpers import discovery_flow
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.dt import monotonic_time_coarse
from .advertisement_tracker import (
TRACKER_BUFFERING_WOBBLE_SECONDS,
AdvertisementTracker,
)
from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .base_scanner import BaseHaScanner, BluetoothScannerDevice
from .const import ( from .const import (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
@@ -103,16 +98,12 @@ class BluetoothManager:
"""Manage Bluetooth.""" """Manage Bluetooth."""
__slots__ = ( __slots__ = (
"hass",
"_integration_matcher",
"_cancel_unavailable_tracking", "_cancel_unavailable_tracking",
"_cancel_logging_listener",
"_advertisement_tracker", "_advertisement_tracker",
"_fallback_intervals", "_fallback_intervals",
"_intervals", "_intervals",
"_unavailable_callbacks", "_unavailable_callbacks",
"_connectable_unavailable_callbacks", "_connectable_unavailable_callbacks",
"_callback_index",
"_bleak_callbacks", "_bleak_callbacks",
"_all_history", "_all_history",
"_connectable_history", "_connectable_history",
@@ -125,21 +116,17 @@ class BluetoothManager:
"slot_manager", "slot_manager",
"_debug", "_debug",
"shutdown", "shutdown",
"_loop",
) )
def __init__( def __init__(
self, self,
hass: HomeAssistant,
integration_matcher: IntegrationMatcher,
bluetooth_adapters: BluetoothAdapters, bluetooth_adapters: BluetoothAdapters,
storage: BluetoothStorage, storage: BluetoothStorage,
slot_manager: BleakSlotManager, slot_manager: BleakSlotManager,
) -> None: ) -> None:
"""Init bluetooth manager.""" """Init bluetooth manager."""
self.hass = hass self._cancel_unavailable_tracking: asyncio.TimerHandle | None = None
self._integration_matcher = integration_matcher
self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None
self._cancel_logging_listener: CALLBACK_TYPE | None = None
self._advertisement_tracker = AdvertisementTracker() self._advertisement_tracker = AdvertisementTracker()
self._fallback_intervals = self._advertisement_tracker.fallback_intervals self._fallback_intervals = self._advertisement_tracker.fallback_intervals
@@ -152,7 +139,6 @@ class BluetoothManager:
str, list[Callable[[BluetoothServiceInfoBleak], None]] str, list[Callable[[BluetoothServiceInfoBleak], None]]
] = {} ] = {}
self._callback_index = BluetoothCallbackMatcherIndex()
self._bleak_callbacks: list[ self._bleak_callbacks: list[
tuple[AdvertisementDataCallback, dict[str, set[str]]] tuple[AdvertisementDataCallback, dict[str, set[str]]]
] = [] ] = []
@@ -167,6 +153,7 @@ class BluetoothManager:
self.slot_manager = slot_manager self.slot_manager = slot_manager
self._debug = _LOGGER.isEnabledFor(logging.DEBUG) self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
self.shutdown = False self.shutdown = False
self._loop: asyncio.AbstractEventLoop | None = None
@property @property
def supports_passive_scan(self) -> bool: def supports_passive_scan(self) -> bool:
@@ -209,7 +196,6 @@ class BluetoothManager:
return adapter return adapter
return None return None
@hass_callback
def async_scanner_by_source(self, source: str) -> BaseHaScanner | None: def async_scanner_by_source(self, source: str) -> BaseHaScanner | None:
"""Return the scanner for a source.""" """Return the scanner for a source."""
return self._sources.get(source) return self._sources.get(source)
@@ -232,45 +218,22 @@ class BluetoothManager:
self._adapters = self._bluetooth_adapters.adapters self._adapters = self._bluetooth_adapters.adapters
return self._find_adapter_by_address(address) return self._find_adapter_by_address(address)
@hass_callback
def _async_logging_changed(self, event: Event) -> None:
"""Handle logging change."""
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
async def async_setup(self) -> None: async def async_setup(self) -> None:
"""Set up the bluetooth manager.""" """Set up the bluetooth manager."""
self._loop = asyncio.get_running_loop()
await self._bluetooth_adapters.refresh() await self._bluetooth_adapters.refresh()
install_multiple_bleak_catcher() install_multiple_bleak_catcher()
self._all_history, self._connectable_history = async_load_history_from_system(
self._bluetooth_adapters, self.storage
)
self._cancel_logging_listener = self.hass.bus.async_listen(
EVENT_LOGGING_CHANGED, self._async_logging_changed
)
self.async_setup_unavailable_tracking() self.async_setup_unavailable_tracking()
seen: set[str] = set()
for address, service_info in itertools.chain(
self._connectable_history.items(), self._all_history.items()
):
if address in seen:
continue
seen.add(address)
self._async_trigger_matching_discovery(service_info)
@hass_callback def async_stop(self) -> None:
def async_stop(self, event: Event) -> None:
"""Stop the Bluetooth integration at shutdown.""" """Stop the Bluetooth integration at shutdown."""
_LOGGER.debug("Stopping bluetooth manager") _LOGGER.debug("Stopping bluetooth manager")
self.shutdown = True self.shutdown = True
if self._cancel_unavailable_tracking: if self._cancel_unavailable_tracking:
self._cancel_unavailable_tracking() self._cancel_unavailable_tracking.cancel()
self._cancel_unavailable_tracking = None self._cancel_unavailable_tracking = None
if self._cancel_logging_listener:
self._cancel_logging_listener()
self._cancel_logging_listener = None
uninstall_multiple_bleak_catcher() uninstall_multiple_bleak_catcher()
@hass_callback
def async_scanner_devices_by_address( def async_scanner_devices_by_address(
self, address: str, connectable: bool self, address: str, connectable: bool
) -> list[BluetoothScannerDevice]: ) -> list[BluetoothScannerDevice]:
@@ -291,7 +254,6 @@ class BluetoothManager:
) )
] ]
@hass_callback
def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]:
"""Return all of discovered addresses. """Return all of discovered addresses.
@@ -307,24 +269,25 @@ class BluetoothManager:
for scanner in self._non_connectable_scanners for scanner in self._non_connectable_scanners
) )
@hass_callback
def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]: def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]:
"""Return all of combined best path to discovered from all the scanners.""" """Return all of combined best path to discovered from all the scanners."""
histories = self._connectable_history if connectable else self._all_history histories = self._connectable_history if connectable else self._all_history
return [history.device for history in histories.values()] return [history.device for history in histories.values()]
@hass_callback
def async_setup_unavailable_tracking(self) -> None: def async_setup_unavailable_tracking(self) -> None:
"""Set up the unavailable tracking.""" """Set up the unavailable tracking."""
self._cancel_unavailable_tracking = async_track_time_interval( self._schedule_unavailable_tracking()
self.hass,
self._async_check_unavailable, def _schedule_unavailable_tracking(self) -> None:
timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), """Schedule the unavailable tracking."""
name="Bluetooth manager unavailable tracking", if TYPE_CHECKING:
assert self._loop is not None
loop = self._loop
self._cancel_unavailable_tracking = loop.call_at(
loop.time() + UNAVAILABLE_TRACK_SECONDS, self._async_check_unavailable
) )
@hass_callback def _async_check_unavailable(self) -> None:
def _async_check_unavailable(self, now: datetime) -> None:
"""Watch for unavailable devices and cleanup state history.""" """Watch for unavailable devices and cleanup state history."""
monotonic_now = MONOTONIC_TIME() monotonic_now = MONOTONIC_TIME()
connectable_history = self._connectable_history connectable_history = self._connectable_history
@@ -366,8 +329,7 @@ class BluetoothManager:
# available for both connectable and non-connectable # available for both connectable and non-connectable
tracker.async_remove_fallback_interval(address) tracker.async_remove_fallback_interval(address)
tracker.async_remove_address(address) tracker.async_remove_address(address)
self._integration_matcher.async_clear_address(address) self._address_disappeared(address)
self._async_dismiss_discoveries(address)
service_info = history.pop(address) service_info = history.pop(address)
@@ -380,13 +342,13 @@ class BluetoothManager:
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in unavailable callback") _LOGGER.exception("Error in unavailable callback")
def _async_dismiss_discoveries(self, address: str) -> None: self._schedule_unavailable_tracking()
"""Dismiss all discoveries for the given address."""
for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( def _address_disappeared(self, address: str) -> None:
BluetoothServiceInfoBleak, """Call when an address disappears from the stack.
lambda service_info: bool(service_info.address == address),
): This method is intended to be overridden by subclasses.
self.hass.config_entries.flow.async_abort(flow["flow_id"]) """
def _prefer_previous_adv_from_different_source( def _prefer_previous_adv_from_different_source(
self, self,
@@ -439,7 +401,6 @@ class BluetoothManager:
return False return False
return True return True
@hass_callback
def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None:
"""Handle a new advertisement from any scanner. """Handle a new advertisement from any scanner.
@@ -570,16 +531,6 @@ class BluetoothManager:
time=service_info.time, time=service_info.time,
) )
matched_domains = self._integration_matcher.match_domains(service_info)
if self._debug:
_LOGGER.debug(
"%s: %s %s match: %s",
self._async_describe_source(service_info),
address,
service_info.advertisement,
matched_domains,
)
if (connectable or old_connectable_service_info) and ( if (connectable or old_connectable_service_info) and (
bleak_callbacks := self._bleak_callbacks bleak_callbacks := self._bleak_callbacks
): ):
@@ -589,22 +540,14 @@ class BluetoothManager:
for callback_filters in bleak_callbacks: for callback_filters in bleak_callbacks:
_dispatch_bleak_callback(*callback_filters, device, advertisement_data) _dispatch_bleak_callback(*callback_filters, device, advertisement_data)
for match in self._callback_index.match_callbacks(service_info): self._discover_service_info(service_info)
callback = match[CALLBACK]
try:
callback(service_info, BluetoothChange.ADVERTISEMENT)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in bluetooth callback")
for domain in matched_domains: def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None:
discovery_flow.async_create_flow( """Discover a new service info.
self.hass,
domain, This method is intended to be overridden by subclasses.
{"source": config_entries.SOURCE_BLUETOOTH}, """
service_info,
)
@hass_callback
def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str: def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str:
"""Describe a source.""" """Describe a source."""
if scanner := self._sources.get(service_info.source): if scanner := self._sources.get(service_info.source):
@@ -615,7 +558,6 @@ class BluetoothManager:
description += " [connectable]" description += " [connectable]"
return description return description
@hass_callback
def async_track_unavailable( def async_track_unavailable(
self, self,
callback: Callable[[BluetoothServiceInfoBleak], None], callback: Callable[[BluetoothServiceInfoBleak], None],
@@ -629,7 +571,6 @@ class BluetoothManager:
unavailable_callbacks = self._unavailable_callbacks unavailable_callbacks = self._unavailable_callbacks
unavailable_callbacks.setdefault(address, []).append(callback) unavailable_callbacks.setdefault(address, []).append(callback)
@hass_callback
def _async_remove_callback() -> None: def _async_remove_callback() -> None:
unavailable_callbacks[address].remove(callback) unavailable_callbacks[address].remove(callback)
if not unavailable_callbacks[address]: if not unavailable_callbacks[address]:
@@ -637,50 +578,6 @@ class BluetoothManager:
return _async_remove_callback return _async_remove_callback
@hass_callback
def async_register_callback(
self,
callback: BluetoothCallback,
matcher: BluetoothCallbackMatcher | None,
) -> Callable[[], None]:
"""Register a callback."""
callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback)
if not matcher:
callback_matcher[CONNECTABLE] = True
else:
# We could write out every item in the typed dict here
# but that would be a bit inefficient and verbose.
callback_matcher.update(matcher)
callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True)
connectable = callback_matcher[CONNECTABLE]
self._callback_index.add_callback_matcher(callback_matcher)
@hass_callback
def _async_remove_callback() -> None:
self._callback_index.remove_callback_matcher(callback_matcher)
# If we have history for the subscriber, we can trigger the callback
# immediately with the last packet so the subscriber can see the
# device.
history = self._connectable_history if connectable else self._all_history
service_infos: Iterable[BluetoothServiceInfoBleak] = []
if address := callback_matcher.get(ADDRESS):
if service_info := history.get(address):
service_infos = [service_info]
else:
service_infos = history.values()
for service_info in service_infos:
if ble_device_matches(callback_matcher, service_info):
try:
callback(service_info, BluetoothChange.ADVERTISEMENT)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in bluetooth callback")
return _async_remove_callback
@hass_callback
def async_ble_device_from_address( def async_ble_device_from_address(
self, address: str, connectable: bool self, address: str, connectable: bool
) -> BLEDevice | None: ) -> BLEDevice | None:
@@ -690,13 +587,11 @@ class BluetoothManager:
return history.device return history.device
return None return None
@hass_callback
def async_address_present(self, address: str, connectable: bool) -> bool: def async_address_present(self, address: str, connectable: bool) -> bool:
"""Return if the address is present.""" """Return if the address is present."""
histories = self._connectable_history if connectable else self._all_history histories = self._connectable_history if connectable else self._all_history
return address in histories return address in histories
@hass_callback
def async_discovered_service_info( def async_discovered_service_info(
self, connectable: bool self, connectable: bool
) -> Iterable[BluetoothServiceInfoBleak]: ) -> Iterable[BluetoothServiceInfoBleak]:
@@ -704,7 +599,6 @@ class BluetoothManager:
histories = self._connectable_history if connectable else self._all_history histories = self._connectable_history if connectable else self._all_history
return histories.values() return histories.values()
@hass_callback
def async_last_service_info( def async_last_service_info(
self, address: str, connectable: bool self, address: str, connectable: bool
) -> BluetoothServiceInfoBleak | None: ) -> BluetoothServiceInfoBleak | None:
@@ -712,28 +606,6 @@ class BluetoothManager:
histories = self._connectable_history if connectable else self._all_history histories = self._connectable_history if connectable else self._all_history
return histories.get(address) return histories.get(address)
def _async_trigger_matching_discovery(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""Trigger discovery for matching domains."""
for domain in self._integration_matcher.match_domains(service_info):
discovery_flow.async_create_flow(
self.hass,
domain,
{"source": config_entries.SOURCE_BLUETOOTH},
service_info,
)
@hass_callback
def async_rediscover_address(self, address: str) -> None:
"""Trigger discovery of devices which have already been seen."""
self._integration_matcher.async_clear_address(address)
if service_info := self._connectable_history.get(address):
self._async_trigger_matching_discovery(service_info)
return
if service_info := self._all_history.get(address):
self._async_trigger_matching_discovery(service_info)
def async_register_scanner( def async_register_scanner(
self, self,
scanner: BaseHaScanner, scanner: BaseHaScanner,
@@ -761,7 +633,6 @@ class BluetoothManager:
self.slot_manager.register_adapter(scanner.adapter, connection_slots) self.slot_manager.register_adapter(scanner.adapter, connection_slots)
return _unregister_scanner return _unregister_scanner
@hass_callback
def async_register_bleak_callback( def async_register_bleak_callback(
self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] self, callback: AdvertisementDataCallback, filters: dict[str, set[str]]
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
@@ -769,7 +640,6 @@ class BluetoothManager:
callback_entry = (callback, filters) callback_entry = (callback, filters)
self._bleak_callbacks.append(callback_entry) self._bleak_callbacks.append(callback_entry)
@hass_callback
def _remove_callback() -> None: def _remove_callback() -> None:
self._bleak_callbacks.remove(callback_entry) self._bleak_callbacks.remove(callback_entry)
@@ -783,29 +653,180 @@ class BluetoothManager:
return _remove_callback return _remove_callback
@hass_callback
def async_release_connection_slot(self, device: BLEDevice) -> None: def async_release_connection_slot(self, device: BLEDevice) -> None:
"""Release a connection slot.""" """Release a connection slot."""
self.slot_manager.release_slot(device) self.slot_manager.release_slot(device)
@hass_callback
def async_allocate_connection_slot(self, device: BLEDevice) -> bool: def async_allocate_connection_slot(self, device: BLEDevice) -> bool:
"""Allocate a connection slot.""" """Allocate a connection slot."""
return self.slot_manager.allocate_slot(device) return self.slot_manager.allocate_slot(device)
@hass_callback
def async_get_learned_advertising_interval(self, address: str) -> float | None: def async_get_learned_advertising_interval(self, address: str) -> float | None:
"""Get the learned advertising interval for a MAC address.""" """Get the learned advertising interval for a MAC address."""
return self._intervals.get(address) return self._intervals.get(address)
@hass_callback
def async_get_fallback_availability_interval(self, address: str) -> float | None: def async_get_fallback_availability_interval(self, address: str) -> float | None:
"""Get the fallback availability timeout for a MAC address.""" """Get the fallback availability timeout for a MAC address."""
return self._fallback_intervals.get(address) return self._fallback_intervals.get(address)
@hass_callback
def async_set_fallback_availability_interval( def async_set_fallback_availability_interval(
self, address: str, interval: float self, address: str, interval: float
) -> None: ) -> None:
"""Override the fallback availability timeout for a MAC address.""" """Override the fallback availability timeout for a MAC address."""
self._fallback_intervals[address] = interval self._fallback_intervals[address] = interval
class HomeAssistantBluetoothManager(BluetoothManager):
"""Manage Bluetooth for Home Assistant."""
__slots__ = (
"hass",
"_integration_matcher",
"_callback_index",
"_cancel_logging_listener",
)
def __init__(
self,
hass: HomeAssistant,
integration_matcher: IntegrationMatcher,
bluetooth_adapters: BluetoothAdapters,
storage: BluetoothStorage,
slot_manager: BleakSlotManager,
) -> None:
"""Init bluetooth manager."""
self.hass = hass
self._integration_matcher = integration_matcher
self._callback_index = BluetoothCallbackMatcherIndex()
self._cancel_logging_listener: CALLBACK_TYPE | None = None
super().__init__(bluetooth_adapters, storage, slot_manager)
@hass_callback
def _async_logging_changed(self, event: Event) -> None:
"""Handle logging change."""
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
def _async_trigger_matching_discovery(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""Trigger discovery for matching domains."""
for domain in self._integration_matcher.match_domains(service_info):
discovery_flow.async_create_flow(
self.hass,
domain,
{"source": config_entries.SOURCE_BLUETOOTH},
service_info,
)
@hass_callback
def async_rediscover_address(self, address: str) -> None:
"""Trigger discovery of devices which have already been seen."""
self._integration_matcher.async_clear_address(address)
if service_info := self._connectable_history.get(address):
self._async_trigger_matching_discovery(service_info)
return
if service_info := self._all_history.get(address):
self._async_trigger_matching_discovery(service_info)
def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None:
matched_domains = self._integration_matcher.match_domains(service_info)
if self._debug:
_LOGGER.debug(
"%s: %s %s match: %s",
self._async_describe_source(service_info),
service_info.address,
service_info.advertisement,
matched_domains,
)
for match in self._callback_index.match_callbacks(service_info):
callback = match[CALLBACK]
try:
callback(service_info, BluetoothChange.ADVERTISEMENT)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in bluetooth callback")
for domain in matched_domains:
discovery_flow.async_create_flow(
self.hass,
domain,
{"source": config_entries.SOURCE_BLUETOOTH},
service_info,
)
def _address_disappeared(self, address: str) -> None:
"""Dismiss all discoveries for the given address."""
self._integration_matcher.async_clear_address(address)
for flow in self.hass.config_entries.flow.async_progress_by_init_data_type(
BluetoothServiceInfoBleak,
lambda service_info: bool(service_info.address == address),
):
self.hass.config_entries.flow.async_abort(flow["flow_id"])
async def async_setup(self) -> None:
"""Set up the bluetooth manager."""
await super().async_setup()
self._all_history, self._connectable_history = async_load_history_from_system(
self._bluetooth_adapters, self.storage
)
self._cancel_logging_listener = self.hass.bus.async_listen(
EVENT_LOGGING_CHANGED, self._async_logging_changed
)
seen: set[str] = set()
for address, service_info in itertools.chain(
self._connectable_history.items(), self._all_history.items()
):
if address in seen:
continue
seen.add(address)
self._async_trigger_matching_discovery(service_info)
def async_register_callback(
self,
callback: BluetoothCallback,
matcher: BluetoothCallbackMatcher | None,
) -> Callable[[], None]:
"""Register a callback."""
callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback)
if not matcher:
callback_matcher[CONNECTABLE] = True
else:
# We could write out every item in the typed dict here
# but that would be a bit inefficient and verbose.
callback_matcher.update(matcher)
callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True)
connectable = callback_matcher[CONNECTABLE]
self._callback_index.add_callback_matcher(callback_matcher)
def _async_remove_callback() -> None:
self._callback_index.remove_callback_matcher(callback_matcher)
# If we have history for the subscriber, we can trigger the callback
# immediately with the last packet so the subscriber can see the
# device.
history = self._connectable_history if connectable else self._all_history
service_infos: Iterable[BluetoothServiceInfoBleak] = []
if address := callback_matcher.get(ADDRESS):
if service_info := history.get(address):
service_infos = [service_info]
else:
service_infos = history.values()
for service_info in service_infos:
if ble_device_matches(callback_matcher, service_info):
try:
callback(service_info, BluetoothChange.ADVERTISEMENT)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in bluetooth callback")
return _async_remove_callback
@hass_callback
def async_stop(self) -> None:
"""Stop the Bluetooth integration at shutdown."""
_LOGGER.debug("Stopping bluetooth manager")
super().async_stop()
if self._cancel_logging_listener:
self._cancel_logging_listener()
self._cancel_logging_listener = None

View File

@@ -18,7 +18,8 @@
"bleak-retry-connector==3.3.0", "bleak-retry-connector==3.3.0",
"bluetooth-adapters==0.16.1", "bluetooth-adapters==0.16.1",
"bluetooth-auto-recovery==1.2.3", "bluetooth-auto-recovery==1.2.3",
"bluetooth-data-tools==1.15.0", "bluetooth-data-tools==1.17.0",
"dbus-fast==2.14.0" "dbus-fast==2.20.0",
"habluetooth==0.10.0"
] ]
} }

View File

@@ -2,15 +2,12 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Final from typing import TYPE_CHECKING, Final
from bleak import BaseBleakClient from bluetooth_data_tools import monotonic_time_coarse
from home_assistant_bluetooth import BluetoothServiceInfoBleak from home_assistant_bluetooth import BluetoothServiceInfoBleak
from homeassistant.util.dt import monotonic_time_coarse
if TYPE_CHECKING: if TYPE_CHECKING:
from .manager import BluetoothManager from .manager import BluetoothManager
@@ -20,22 +17,6 @@ MANAGER: BluetoothManager | None = None
MONOTONIC_TIME: Final = monotonic_time_coarse MONOTONIC_TIME: Final = monotonic_time_coarse
@dataclass(slots=True)
class HaBluetoothConnector:
"""Data for how to connect a BLEDevice from a given scanner."""
client: type[BaseBleakClient]
source: str
can_connect: Callable[[], bool]
class BluetoothScanningMode(Enum):
"""The mode of scanning for bluetooth devices."""
PASSIVE = "passive"
ACTIVE = "active"
BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT") BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT")
BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None] BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None]
ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool] ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool]

View File

@@ -7,6 +7,8 @@ from functools import cache
import logging import logging
from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast
from habluetooth import BluetoothScanningMode
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import ( from homeassistant.const import (
ATTR_CONNECTIONS, ATTR_CONNECTIONS,
@@ -33,11 +35,7 @@ if TYPE_CHECKING:
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .models import ( from .models import BluetoothChange, BluetoothServiceInfoBleak
BluetoothChange,
BluetoothScanningMode,
BluetoothServiceInfoBleak,
)
STORAGE_KEY = "bluetooth.passive_update_processor" STORAGE_KEY = "bluetooth.passive_update_processor"
STORAGE_VERSION = 1 STORAGE_VERSION = 1

View File

@@ -1,386 +0,0 @@
"""The bluetooth integration."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import datetime
import logging
import platform
from typing import Any
import bleak
from bleak import BleakError
from bleak.assigned_numbers import AdvertisementDataType
from bleak.backends.bluezdbus.advertisement_monitor import OrPattern
from bleak.backends.bluezdbus.scanner import BlueZScannerArgs
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback
from bleak_retry_connector import restore_discoveries
from bluetooth_adapters import DEFAULT_ADDRESS
from dbus_fast import InvalidMessageError
from homeassistant.core import HomeAssistant, callback as hass_callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util.package import is_docker_env
from .base_scanner import MONOTONIC_TIME, BaseHaScanner
from .const import (
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
SOURCE_LOCAL,
START_TIMEOUT,
)
from .models import BluetoothScanningMode, BluetoothServiceInfoBleak
from .util import async_reset_adapter
OriginalBleakScanner = bleak.BleakScanner
# or_patterns is a workaround for the fact that passive scanning
# needs at least one matcher to be set. The below matcher
# will match all devices.
PASSIVE_SCANNER_ARGS = BlueZScannerArgs(
or_patterns=[
OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"),
OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"),
]
)
_LOGGER = logging.getLogger(__name__)
# If the adapter is in a stuck state the following errors are raised:
NEED_RESET_ERRORS = [
"org.bluez.Error.Failed",
"org.bluez.Error.InProgress",
"org.bluez.Error.NotReady",
"not found",
]
# When the adapter is still initializing, the scanner will raise an exception
# with org.freedesktop.DBus.Error.UnknownObject
WAIT_FOR_ADAPTER_TO_INIT_ERRORS = ["org.freedesktop.DBus.Error.UnknownObject"]
ADAPTER_INIT_TIME = 1.5
START_ATTEMPTS = 3
SCANNING_MODE_TO_BLEAK = {
BluetoothScanningMode.ACTIVE: "active",
BluetoothScanningMode.PASSIVE: "passive",
}
# The minimum number of seconds to know
# the adapter has not had advertisements
# and we already tried to restart the scanner
# without success when the first time the watch
# dog hit the failure path.
SCANNER_WATCHDOG_MULTIPLE = (
SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds()
)
class ScannerStartError(HomeAssistantError):
"""Error to indicate that the scanner failed to start."""
def create_bleak_scanner(
detection_callback: AdvertisementDataCallback,
scanning_mode: BluetoothScanningMode,
adapter: str | None,
) -> bleak.BleakScanner:
"""Create a Bleak scanner."""
scanner_kwargs: dict[str, Any] = {
"detection_callback": detection_callback,
"scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode],
}
system = platform.system()
if system == "Linux":
# Only Linux supports multiple adapters
if adapter:
scanner_kwargs["adapter"] = adapter
if scanning_mode == BluetoothScanningMode.PASSIVE:
scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS
elif system == "Darwin":
# We want mac address on macOS
scanner_kwargs["cb"] = {"use_bdaddr": True}
_LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs)
try:
return OriginalBleakScanner(**scanner_kwargs)
except (FileNotFoundError, BleakError) as ex:
raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex
class HaScanner(BaseHaScanner):
"""Operate and automatically recover a BleakScanner.
Multiple BleakScanner can be used at the same time
if there are multiple adapters. This is only useful
if the adapters are not located physically next to each other.
Example use cases are usbip, a long extension cable, usb to bluetooth
over ethernet, usb over ethernet, etc.
"""
scanner: bleak.BleakScanner
def __init__(
self,
hass: HomeAssistant,
mode: BluetoothScanningMode,
adapter: str,
address: str,
new_info_callback: Callable[[BluetoothServiceInfoBleak], None],
) -> None:
"""Init bluetooth discovery."""
self.mac_address = address
source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL
super().__init__(hass, source, adapter)
self.connectable = True
self.mode = mode
self._start_stop_lock = asyncio.Lock()
self._new_info_callback = new_info_callback
self.scanning = False
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
return self.scanner.discovered_devices
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and advertisement data."""
return self.scanner.discovered_devices_and_advertisement_data
@hass_callback
def async_setup(self) -> None:
"""Set up the scanner."""
self.scanner = create_bleak_scanner(
self._async_detection_callback, self.mode, self.adapter
)
async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner."""
base_diag = await super().async_diagnostics()
return base_diag | {
"adapter": self.adapter,
}
@hass_callback
def _async_detection_callback(
self,
device: BLEDevice,
advertisement_data: AdvertisementData,
) -> None:
"""Call the callback when an advertisement is received.
Currently this is used to feed the callbacks into the
central manager.
"""
callback_time = MONOTONIC_TIME()
if (
advertisement_data.local_name
or advertisement_data.manufacturer_data
or advertisement_data.service_data
or advertisement_data.service_uuids
):
# Don't count empty advertisements
# as the adapter is in a failure
# state if all the data is empty.
self._last_detection = callback_time
self._new_info_callback(
BluetoothServiceInfoBleak(
name=advertisement_data.local_name or device.name or device.address,
address=device.address,
rssi=advertisement_data.rssi,
manufacturer_data=advertisement_data.manufacturer_data,
service_data=advertisement_data.service_data,
service_uuids=advertisement_data.service_uuids,
source=self.source,
device=device,
advertisement=advertisement_data,
connectable=True,
time=callback_time,
)
)
async def async_start(self) -> None:
"""Start bluetooth scanner."""
async with self._start_stop_lock:
await self._async_start()
async def _async_start(self) -> None:
"""Start bluetooth scanner under the lock."""
for attempt in range(START_ATTEMPTS):
_LOGGER.debug(
"%s: Starting bluetooth discovery attempt: (%s/%s)",
self.name,
attempt + 1,
START_ATTEMPTS,
)
try:
async with asyncio.timeout(START_TIMEOUT):
await self.scanner.start() # type: ignore[no-untyped-call]
except InvalidMessageError as ex:
_LOGGER.debug(
"%s: Invalid DBus message received: %s",
self.name,
ex,
exc_info=True,
)
raise ScannerStartError(
f"{self.name}: Invalid DBus message received: {ex}; "
"try restarting `dbus`"
) from ex
except BrokenPipeError as ex:
_LOGGER.debug(
"%s: DBus connection broken: %s", self.name, ex, exc_info=True
)
if is_docker_env():
raise ScannerStartError(
f"{self.name}: DBus connection broken: {ex}; try restarting "
"`bluetooth`, `dbus`, and finally the docker container"
) from ex
raise ScannerStartError(
f"{self.name}: DBus connection broken: {ex}; try restarting "
"`bluetooth` and `dbus`"
) from ex
except FileNotFoundError as ex:
_LOGGER.debug(
"%s: FileNotFoundError while starting bluetooth: %s",
self.name,
ex,
exc_info=True,
)
if is_docker_env():
raise ScannerStartError(
f"{self.name}: DBus service not found; docker config may "
"be missing `-v /run/dbus:/run/dbus:ro`: {ex}"
) from ex
raise ScannerStartError(
f"{self.name}: DBus service not found; make sure the DBus socket "
f"is available to Home Assistant: {ex}"
) from ex
except asyncio.TimeoutError as ex:
if attempt == 0:
await self._async_reset_adapter()
continue
raise ScannerStartError(
f"{self.name}: Timed out starting Bluetooth after"
f" {START_TIMEOUT} seconds"
) from ex
except BleakError as ex:
error_str = str(ex)
if attempt == 0:
if any(
needs_reset_error in error_str
for needs_reset_error in NEED_RESET_ERRORS
):
await self._async_reset_adapter()
continue
if attempt != START_ATTEMPTS - 1:
# If we are not out of retry attempts, and the
# adapter is still initializing, wait a bit and try again.
if any(
wait_error in error_str
for wait_error in WAIT_FOR_ADAPTER_TO_INIT_ERRORS
):
_LOGGER.debug(
"%s: Waiting for adapter to initialize; attempt (%s/%s)",
self.name,
attempt + 1,
START_ATTEMPTS,
)
await asyncio.sleep(ADAPTER_INIT_TIME)
continue
_LOGGER.debug(
"%s: BleakError while starting bluetooth; attempt: (%s/%s): %s",
self.name,
attempt + 1,
START_ATTEMPTS,
ex,
exc_info=True,
)
raise ScannerStartError(
f"{self.name}: Failed to start Bluetooth: {ex}"
) from ex
# Everything is fine, break out of the loop
break
self.scanning = True
self._async_setup_scanner_watchdog()
await restore_discoveries(self.scanner, self.adapter)
@hass_callback
def _async_scanner_watchdog(self, now: datetime) -> None:
"""Check if the scanner is running."""
if not self._async_watchdog_triggered():
return
if self._start_stop_lock.locked():
_LOGGER.debug(
"%s: Scanner is already restarting, deferring restart",
self.name,
)
return
_LOGGER.info(
"%s: Bluetooth scanner has gone quiet for %ss, restarting",
self.name,
SCANNER_WATCHDOG_TIMEOUT,
)
# Immediately mark the scanner as not scanning
# since the restart task will have to wait for the lock
self.scanning = False
self.hass.async_create_task(self._async_restart_scanner())
async def _async_restart_scanner(self) -> None:
"""Restart the scanner."""
async with self._start_stop_lock:
time_since_last_detection = MONOTONIC_TIME() - self._last_detection
# Stop the scanner but not the watchdog
# since we want to try again later if it's still quiet
await self._async_stop_scanner()
# If there have not been any valid advertisements,
# or the watchdog has hit the failure path multiple times,
# do the reset.
if (
self._start_time == self._last_detection
or time_since_last_detection > SCANNER_WATCHDOG_MULTIPLE
):
await self._async_reset_adapter()
try:
await self._async_start()
except ScannerStartError as ex:
_LOGGER.exception(
"%s: Failed to restart Bluetooth scanner: %s",
self.name,
ex,
)
async def _async_reset_adapter(self) -> None:
"""Reset the adapter."""
# There is currently nothing the user can do to fix this
# so we log at debug level. If we later come up with a repair
# strategy, we will change this to raise a repair issue as well.
_LOGGER.debug("%s: adapter stopped responding; executing reset", self.name)
result = await async_reset_adapter(self.adapter, self.mac_address)
_LOGGER.debug("%s: adapter reset result: %s", self.name, result)
async def async_stop(self) -> None:
"""Stop bluetooth scanner."""
async with self._start_stop_lock:
self._async_stop_scanner_watchdog()
await self._async_stop_scanner()
async def _async_stop_scanner(self) -> None:
"""Stop bluetooth discovery under the lock."""
self.scanning = False
_LOGGER.debug("%s: Stopping bluetooth discovery", self.name)
try:
await self.scanner.stop() # type: ignore[no-untyped-call]
except BleakError as ex:
# This is not fatal, and they may want to reload
# the config entry to restart the scanner if they
# change the bluetooth dongle.
_LOGGER.error("%s: Error stopping scanner: %s", self.name, ex)

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import logging import logging
from habluetooth import BluetoothScanningMode
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from .api import ( from .api import (
@@ -13,7 +15,7 @@ from .api import (
async_track_unavailable, async_track_unavailable,
) )
from .match import BluetoothCallbackMatcher from .match import BluetoothCallbackMatcher
from .models import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .models import BluetoothChange, BluetoothServiceInfoBleak
class BasePassiveBluetoothCoordinator(ABC): class BasePassiveBluetoothCoordinator(ABC):

View File

@@ -2,10 +2,9 @@
from __future__ import annotations from __future__ import annotations
from bluetooth_adapters import BluetoothAdapters from bluetooth_adapters import BluetoothAdapters
from bluetooth_auto_recovery import recover_adapter from bluetooth_data_tools import monotonic_time_coarse
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.util.dt import monotonic_time_coarse
from .models import BluetoothServiceInfoBleak from .models import BluetoothServiceInfoBleak
from .storage import BluetoothStorage from .storage import BluetoothStorage
@@ -69,11 +68,3 @@ def async_load_history_from_system(
connectable_loaded_history[address] = service_info connectable_loaded_history[address] = service_info
return all_loaded_history, connectable_loaded_history return all_loaded_history, connectable_loaded_history
async def async_reset_adapter(adapter: str | None, mac_address: str) -> bool | None:
"""Reset the adapter."""
if adapter and adapter.startswith("hci"):
adapter_id = int(adapter[3:])
return await recover_adapter(adapter_id, mac_address)
return False

View File

@@ -283,7 +283,6 @@ class HaBleakClientWrapper(BleakClient):
self.__disconnected_callback self.__disconnected_callback
), ),
timeout=self.__timeout, timeout=self.__timeout,
hass=manager.hass,
) )
if debug_logging: if debug_logging:
# Only lookup the description if we are going to log it # Only lookup the description if we are going to log it

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["bimmer_connected"], "loggers": ["bimmer_connected"],
"requirements": ["bimmer-connected==0.14.3"] "requirements": ["bimmer-connected[china]==0.14.6"]
} }

View File

@@ -21,10 +21,10 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.percentage import ( from homeassistant.util.percentage import (
int_states_in_range,
percentage_to_ranged_value, percentage_to_ranged_value,
ranged_value_to_percentage, ranged_value_to_percentage,
) )
from homeassistant.util.scaling import int_states_in_range
from .const import DOMAIN, SERVICE_SET_FAN_SPEED_TRACKED_STATE from .const import DOMAIN, SERVICE_SET_FAN_SPEED_TRACKED_STATE
from .entity import BondEntity from .entity import BondEntity
@@ -199,10 +199,6 @@ class BondFan(BondEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan.""" """Set the preset mode of the fan."""
if preset_mode != PRESET_MODE_BREEZE or not self._device.has_action(
Action.BREEZE_ON
):
raise ValueError(f"Invalid preset mode: {preset_mode}")
await self._hub.bond.action(self._device.device_id, Action(Action.BREEZE_ON)) await self._hub.bond.action(self._device.device_id, Action(Action.BREEZE_ON))
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:

View File

@@ -12,6 +12,9 @@
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"access_token": "[%key:common::config_flow::data::access_token%]" "access_token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
"host": "The IP address of your Bond hub."
} }
} }
}, },

View File

@@ -6,6 +6,9 @@
"title": "SHC authentication parameters", "title": "SHC authentication parameters",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your Bosch Smart Home Controller."
} }
}, },
"credentials": { "credentials": {

View File

@@ -5,6 +5,9 @@
"description": "Ensure that your TV is turned on before trying to set it up.", "description": "Ensure that your TV is turned on before trying to set it up.",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of the Sony Bravia TV to control."
} }
}, },
"authorize": { "authorize": {

View File

@@ -3,10 +3,13 @@
"flow_title": "{name} ({model} at {host})", "flow_title": "{name} ({model} at {host})",
"step": { "step": {
"user": { "user": {
"title": "Connect to the device", "description": "Connect to the device",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"timeout": "Timeout" "timeout": "Timeout"
},
"data_description": {
"host": "The hostname or IP address of your Broadlink device."
} }
}, },
"auth": { "auth": {

View File

@@ -6,6 +6,9 @@
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"type": "Type of the printer" "type": "Type of the printer"
},
"data_description": {
"host": "The hostname or IP address of the Brother printer to control."
} }
}, },
"zeroconf_confirm": { "zeroconf_confirm": {

View File

@@ -60,8 +60,7 @@ async def async_setup_entry(
data.static, data.static,
entry, entry,
) )
], ]
True,
) )

View File

@@ -11,6 +11,9 @@
"passkey": "Passkey string", "passkey": "Passkey string",
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"host": "The hostname or IP address of your BSB-Lan device."
} }
} }
}, },

View File

@@ -11,7 +11,11 @@ async def async_get_calendars(
hass: HomeAssistant, client: caldav.DAVClient, component: str hass: HomeAssistant, client: caldav.DAVClient, component: str
) -> list[caldav.Calendar]: ) -> list[caldav.Calendar]:
"""Get all calendars that support the specified component.""" """Get all calendars that support the specified component."""
calendars = await hass.async_add_executor_job(client.principal().calendars)
def _get_calendars() -> list[caldav.Calendar]:
return client.principal().calendars()
calendars = await hass.async_add_executor_job(_get_calendars)
components_results = await asyncio.gather( components_results = await asyncio.gather(
*[ *[
hass.async_add_executor_job(calendar.get_supported_components) hass.async_add_executor_job(calendar.get_supported_components)

View File

@@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from datetime import timedelta from datetime import date, datetime, timedelta
from functools import partial from functools import partial
import logging import logging
from typing import cast from typing import Any, cast
import caldav import caldav
from caldav.lib.error import DAVError, NotFoundError from caldav.lib.error import DAVError, NotFoundError
@@ -21,6 +21,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .api import async_get_calendars, get_attr_value from .api import async_get_calendars, get_attr_value
from .const import DOMAIN from .const import DOMAIN
@@ -71,6 +72,12 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None:
or (summary := get_attr_value(todo, "summary")) is None or (summary := get_attr_value(todo, "summary")) is None
): ):
return None return None
due: date | datetime | None = None
if due_value := get_attr_value(todo, "due"):
if isinstance(due_value, datetime):
due = dt_util.as_local(due_value)
elif isinstance(due_value, date):
due = due_value
return TodoItem( return TodoItem(
uid=uid, uid=uid,
summary=summary, summary=summary,
@@ -78,9 +85,25 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None:
get_attr_value(todo, "status") or "", get_attr_value(todo, "status") or "",
TodoItemStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION,
), ),
due=due,
description=get_attr_value(todo, "description"),
) )
def _to_ics_fields(item: TodoItem) -> dict[str, Any]:
"""Convert a TodoItem to the set of add or update arguments."""
item_data: dict[str, Any] = {}
if summary := item.summary:
item_data["summary"] = summary
if status := item.status:
item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION")
if due := item.due:
item_data["due"] = due
if description := item.description:
item_data["description"] = description
return item_data
class WebDavTodoListEntity(TodoListEntity): class WebDavTodoListEntity(TodoListEntity):
"""CalDAV To-do list entity.""" """CalDAV To-do list entity."""
@@ -89,6 +112,9 @@ class WebDavTodoListEntity(TodoListEntity):
TodoListEntityFeature.CREATE_TODO_ITEM TodoListEntityFeature.CREATE_TODO_ITEM
| TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM
| TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM
| TodoListEntityFeature.SET_DUE_DATE_ON_ITEM
| TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM
| TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
) )
def __init__(self, calendar: caldav.Calendar, config_entry_id: str) -> None: def __init__(self, calendar: caldav.Calendar, config_entry_id: str) -> None:
@@ -116,13 +142,7 @@ class WebDavTodoListEntity(TodoListEntity):
"""Add an item to the To-do list.""" """Add an item to the To-do list."""
try: try:
await self.hass.async_add_executor_job( await self.hass.async_add_executor_job(
partial( partial(self._calendar.save_todo, **_to_ics_fields(item)),
self._calendar.save_todo,
summary=item.summary,
status=TODO_STATUS_MAP_INV.get(
item.status or TodoItemStatus.NEEDS_ACTION, "NEEDS-ACTION"
),
),
) )
except (requests.ConnectionError, DAVError) as err: except (requests.ConnectionError, DAVError) as err:
raise HomeAssistantError(f"CalDAV save error: {err}") from err raise HomeAssistantError(f"CalDAV save error: {err}") from err
@@ -139,10 +159,10 @@ class WebDavTodoListEntity(TodoListEntity):
except (requests.ConnectionError, DAVError) as err: except (requests.ConnectionError, DAVError) as err:
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
vtodo = todo.icalendar_component # type: ignore[attr-defined] vtodo = todo.icalendar_component # type: ignore[attr-defined]
if item.summary: updated_fields = _to_ics_fields(item)
vtodo["summary"] = item.summary if "due" in updated_fields:
if item.status: todo.set_due(updated_fields.pop("due")) # type: ignore[attr-defined]
vtodo["status"] = TODO_STATUS_MAP_INV.get(item.status, "NEEDS-ACTION") vtodo.update(**updated_fields)
try: try:
await self.hass.async_add_executor_job( await self.hass.async_add_executor_job(
partial( partial(

View File

@@ -73,7 +73,7 @@
} }
}, },
"get_events": { "get_events": {
"name": "Get event", "name": "Get events",
"description": "Get events on a calendar within a time range.", "description": "Get events on a calendar within a time range.",
"fields": { "fields": {
"start_date_time": { "start_date_time": {

View File

@@ -1,7 +1,7 @@
{ {
"domain": "co2signal", "domain": "co2signal",
"name": "Electricity Maps", "name": "Electricity Maps",
"codeowners": ["@jpbede"], "codeowners": ["@jpbede", "@VIKTORVAV99"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/co2signal", "documentation": "https://www.home-assistant.io/integrations/co2signal",
"integration_type": "service", "integration_type": "service",

View File

@@ -68,13 +68,13 @@ class ComelitSerialBridge(DataUpdateCoordinator):
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
"""Update device data.""" """Update device data."""
_LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host)
try: try:
await self.api.login() await self.api.login()
return await self.api.get_all_devices()
except exceptions.CannotConnect as err: except exceptions.CannotConnect as err:
_LOGGER.warning("Connection error for %s", self._host) _LOGGER.warning("Connection error for %s", self._host)
await self.api.close() await self.api.close()
raise UpdateFailed(f"Error fetching data: {repr(err)}") from err raise UpdateFailed(f"Error fetching data: {repr(err)}") from err
except exceptions.CannotAuthenticate: except exceptions.CannotAuthenticate:
raise ConfigEntryAuthFailed raise ConfigEntryAuthFailed
return await self.api.get_all_devices()

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/comelit", "documentation": "https://www.home-assistant.io/integrations/comelit",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aiocomelit"], "loggers": ["aiocomelit"],
"requirements": ["aiocomelit==0.5.2"] "requirements": ["aiocomelit==0.6.2"]
} }

View File

@@ -13,6 +13,9 @@
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]", "port": "[%key:common::config_flow::data::port%]",
"pin": "[%key:common::config_flow::data::pin%]" "pin": "[%key:common::config_flow::data::pin%]"
},
"data_description": {
"host": "The hostname or IP address of your Comelit device."
} }
} }
}, },

View File

@@ -22,10 +22,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.percentage import ( from homeassistant.util.percentage import (
int_states_in_range,
percentage_to_ranged_value, percentage_to_ranged_value,
ranged_value_to_percentage, ranged_value_to_percentage,
) )
from homeassistant.util.scaling import int_states_in_range
from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge

View File

@@ -649,7 +649,7 @@ class DefaultAgent(AbstractConversationAgent):
if device_area is None: if device_area is None:
return None return None
return {"area": device_area.name} return {"area": device_area.id}
def _get_error_text( def _get_error_text(
self, response_type: ResponseType, lang_intents: LanguageIntents | None self, response_type: ResponseType, lang_intents: LanguageIntents | None

View File

@@ -7,5 +7,5 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "local_push", "iot_class": "local_push",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==1.5.1", "home-assistant-intents==2023.11.17"] "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.12.05"]
} }

View File

@@ -2,7 +2,7 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Set up your CoolMasterNet connection details.", "description": "Set up your CoolMasterNet connection details.",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"off": "Can be turned off", "off": "Can be turned off",
@@ -12,6 +12,9 @@
"dry": "Support dry mode", "dry": "Support dry mode",
"fan_only": "Support fan only mode", "fan_only": "Support fan only mode",
"swing_support": "Control swing mode" "swing_support": "Control swing mode"
},
"data_description": {
"host": "The hostname or IP address of your CoolMasterNet device."
} }
} }
}, },

View File

@@ -67,7 +67,7 @@ DECONZ_TO_COLOR_MODE = {
LightColorMode.XY: ColorMode.XY, LightColorMode.XY: ColorMode.XY,
} }
TS0601_EFFECTS = [ XMAS_LIGHT_EFFECTS = [
"carnival", "carnival",
"collide", "collide",
"fading", "fading",
@@ -200,8 +200,8 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity):
if device.effect is not None: if device.effect is not None:
self._attr_supported_features |= LightEntityFeature.EFFECT self._attr_supported_features |= LightEntityFeature.EFFECT
self._attr_effect_list = [EFFECT_COLORLOOP] self._attr_effect_list = [EFFECT_COLORLOOP]
if device.model_id == "TS0601": if device.model_id in ("HG06467", "TS0601"):
self._attr_effect_list += TS0601_EFFECTS self._attr_effect_list = XMAS_LIGHT_EFFECTS
@property @property
def color_mode(self) -> str | None: def color_mode(self) -> str | None:

View File

@@ -11,11 +11,14 @@
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]" "port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of your deCONZ host."
} }
}, },
"link": { "link": {
"title": "Link with deCONZ", "title": "Link with deCONZ",
"description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button" "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings > Gateway > Advanced\n2. Press \"Authenticate app\" button"
}, },
"hassio_confirm": { "hassio_confirm": {
"title": "deCONZ Zigbee gateway via Home Assistant add-on", "title": "deCONZ Zigbee gateway via Home Assistant add-on",

View File

@@ -9,6 +9,9 @@
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]", "port": "[%key:common::config_flow::data::port%]",
"web_port": "Web port (for visiting service)" "web_port": "Web port (for visiting service)"
},
"data_description": {
"host": "The hostname or IP address of your Deluge device."
} }
} }
}, },

View File

@@ -161,12 +161,9 @@ class DemoPercentageFan(BaseDemoFan, FanEntity):
def set_preset_mode(self, preset_mode: str) -> None: def set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode.""" """Set new preset mode."""
if self.preset_modes and preset_mode in self.preset_modes: self._preset_mode = preset_mode
self._preset_mode = preset_mode self._percentage = None
self._percentage = None self.schedule_update_ha_state()
self.schedule_update_ha_state()
else:
raise ValueError(f"Invalid preset mode: {preset_mode}")
def turn_on( def turn_on(
self, self,
@@ -230,10 +227,6 @@ class AsyncDemoPercentageFan(BaseDemoFan, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode.""" """Set new preset mode."""
if self.preset_modes is None or preset_mode not in self.preset_modes:
raise ValueError(
f"{preset_mode} is not a valid preset_mode: {self.preset_modes}"
)
self._preset_mode = preset_mode self._preset_mode = preset_mode
self._percentage = None self._percentage = None
self.async_write_ha_state() self.async_write_ha_state()

View File

@@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=5) SCAN_INTERVAL = timedelta(seconds=5)
class DevialetCoordinator(DataUpdateCoordinator): class DevialetCoordinator(DataUpdateCoordinator[None]):
"""Devialet update coordinator.""" """Devialet update coordinator."""
def __init__(self, hass: HomeAssistant, client: DevialetApi) -> None: def __init__(self, hass: HomeAssistant, client: DevialetApi) -> None:
@@ -27,6 +27,6 @@ class DevialetCoordinator(DataUpdateCoordinator):
) )
self.client = client self.client = client
async def _async_update_data(self): async def _async_update_data(self) -> None:
"""Fetch data from API endpoint.""" """Fetch data from API endpoint."""
await self.client.async_update() await self.client.async_update()

View File

@@ -46,13 +46,15 @@ async def async_setup_entry(
async_add_entities([DevialetMediaPlayerEntity(coordinator, entry)]) async_add_entities([DevialetMediaPlayerEntity(coordinator, entry)])
class DevialetMediaPlayerEntity(CoordinatorEntity, MediaPlayerEntity): class DevialetMediaPlayerEntity(
CoordinatorEntity[DevialetCoordinator], MediaPlayerEntity
):
"""Devialet media player.""" """Devialet media player."""
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_name = None _attr_name = None
def __init__(self, coordinator, entry: ConfigEntry) -> None: def __init__(self, coordinator: DevialetCoordinator, entry: ConfigEntry) -> None:
"""Initialize the Devialet device.""" """Initialize the Devialet device."""
self.coordinator = coordinator self.coordinator = coordinator
super().__init__(coordinator) super().__init__(coordinator)

View File

@@ -14,7 +14,11 @@ import voluptuous as vol
from homeassistant import util from homeassistant import util
from homeassistant.backports.functools import cached_property from homeassistant.backports.functools import cached_property
from homeassistant.components import zone from homeassistant.components import zone
from homeassistant.config import async_log_schema_error, load_yaml_config_file from homeassistant.config import (
async_log_schema_error,
config_per_platform,
load_yaml_config_file,
)
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
ATTR_GPS_ACCURACY, ATTR_GPS_ACCURACY,
@@ -33,7 +37,6 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import ( from homeassistant.helpers import (
config_per_platform,
config_validation as cv, config_validation as cv,
discovery, discovery,
entity_registry as er, entity_registry as er,
@@ -284,7 +287,7 @@ class DeviceTrackerPlatform:
) -> None: ) -> None:
"""Set up a legacy platform.""" """Set up a legacy platform."""
assert self.type == PLATFORM_TYPE_LEGACY assert self.type == PLATFORM_TYPE_LEGACY
full_name = f"{DOMAIN}.{self.name}" full_name = f"{self.name}.{DOMAIN}"
LOGGER.info("Setting up %s", full_name) LOGGER.info("Setting up %s", full_name)
with async_start_setup(hass, [full_name]): with async_start_setup(hass, [full_name]):
try: try:
@@ -1033,6 +1036,19 @@ def update_config(path: str, dev_id: str, device: Device) -> None:
out.write(dump(device_config)) out.write(dump(device_config))
def remove_device_from_config(hass: HomeAssistant, device_id: str) -> None:
"""Remove device from YAML configuration file."""
path = hass.config.path(YAML_DEVICES)
devices = load_yaml_config_file(path)
devices.pop(device_id)
dumped = dump(devices)
with open(path, "r+", encoding="utf8") as out:
out.seek(0)
out.truncate()
out.write(dumped)
def get_gravatar_for_email(email: str) -> str: def get_gravatar_for_email(email: str) -> str:
"""Return an 80px Gravatar for the given email address. """Return an 80px Gravatar for the given email address.

View File

@@ -8,6 +8,9 @@
"user": { "user": {
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your DirectTV device."
} }
} }
}, },

View File

@@ -183,6 +183,7 @@ async def async_setup_entry(
for description in sensors for description in sensors
for value_key in {description.key, *description.alternative_keys} for value_key in {description.key, *description.alternative_keys}
if description.value_fn(coordinator.data, value_key, description.scale) if description.value_fn(coordinator.data, value_key, description.scale)
is not None
) )
async_add_entities(entities) async_add_entities(entities)

View File

@@ -9,6 +9,7 @@
"use_legacy_protocol": "Use legacy protocol" "use_legacy_protocol": "Use legacy protocol"
}, },
"data_description": { "data_description": {
"host": "The hostname or IP address of your D-Link device",
"password": "Default: PIN code on the back." "password": "Default: PIN code on the back."
} }
}, },

View File

@@ -17,8 +17,11 @@
"data": { "data": {
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"name": "Device Name", "name": "Device name",
"username": "[%key:common::config_flow::data::username%]" "username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The hostname or IP address of your DoorBird device."
} }
} }
}, },

View File

@@ -4,6 +4,9 @@
"user": { "user": {
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your Dremel 3D printer."
} }
} }
}, },

View File

@@ -116,7 +116,7 @@ class DSMRConnection:
try: try:
transport, protocol = await asyncio.create_task(reader_factory()) transport, protocol = await asyncio.create_task(reader_factory())
except (serial.serialutil.SerialException, OSError): except (serial.SerialException, OSError):
LOGGER.exception("Error connecting to DSMR") LOGGER.exception("Error connecting to DSMR")
return False return False

View File

@@ -12,8 +12,6 @@ LOGGER = logging.getLogger(__package__)
PLATFORMS = [Platform.SENSOR] PLATFORMS = [Platform.SENSOR]
CONF_DSMR_VERSION = "dsmr_version" CONF_DSMR_VERSION = "dsmr_version"
CONF_PROTOCOL = "protocol" CONF_PROTOCOL = "protocol"
CONF_RECONNECT_INTERVAL = "reconnect_interval"
CONF_PRECISION = "precision"
CONF_TIME_BETWEEN_UPDATE = "time_between_update" CONF_TIME_BETWEEN_UPDATE = "time_between_update"
CONF_SERIAL_ID = "serial_id" CONF_SERIAL_ID = "serial_id"
@@ -29,6 +27,7 @@ DATA_TASK = "task"
DEVICE_NAME_ELECTRICITY = "Electricity Meter" DEVICE_NAME_ELECTRICITY = "Electricity Meter"
DEVICE_NAME_GAS = "Gas Meter" DEVICE_NAME_GAS = "Gas Meter"
DEVICE_NAME_WATER = "Water Meter"
DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"} DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"}

View File

@@ -34,6 +34,7 @@ from homeassistant.const import (
UnitOfVolume, UnitOfVolume,
) )
from homeassistant.core import CoreState, Event, HomeAssistant, callback from homeassistant.core import CoreState, Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.dispatcher import (
async_dispatcher_connect, async_dispatcher_connect,
@@ -45,9 +46,7 @@ from homeassistant.util import Throttle
from .const import ( from .const import (
CONF_DSMR_VERSION, CONF_DSMR_VERSION,
CONF_PRECISION,
CONF_PROTOCOL, CONF_PROTOCOL,
CONF_RECONNECT_INTERVAL,
CONF_SERIAL_ID, CONF_SERIAL_ID,
CONF_SERIAL_ID_GAS, CONF_SERIAL_ID_GAS,
CONF_TIME_BETWEEN_UPDATE, CONF_TIME_BETWEEN_UPDATE,
@@ -57,6 +56,7 @@ from .const import (
DEFAULT_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE,
DEVICE_NAME_ELECTRICITY, DEVICE_NAME_ELECTRICITY,
DEVICE_NAME_GAS, DEVICE_NAME_GAS,
DEVICE_NAME_WATER,
DOMAIN, DOMAIN,
DSMR_PROTOCOL, DSMR_PROTOCOL,
LOGGER, LOGGER,
@@ -73,10 +73,18 @@ class DSMRSensorEntityDescription(SensorEntityDescription):
dsmr_versions: set[str] | None = None dsmr_versions: set[str] | None = None
is_gas: bool = False is_gas: bool = False
is_water: bool = False
obis_reference: str obis_reference: str
SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
DSMRSensorEntityDescription(
key="timestamp",
obis_reference=obis_references.P1_MESSAGE_TIMESTAMP,
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
DSMRSensorEntityDescription( DSMRSensorEntityDescription(
key="current_electricity_usage", key="current_electricity_usage",
translation_key="current_electricity_usage", translation_key="current_electricity_usage",
@@ -374,28 +382,138 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
) )
def add_gas_sensor_5B(telegram: dict[str, DSMRObject]) -> DSMRSensorEntityDescription: def create_mbus_entity(
"""Return correct entity for 5B Gas meter.""" mbus: int, mtype: int, telegram: dict[str, DSMRObject]
ref = None ) -> DSMRSensorEntityDescription | None:
if obis_references.BELGIUM_MBUS1_METER_READING2 in telegram: """Create a new MBUS Entity."""
ref = obis_references.BELGIUM_MBUS1_METER_READING2 if (
elif obis_references.BELGIUM_MBUS2_METER_READING2 in telegram: mtype == 3
ref = obis_references.BELGIUM_MBUS2_METER_READING2 and (
elif obis_references.BELGIUM_MBUS3_METER_READING2 in telegram: obis_reference := getattr(
ref = obis_references.BELGIUM_MBUS3_METER_READING2 obis_references, f"BELGIUM_MBUS{mbus}_METER_READING2"
elif obis_references.BELGIUM_MBUS4_METER_READING2 in telegram: )
ref = obis_references.BELGIUM_MBUS4_METER_READING2 )
elif ref is None: in telegram
ref = obis_references.BELGIUM_MBUS1_METER_READING2 ):
return DSMRSensorEntityDescription( return DSMRSensorEntityDescription(
key="belgium_5min_gas_meter_reading", key=f"mbus{mbus}_gas_reading",
translation_key="gas_meter_reading", translation_key="gas_meter_reading",
obis_reference=ref, obis_reference=obis_reference,
dsmr_versions={"5B"}, is_gas=True,
is_gas=True, device_class=SensorDeviceClass.GAS,
device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING,
state_class=SensorStateClass.TOTAL_INCREASING, )
) if (
mtype == 7
and (
obis_reference := getattr(
obis_references, f"BELGIUM_MBUS{mbus}_METER_READING1"
)
)
in telegram
):
return DSMRSensorEntityDescription(
key=f"mbus{mbus}_water_reading",
translation_key="water_meter_reading",
obis_reference=obis_reference,
is_water=True,
device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
)
return None
def device_class_and_uom(
telegram: dict[str, DSMRObject],
entity_description: DSMRSensorEntityDescription,
) -> tuple[SensorDeviceClass | None, str | None]:
"""Get native unit of measurement from telegram,."""
dsmr_object = telegram[entity_description.obis_reference]
uom: str | None = getattr(dsmr_object, "unit") or None
with suppress(ValueError):
if entity_description.device_class == SensorDeviceClass.GAS and (
enery_uom := UnitOfEnergy(str(uom))
):
return (SensorDeviceClass.ENERGY, enery_uom)
if uom in UNIT_CONVERSION:
return (entity_description.device_class, UNIT_CONVERSION[uom])
return (entity_description.device_class, uom)
def rename_old_gas_to_mbus(
hass: HomeAssistant, entry: ConfigEntry, mbus_device_id: str
) -> None:
"""Rename old gas sensor to mbus variant."""
dev_reg = dr.async_get(hass)
device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)})
if device_entry_v1 is not None:
device_id = device_entry_v1.id
ent_reg = er.async_get(hass)
entries = er.async_entries_for_device(ent_reg, device_id)
for entity in entries:
if entity.unique_id.endswith("belgium_5min_gas_meter_reading"):
try:
ent_reg.async_update_entity(
entity.entity_id,
new_unique_id=mbus_device_id,
device_id=mbus_device_id,
)
except ValueError:
LOGGER.debug(
"Skip migration of %s because it already exists",
entity.entity_id,
)
else:
LOGGER.debug(
"Migrated entity %s from unique id %s to %s",
entity.entity_id,
entity.unique_id,
mbus_device_id,
)
# Cleanup old device
dev_entities = er.async_entries_for_device(
ent_reg, device_id, include_disabled_entities=True
)
if not dev_entities:
dev_reg.async_remove_device(device_id)
def create_mbus_entities(
hass: HomeAssistant, telegram: dict[str, DSMRObject], entry: ConfigEntry
) -> list[DSMREntity]:
"""Create MBUS Entities."""
entities = []
for idx in range(1, 5):
if (
device_type := getattr(obis_references, f"BELGIUM_MBUS{idx}_DEVICE_TYPE")
) not in telegram:
continue
if (type_ := int(telegram[device_type].value)) not in (3, 7):
continue
if (
identifier := getattr(
obis_references,
f"BELGIUM_MBUS{idx}_EQUIPMENT_IDENTIFIER",
)
) in telegram:
serial_ = telegram[identifier].value
rename_old_gas_to_mbus(hass, entry, serial_)
else:
serial_ = ""
if description := create_mbus_entity(idx, type_, telegram):
entities.append(
DSMREntity(
description,
entry,
telegram,
*device_class_and_uom(telegram, description), # type: ignore[arg-type]
serial_,
idx,
)
)
return entities
async def async_setup_entry( async def async_setup_entry(
@@ -415,25 +533,10 @@ async def async_setup_entry(
add_entities_handler() add_entities_handler()
add_entities_handler = None add_entities_handler = None
def device_class_and_uom(
telegram: dict[str, DSMRObject],
entity_description: DSMRSensorEntityDescription,
) -> tuple[SensorDeviceClass | None, str | None]:
"""Get native unit of measurement from telegram,."""
dsmr_object = telegram[entity_description.obis_reference]
uom: str | None = getattr(dsmr_object, "unit") or None
with suppress(ValueError):
if entity_description.device_class == SensorDeviceClass.GAS and (
enery_uom := UnitOfEnergy(str(uom))
):
return (SensorDeviceClass.ENERGY, enery_uom)
if uom in UNIT_CONVERSION:
return (entity_description.device_class, UNIT_CONVERSION[uom])
return (entity_description.device_class, uom)
all_sensors = SENSORS
if dsmr_version == "5B": if dsmr_version == "5B":
all_sensors += (add_gas_sensor_5B(telegram),) mbus_entities = create_mbus_entities(hass, telegram, entry)
for mbus_entity in mbus_entities:
entities.append(mbus_entity)
entities.extend( entities.extend(
[ [
@@ -443,7 +546,7 @@ async def async_setup_entry(
telegram, telegram,
*device_class_and_uom(telegram, description), # type: ignore[arg-type] *device_class_and_uom(telegram, description), # type: ignore[arg-type]
) )
for description in all_sensors for description in SENSORS
if ( if (
description.dsmr_versions is None description.dsmr_versions is None
or dsmr_version in description.dsmr_versions or dsmr_version in description.dsmr_versions
@@ -549,11 +652,9 @@ async def async_setup_entry(
update_entities_telegram(None) update_entities_telegram(None)
# throttle reconnect attempts # throttle reconnect attempts
await asyncio.sleep( await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL)
)
except (serial.serialutil.SerialException, OSError): except (serial.SerialException, OSError):
# Log any error while establishing connection and drop to retry # Log any error while establishing connection and drop to retry
# connection wait # connection wait
LOGGER.exception("Error connecting to DSMR") LOGGER.exception("Error connecting to DSMR")
@@ -565,9 +666,7 @@ async def async_setup_entry(
update_entities_telegram(None) update_entities_telegram(None)
# throttle reconnect attempts # throttle reconnect attempts
await asyncio.sleep( await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL)
)
except CancelledError: except CancelledError:
# Reflect disconnect state in devices state by setting an # Reflect disconnect state in devices state by setting an
# None telegram resulting in `unavailable` states # None telegram resulting in `unavailable` states
@@ -618,6 +717,8 @@ class DSMREntity(SensorEntity):
telegram: dict[str, DSMRObject], telegram: dict[str, DSMRObject],
device_class: SensorDeviceClass, device_class: SensorDeviceClass,
native_unit_of_measurement: str | None, native_unit_of_measurement: str | None,
serial_id: str = "",
mbus_id: int = 0,
) -> None: ) -> None:
"""Initialize entity.""" """Initialize entity."""
self.entity_description = entity_description self.entity_description = entity_description
@@ -629,8 +730,15 @@ class DSMREntity(SensorEntity):
device_serial = entry.data[CONF_SERIAL_ID] device_serial = entry.data[CONF_SERIAL_ID]
device_name = DEVICE_NAME_ELECTRICITY device_name = DEVICE_NAME_ELECTRICITY
if entity_description.is_gas: if entity_description.is_gas:
device_serial = entry.data[CONF_SERIAL_ID_GAS] if serial_id:
device_serial = serial_id
else:
device_serial = entry.data[CONF_SERIAL_ID_GAS]
device_name = DEVICE_NAME_GAS device_name = DEVICE_NAME_GAS
if entity_description.is_water:
if serial_id:
device_serial = serial_id
device_name = DEVICE_NAME_WATER
if device_serial is None: if device_serial is None:
device_serial = entry.entry_id device_serial = entry.entry_id
@@ -638,7 +746,13 @@ class DSMREntity(SensorEntity):
identifiers={(DOMAIN, device_serial)}, identifiers={(DOMAIN, device_serial)},
name=device_name, name=device_name,
) )
self._attr_unique_id = f"{device_serial}_{entity_description.key}" if mbus_id != 0:
if serial_id:
self._attr_unique_id = f"{device_serial}"
else:
self._attr_unique_id = f"{device_serial}_{mbus_id}"
else:
self._attr_unique_id = f"{device_serial}_{entity_description.key}"
@callback @callback
def update_data(self, telegram: dict[str, DSMRObject] | None) -> None: def update_data(self, telegram: dict[str, DSMRObject] | None) -> None:
@@ -682,9 +796,11 @@ class DSMREntity(SensorEntity):
return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION]) return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION])
with suppress(TypeError): with suppress(TypeError):
value = round( value = round(float(value), DEFAULT_PRECISION)
float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION)
) # Make sure we do not return a zero value for an energy sensor
if not value and self.state_class == SensorStateClass.TOTAL_INCREASING:
return None
return value return value

View File

@@ -147,6 +147,9 @@
}, },
"voltage_swell_l3_count": { "voltage_swell_l3_count": {
"name": "Voltage swells phase L3" "name": "Voltage swells phase L3"
},
"water_meter_reading": {
"name": "Water consumption"
} }
} }
}, },

View File

@@ -5,6 +5,9 @@
"description": "Ensure that your player is turned on.", "description": "Ensure that your player is turned on.",
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your Dune HD device."
} }
} }
}, },

Some files were not shown because too many files have changed in this diff Show More