mirror of
https://github.com/home-assistant/core.git
synced 2025-08-10 16:15:08 +02:00
Merge branch 'dev' into fuelcell
This commit is contained in:
@@ -404,6 +404,9 @@ omit =
|
||||
homeassistant/components/fjaraskupan/sensor.py
|
||||
homeassistant/components/fleetgo/device_tracker.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/flick_electric/__init__.py
|
||||
homeassistant/components/flick_electric/sensor.py
|
||||
@@ -633,8 +636,6 @@ omit =
|
||||
homeassistant/components/kodi/browse_media.py
|
||||
homeassistant/components/kodi/media_player.py
|
||||
homeassistant/components/kodi/notify.py
|
||||
homeassistant/components/komfovent/__init__.py
|
||||
homeassistant/components/komfovent/climate.py
|
||||
homeassistant/components/konnected/__init__.py
|
||||
homeassistant/components/konnected/panel.py
|
||||
homeassistant/components/konnected/switch.py
|
||||
@@ -902,6 +903,9 @@ omit =
|
||||
homeassistant/components/opple/light.py
|
||||
homeassistant/components/oru/*
|
||||
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/otp/sensor.py
|
||||
homeassistant/components/overkiz/__init__.py
|
||||
|
6
.github/workflows/builder.yml
vendored
6
.github/workflows/builder.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
|
26
.github/workflows/ci.yaml
vendored
26
.github/workflows/ci.yaml
vendored
@@ -36,7 +36,7 @@ env:
|
||||
CACHE_VERSION: 5
|
||||
PIP_CACHE_VERSION: 4
|
||||
MYPY_CACHE_VERSION: 6
|
||||
HA_SHORT_VERSION: "2023.12"
|
||||
HA_SHORT_VERSION: "2024.1"
|
||||
DEFAULT_PYTHON: "3.11"
|
||||
ALL_PYTHON_VERSIONS: "['3.11', '3.12']"
|
||||
# 10.3 is the oldest supported version
|
||||
@@ -225,7 +225,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -269,7 +269,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -309,7 +309,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -348,7 +348,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -443,7 +443,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
@@ -511,7 +511,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -543,7 +543,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -576,7 +576,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -620,7 +620,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -702,7 +702,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
@@ -854,7 +854,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
@@ -978,7 +978,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -29,11 +29,11 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2.22.8
|
||||
uses: github/codeql-action/init@v2.22.9
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2.22.8
|
||||
uses: github/codeql-action/analyze@v2.22.9
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
18
.github/workflows/stale.yml
vendored
18
.github/workflows/stale.yml
vendored
@@ -11,16 +11,16 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# The 90 day stale policy for PRs
|
||||
# The 60 day stale policy for PRs
|
||||
# Used for:
|
||||
# - PRs
|
||||
# - No PRs marked as no-stale
|
||||
# - No issues (-1)
|
||||
- name: 90 days stale PRs policy
|
||||
uses: actions/stale@v8.0.0
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@v9.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
@@ -33,7 +33,11 @@ jobs:
|
||||
pull request has been automatically marked as stale because of that
|
||||
and will be closed if no further activity occurs within 7 days.
|
||||
|
||||
Thank you for your contributions.
|
||||
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
|
||||
# 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 PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@v8.0.0
|
||||
uses: actions/stale@v9.0.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
@@ -83,7 +87,7 @@ jobs:
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@v8.0.0
|
||||
uses: actions/stale@v9.0.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
|
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v4.7.1
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
|
@@ -120,6 +120,7 @@ homeassistant.components.energy.*
|
||||
homeassistant.components.esphome.*
|
||||
homeassistant.components.event.*
|
||||
homeassistant.components.evil_genius_labs.*
|
||||
homeassistant.components.faa_delays.*
|
||||
homeassistant.components.fan.*
|
||||
homeassistant.components.fastdotcom.*
|
||||
homeassistant.components.feedreader.*
|
||||
@@ -127,6 +128,7 @@ homeassistant.components.file_upload.*
|
||||
homeassistant.components.filesize.*
|
||||
homeassistant.components.filter.*
|
||||
homeassistant.components.fitbit.*
|
||||
homeassistant.components.flexit_bacnet.*
|
||||
homeassistant.components.flux_led.*
|
||||
homeassistant.components.forecast_solar.*
|
||||
homeassistant.components.fritz.*
|
||||
@@ -150,6 +152,7 @@ homeassistant.components.hardkernel.*
|
||||
homeassistant.components.hardware.*
|
||||
homeassistant.components.here_travel_time.*
|
||||
homeassistant.components.history.*
|
||||
homeassistant.components.holiday.*
|
||||
homeassistant.components.homeassistant.exposed_entities
|
||||
homeassistant.components.homeassistant.triggers.event
|
||||
homeassistant.components.homeassistant_alerts.*
|
||||
@@ -264,6 +267,7 @@ homeassistant.components.proximity.*
|
||||
homeassistant.components.prusalink.*
|
||||
homeassistant.components.pure_energie.*
|
||||
homeassistant.components.purpleair.*
|
||||
homeassistant.components.pushbullet.*
|
||||
homeassistant.components.pvoutput.*
|
||||
homeassistant.components.qnap_qsw.*
|
||||
homeassistant.components.radarr.*
|
||||
@@ -313,6 +317,7 @@ homeassistant.components.statistics.*
|
||||
homeassistant.components.steamist.*
|
||||
homeassistant.components.stookalert.*
|
||||
homeassistant.components.stream.*
|
||||
homeassistant.components.streamlabswater.*
|
||||
homeassistant.components.sun.*
|
||||
homeassistant.components.surepetcare.*
|
||||
homeassistant.components.switch.*
|
||||
|
22
CODEOWNERS
22
CODEOWNERS
@@ -86,6 +86,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/anova/ @Lash-L
|
||||
/homeassistant/components/anthemav/ @hyralex
|
||||
/tests/components/anthemav/ @hyralex
|
||||
/homeassistant/components/aosmith/ @bdr99
|
||||
/tests/components/aosmith/ @bdr99
|
||||
/homeassistant/components/apache_kafka/ @bachya
|
||||
/tests/components/apache_kafka/ @bachya
|
||||
/homeassistant/components/apcupsd/ @yuxincs
|
||||
@@ -205,8 +207,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/cloud/ @home-assistant/cloud
|
||||
/homeassistant/components/cloudflare/ @ludeeus @ctalkington
|
||||
/tests/components/cloudflare/ @ludeeus @ctalkington
|
||||
/homeassistant/components/co2signal/ @jpbede
|
||||
/tests/components/co2signal/ @jpbede
|
||||
/homeassistant/components/co2signal/ @jpbede @VIKTORVAV99
|
||||
/tests/components/co2signal/ @jpbede @VIKTORVAV99
|
||||
/homeassistant/components/coinbase/ @tombrien
|
||||
/tests/components/coinbase/ @tombrien
|
||||
/homeassistant/components/color_extractor/ @GenericStudent
|
||||
@@ -395,6 +397,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/fivem/ @Sander0542
|
||||
/homeassistant/components/fjaraskupan/ @elupus
|
||||
/tests/components/fjaraskupan/ @elupus
|
||||
/homeassistant/components/flexit_bacnet/ @lellky @piotrbulinski
|
||||
/tests/components/flexit_bacnet/ @lellky @piotrbulinski
|
||||
/homeassistant/components/flick_electric/ @ZephireNZ
|
||||
/tests/components/flick_electric/ @ZephireNZ
|
||||
/homeassistant/components/flipr/ @cnico
|
||||
@@ -520,6 +524,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/hive/ @Rendili @KJonline
|
||||
/homeassistant/components/hlk_sw16/ @jameshilliard
|
||||
/tests/components/hlk_sw16/ @jameshilliard
|
||||
/homeassistant/components/holiday/ @jrieger
|
||||
/tests/components/holiday/ @jrieger
|
||||
/homeassistant/components/home_connect/ @DavidMStraub
|
||||
/tests/components/home_connect/ @DavidMStraub
|
||||
/homeassistant/components/home_plus_control/ @chemaaa
|
||||
@@ -663,8 +669,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/knx/ @Julius2342 @farmio @marvin-w
|
||||
/homeassistant/components/kodi/ @OnFreund
|
||||
/tests/components/kodi/ @OnFreund
|
||||
/homeassistant/components/komfovent/ @ProstoSanja
|
||||
/tests/components/komfovent/ @ProstoSanja
|
||||
/homeassistant/components/konnected/ @heythisisnate
|
||||
/tests/components/konnected/ @heythisisnate
|
||||
/homeassistant/components/kostal_plenticore/ @stegm
|
||||
@@ -928,6 +932,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/oralb/ @bdraco @Lash-L
|
||||
/tests/components/oralb/ @bdraco @Lash-L
|
||||
/homeassistant/components/oru/ @bvlaicu
|
||||
/homeassistant/components/osoenergy/ @osohotwateriot
|
||||
/tests/components/osoenergy/ @osohotwateriot
|
||||
/homeassistant/components/otbr/ @home-assistant/core
|
||||
/tests/components/otbr/ @home-assistant/core
|
||||
/homeassistant/components/ourgroceries/ @OnFreund
|
||||
@@ -1247,6 +1253,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/suez_water/ @ooii
|
||||
/homeassistant/components/sun/ @Swamp-Ig
|
||||
/tests/components/sun/ @Swamp-Ig
|
||||
/homeassistant/components/sunweg/ @rokam
|
||||
/tests/components/sunweg/ @rokam
|
||||
/homeassistant/components/supla/ @mwegrzynek
|
||||
/homeassistant/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
|
||||
/homeassistant/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
|
||||
/tests/components/text/ @home-assistant/core
|
||||
/homeassistant/components/tfiac/ @fredrike @mellado
|
||||
@@ -1398,8 +1408,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/versasense/ @imstevenxyz
|
||||
/homeassistant/components/version/ @ludeeus
|
||||
/tests/components/version/ @ludeeus
|
||||
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey
|
||||
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey
|
||||
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja
|
||||
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja
|
||||
/homeassistant/components/vicare/ @CFenner
|
||||
/tests/components/vicare/ @CFenner
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
|
@@ -1,9 +1,12 @@
|
||||
# Automatically generated by hassfest.
|
||||
#
|
||||
# To update, run python3 -m script.hassfest -p docker
|
||||
ARG BUILD_FROM
|
||||
FROM ${BUILD_FROM}
|
||||
|
||||
# Synchronize with homeassistant/core.py:async_stop
|
||||
ENV \
|
||||
S6_SERVICES_GRACETIME=220000
|
||||
S6_SERVICES_GRACETIME=240000
|
||||
|
||||
ARG QEMU_CPU
|
||||
|
||||
|
@@ -27,6 +27,7 @@ from .const import (
|
||||
from .exceptions import HomeAssistantError
|
||||
from .helpers import (
|
||||
area_registry,
|
||||
config_validation as cv,
|
||||
device_registry,
|
||||
entity,
|
||||
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]:
|
||||
"""Get domains of components to set up."""
|
||||
# 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
|
||||
if not hass.config.recovery_mode:
|
||||
|
5
homeassistant/brands/flexit.json
Normal file
5
homeassistant/brands/flexit.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "flexit",
|
||||
"name": "Flexit",
|
||||
"integrations": ["flexit", "flexit_bacnet"]
|
||||
}
|
@@ -6,6 +6,9 @@
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The IP address of the Agent DVR server."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -3,7 +3,6 @@ from typing import Final
|
||||
|
||||
DOMAIN: Final = "airq"
|
||||
MANUFACTURER: Final = "CorantGmbH"
|
||||
TARGET_ROUTE: Final = "average"
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³"
|
||||
ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³"
|
||||
UPDATE_INTERVAL: float = 10.0
|
||||
|
@@ -13,7 +13,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
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__)
|
||||
|
||||
@@ -56,6 +56,4 @@ class AirQCoordinator(DataUpdateCoordinator):
|
||||
hw_version=info["hw_version"],
|
||||
)
|
||||
)
|
||||
|
||||
data = await self.airq.get(TARGET_ROUTE)
|
||||
return self.airq.drop_uncertainties_from_data(data)
|
||||
return await self.airq.get_latest_data()
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairq"],
|
||||
"requirements": ["aioairq==0.2.4"]
|
||||
"requirements": ["aioairq==0.3.1"]
|
||||
}
|
||||
|
@@ -14,7 +14,7 @@
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -14,7 +14,7 @@
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -16,7 +16,7 @@
|
||||
"device_path": "Device Path"
|
||||
},
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
|
@@ -36,6 +36,15 @@ CONF_FLASH_BRIEFINGS = "flash_briefings"
|
||||
CONF_SMART_HOME = "smart_home"
|
||||
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(
|
||||
{
|
||||
vol.Optional(CONF_DESCRIPTION): cv.string,
|
||||
@@ -46,7 +55,7 @@ ALEXA_ENTITY_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_SECRET): cv.string,
|
||||
vol.Optional(CONF_LOCALE, default=DEFAULT_LOCALE): vol.In(
|
||||
|
@@ -1304,13 +1304,14 @@ async def async_api_set_range(
|
||||
service = None
|
||||
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
|
||||
range_value = directive.payload["rangeValue"]
|
||||
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
# Cover Position
|
||||
if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
|
||||
range_value = int(range_value)
|
||||
if range_value == 0:
|
||||
if supported & cover.CoverEntityFeature.CLOSE and range_value == 0:
|
||||
service = cover.SERVICE_CLOSE_COVER
|
||||
elif range_value == 100:
|
||||
elif supported & cover.CoverEntityFeature.OPEN and range_value == 100:
|
||||
service = cover.SERVICE_OPEN_COVER
|
||||
else:
|
||||
service = cover.SERVICE_SET_COVER_POSITION
|
||||
@@ -1319,9 +1320,9 @@ async def async_api_set_range(
|
||||
# Cover Tilt
|
||||
elif instance == f"{cover.DOMAIN}.tilt":
|
||||
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
|
||||
elif range_value == 100:
|
||||
elif supported & cover.CoverEntityFeature.OPEN_TILT and range_value == 100:
|
||||
service = cover.SERVICE_OPEN_COVER_TILT
|
||||
else:
|
||||
service = cover.SERVICE_SET_COVER_TILT_POSITION
|
||||
@@ -1332,9 +1333,7 @@ async def async_api_set_range(
|
||||
range_value = int(range_value)
|
||||
if range_value == 0:
|
||||
service = fan.SERVICE_TURN_OFF
|
||||
else:
|
||||
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
if supported and fan.FanEntityFeature.SET_SPEED:
|
||||
elif supported & fan.FanEntityFeature.SET_SPEED:
|
||||
service = fan.SERVICE_SET_PERCENTAGE
|
||||
data[fan.ATTR_PERCENTAGE] = range_value
|
||||
else:
|
||||
|
@@ -7,6 +7,9 @@
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
53
homeassistant/components/aosmith/__init__.py
Normal file
53
homeassistant/components/aosmith/__init__.py
Normal 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
|
111
homeassistant/components/aosmith/config_flow.py
Normal file
111
homeassistant/components/aosmith/config_flow.py
Normal 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,
|
||||
)
|
16
homeassistant/components/aosmith/const.py
Normal file
16
homeassistant/components/aosmith/const.py
Normal 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)
|
51
homeassistant/components/aosmith/coordinator.py
Normal file
51
homeassistant/components/aosmith/coordinator.py
Normal 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}
|
51
homeassistant/components/aosmith/entity.py
Normal file
51
homeassistant/components/aosmith/entity.py
Normal 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
|
10
homeassistant/components/aosmith/manifest.json
Normal file
10
homeassistant/components/aosmith/manifest.json
Normal 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"]
|
||||
}
|
28
homeassistant/components/aosmith/strings.json
Normal file
28
homeassistant/components/aosmith/strings.json
Normal 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%]"
|
||||
}
|
||||
}
|
||||
}
|
149
homeassistant/components/aosmith/water_heater.py
Normal file
149
homeassistant/components/aosmith/water_heater.py
Normal 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()
|
@@ -7,8 +7,9 @@ from datetime import timedelta
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from apcaccess import status
|
||||
import aioapcaccess
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -32,6 +33,8 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]):
|
||||
updates from the server.
|
||||
"""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, host: str, port: int) -> None:
|
||||
"""Initialize the data object."""
|
||||
super().__init__(
|
||||
@@ -70,13 +73,10 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]):
|
||||
return self.data.get("SERIALNO")
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo | None:
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return the DeviceInfo of this APC UPS, if serial number is available."""
|
||||
if not self.ups_serial_no:
|
||||
return None
|
||||
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.ups_serial_no)},
|
||||
identifiers={(DOMAIN, self.ups_serial_no or self.config_entry.entry_id)},
|
||||
model=self.ups_model,
|
||||
manufacturer="APC",
|
||||
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
|
||||
integration uses lower cases as keys internally.
|
||||
"""
|
||||
|
||||
async with asyncio.timeout(10):
|
||||
try:
|
||||
raw = await self.hass.async_add_executor_job(
|
||||
status.get, self._host, self._port
|
||||
)
|
||||
result: OrderedDict[str, str] = status.parse(raw)
|
||||
return result
|
||||
except OSError as error:
|
||||
return await aioapcaccess.request_status(self._host, self._port)
|
||||
except (OSError, asyncio.IncompleteReadError) as error:
|
||||
raise UpdateFailed(error) from error
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["apcaccess"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["apcaccess==0.0.13"]
|
||||
"requirements": ["aioapcaccess==0.4.2"]
|
||||
}
|
||||
|
@@ -41,7 +41,6 @@ from homeassistant.exceptions import (
|
||||
Unauthorized,
|
||||
)
|
||||
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.json import json_dumps
|
||||
from homeassistant.helpers.service import async_get_all_descriptions
|
||||
@@ -218,9 +217,11 @@ class APIStatesView(HomeAssistantView):
|
||||
if entity_perm(state.entity_id, "read")
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -390,7 +391,6 @@ class APIDomainServicesView(HomeAssistantView):
|
||||
)
|
||||
|
||||
try:
|
||||
async with timeout(SERVICE_WAIT_TIMEOUT):
|
||||
# shield the service call from cancellation on connection drop
|
||||
await shield(
|
||||
hass.services.async_call(
|
||||
@@ -399,8 +399,6 @@ class APIDomainServicesView(HomeAssistantView):
|
||||
)
|
||||
except (vol.Invalid, ServiceNotFound) as ex:
|
||||
raise HTTPBadRequest() from ex
|
||||
except TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
cancel_listen()
|
||||
|
||||
|
@@ -9,7 +9,7 @@ from dataclasses import asdict, dataclass, field
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from queue import Empty, Queue
|
||||
from threading import Thread
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
@@ -1010,8 +1010,8 @@ class PipelineRun:
|
||||
self.tts_engine = engine
|
||||
self.tts_options = tts_options
|
||||
|
||||
async def text_to_speech(self, tts_input: str) -> str:
|
||||
"""Run text-to-speech portion of pipeline. Returns URL of TTS audio."""
|
||||
async def text_to_speech(self, tts_input: str) -> None:
|
||||
"""Run text-to-speech portion of pipeline."""
|
||||
self.process_event(
|
||||
PipelineEvent(
|
||||
PipelineEventType.TTS_START,
|
||||
@@ -1024,6 +1024,7 @@ class PipelineRun:
|
||||
)
|
||||
)
|
||||
|
||||
if tts_input := tts_input.strip():
|
||||
try:
|
||||
# Synthesize audio and get URL
|
||||
tts_media_id = tts_generate_media_source_id(
|
||||
@@ -1046,20 +1047,16 @@ class PipelineRun:
|
||||
) from src_error
|
||||
|
||||
_LOGGER.debug("TTS result %s", tts_media)
|
||||
|
||||
self.process_event(
|
||||
PipelineEvent(
|
||||
PipelineEventType.TTS_END,
|
||||
{
|
||||
"tts_output": {
|
||||
tts_output = {
|
||||
"media_id": tts_media_id,
|
||||
**asdict(tts_media),
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
tts_output = {}
|
||||
|
||||
return tts_media.url
|
||||
self.process_event(
|
||||
PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output})
|
||||
)
|
||||
|
||||
def _capture_chunk(self, audio_bytes: bytes | None) -> None:
|
||||
"""Forward audio chunk to various capturing mechanisms."""
|
||||
@@ -1247,6 +1244,8 @@ def _pipeline_debug_recording_thread_proc(
|
||||
# Chunk of 16-bit mono audio at 16Khz
|
||||
if wav_writer is not None:
|
||||
wav_writer.writeframes(message)
|
||||
except Empty:
|
||||
pass # occurs when pipeline has unexpected error
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_LOGGER.exception("Unexpected error in debug recording thread")
|
||||
finally:
|
||||
|
@@ -55,7 +55,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_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]]]
|
||||
|
||||
|
||||
@@ -81,7 +83,7 @@ def handle_errors_and_zip(
|
||||
|
||||
if isinstance(data, dict):
|
||||
return dict(zip(keys, list(data.values())))
|
||||
if not isinstance(data, list):
|
||||
if not isinstance(data, (list, tuple)):
|
||||
raise UpdateFailed("Received invalid data type")
|
||||
return dict(zip(keys, data))
|
||||
|
||||
|
@@ -2,7 +2,6 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "AsusWRT",
|
||||
"description": "Set required parameter to connect to your router",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
@@ -11,10 +10,12 @@
|
||||
"ssh_key": "Path to your SSH key file (instead of password)",
|
||||
"protocol": "Communication protocol to use",
|
||||
"port": "Port (leave empty for protocol default)"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your ASUSWRT router."
|
||||
}
|
||||
},
|
||||
"legacy": {
|
||||
"title": "AsusWRT",
|
||||
"description": "Set required parameters to connect to your router",
|
||||
"data": {
|
||||
"mode": "Router operating mode"
|
||||
@@ -37,7 +38,6 @@
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "AsusWRT Options",
|
||||
"data": {
|
||||
"consider_home": "Seconds to wait before considering a device away",
|
||||
"track_unknown": "Track unknown / unnamed devices",
|
||||
|
@@ -2,10 +2,13 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to the device",
|
||||
"description": "Connect to the device",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the Atag device."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -76,6 +76,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]):
|
||||
power_watts = self.client.measure(3, True)
|
||||
temperature_c = self.client.measure(21)
|
||||
energy_wh = self.client.cumulated_energy(5)
|
||||
[alarm, *_] = self.client.alarms()
|
||||
except AuroraTimeoutError:
|
||||
self.available = False
|
||||
_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["temp"] = round(temperature_c, 1)
|
||||
data["totalenergy"] = round(energy_wh / 1000, 2)
|
||||
data["alarm"] = alarm
|
||||
self.available = True
|
||||
|
||||
finally:
|
||||
|
@@ -5,6 +5,8 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aurorapy.mapping import Mapping as AuroraMapping
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -36,8 +38,16 @@ from .const import (
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
ALARM_STATES = list(AuroraMapping.ALARM_STATES.values())
|
||||
|
||||
SENSOR_TYPES = [
|
||||
SensorEntityDescription(
|
||||
key="alarm",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=ALARM_STATES,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
translation_key="alarm",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="instantaneouspower",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
|
@@ -21,11 +21,14 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"alarm": {
|
||||
"name": "Alarm status"
|
||||
},
|
||||
"power_output": {
|
||||
"name": "Power Output"
|
||||
"name": "Power output"
|
||||
},
|
||||
"total_energy": {
|
||||
"name": "Total Energy"
|
||||
"name": "Total energy"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -11,7 +11,7 @@ from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant.components import blueprint
|
||||
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 (
|
||||
CONF_ALIAS,
|
||||
CONF_CONDITION,
|
||||
@@ -21,7 +21,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
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.trigger import async_validate_trigger_config
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
@@ -3,12 +3,16 @@
|
||||
"flow_title": "{name} ({host})",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up Axis device",
|
||||
"description": "Set up an Axis device",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -93,8 +93,6 @@ class BAFFan(BAFEntity, FanEntity):
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""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
|
||||
|
||||
async def async_set_direction(self, direction: str) -> None:
|
||||
|
@@ -2,9 +2,12 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to the Balboa Wi-Fi device",
|
||||
"description": "Connect to the Balboa Wi-Fi device",
|
||||
"data": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -25,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS
|
||||
from .coordinator import BlinkUpdateCoordinator
|
||||
from .services import async_setup_services
|
||||
from .services import setup_services
|
||||
|
||||
_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:
|
||||
"""Set up Blink."""
|
||||
|
||||
await async_setup_services(hass)
|
||||
setup_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
@@ -1,8 +1,6 @@
|
||||
"""Services for the Blink integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
@@ -14,7 +12,7 @@ from homeassistant.const import (
|
||||
CONF_PIN,
|
||||
)
|
||||
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.device_registry as dr
|
||||
|
||||
@@ -27,56 +25,72 @@ from .const import (
|
||||
)
|
||||
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(
|
||||
{
|
||||
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_FILENAME): cv.string,
|
||||
}
|
||||
)
|
||||
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(
|
||||
{
|
||||
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_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."""
|
||||
|
||||
async def collect_coordinators(
|
||||
def collect_coordinators(
|
||||
device_ids: list[str],
|
||||
) -> list[BlinkUpdateCoordinator]:
|
||||
config_entries = list[ConfigEntry]()
|
||||
config_entries: list[ConfigEntry] = []
|
||||
registry = dr.async_get(hass)
|
||||
for target in device_ids:
|
||||
device = registry.async_get(target)
|
||||
if device:
|
||||
device_entries = list[ConfigEntry]()
|
||||
device_entries: list[ConfigEntry] = []
|
||||
for entry_id in device.config_entries:
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
if entry and entry.domain == DOMAIN:
|
||||
device_entries.append(entry)
|
||||
if not device_entries:
|
||||
raise HomeAssistantError(
|
||||
f"Device '{target}' is not a {DOMAIN} device"
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_device",
|
||||
translation_placeholders={"target": target, "domain": DOMAIN},
|
||||
)
|
||||
config_entries.extend(device_entries)
|
||||
else:
|
||||
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:
|
||||
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])
|
||||
return coordinators
|
||||
|
||||
@@ -85,24 +99,36 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
camera_name = call.data[CONF_NAME]
|
||||
video_path = call.data[CONF_FILENAME]
|
||||
if not hass.config.is_allowed_path(video_path):
|
||||
_LOGGER.error("Can't write %s, no access to path!", video_path)
|
||||
return
|
||||
for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_path",
|
||||
translation_placeholders={"target": video_path},
|
||||
)
|
||||
|
||||
for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]):
|
||||
all_cameras = coordinator.api.cameras
|
||||
if camera_name in all_cameras:
|
||||
try:
|
||||
await all_cameras[camera_name].video_to_file(video_path)
|
||||
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:
|
||||
"""Save multiple recent clips to output directory."""
|
||||
camera_name = call.data[CONF_NAME]
|
||||
clips_dir = call.data[CONF_FILE_PATH]
|
||||
if not hass.config.is_allowed_path(clips_dir):
|
||||
_LOGGER.error("Can't write to directory %s, no access to path!", clips_dir)
|
||||
return
|
||||
for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_path",
|
||||
translation_placeholders={"target": clips_dir},
|
||||
)
|
||||
|
||||
for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]):
|
||||
all_cameras = coordinator.api.cameras
|
||||
if camera_name in all_cameras:
|
||||
try:
|
||||
@@ -110,11 +136,15 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
output_dir=clips_dir
|
||||
)
|
||||
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):
|
||||
"""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(
|
||||
coordinator.api,
|
||||
call.data[CONF_PIN],
|
||||
@@ -122,12 +152,12 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
|
||||
async def blink_refresh(call: ServiceCall):
|
||||
"""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)
|
||||
|
||||
# Register all the above services
|
||||
service_mapping = [
|
||||
(blink_refresh, SERVICE_REFRESH, None),
|
||||
(blink_refresh, SERVICE_REFRESH, SERVICE_UPDATE_SCHEMA),
|
||||
(
|
||||
async_handle_save_video_service,
|
||||
SERVICE_SAVE_VIDEO,
|
||||
|
@@ -1,14 +1,28 @@
|
||||
# Describes the format for available Blink services
|
||||
|
||||
blink_update:
|
||||
trigger_camera:
|
||||
target:
|
||||
entity:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: blink
|
||||
|
||||
trigger_camera:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: blink
|
||||
domain: camera
|
||||
|
||||
save_video:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: blink
|
||||
name:
|
||||
required: true
|
||||
example: "Living Room"
|
||||
@@ -22,6 +36,11 @@ save_video:
|
||||
|
||||
save_recent_clips:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: blink
|
||||
name:
|
||||
required: true
|
||||
example: "Living Room"
|
||||
@@ -35,6 +54,11 @@ save_recent_clips:
|
||||
|
||||
send_pin:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: blink
|
||||
pin:
|
||||
example: "abc123"
|
||||
selector:
|
||||
|
@@ -57,11 +57,23 @@
|
||||
"services": {
|
||||
"blink_update": {
|
||||
"name": "Update",
|
||||
"description": "Forces a refresh."
|
||||
"description": "Forces a refresh.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device ID",
|
||||
"description": "The Blink device id."
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"name": "Save video",
|
||||
@@ -74,6 +86,10 @@
|
||||
"filename": {
|
||||
"name": "File name",
|
||||
"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": {
|
||||
"name": "Output directory",
|
||||
"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": {
|
||||
"name": "Pin",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -215,7 +215,7 @@ class DomainBlueprints:
|
||||
def _load_blueprint(self, blueprint_path) -> Blueprint:
|
||||
"""Load a blueprint."""
|
||||
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:
|
||||
raise FailedToLoad(
|
||||
self.domain,
|
||||
@@ -225,7 +225,6 @@ class DomainBlueprints:
|
||||
except HomeAssistantError as err:
|
||||
raise FailedToLoad(self.domain, blueprint_path, err) from err
|
||||
|
||||
assert isinstance(blueprint_data, dict)
|
||||
return Blueprint(
|
||||
blueprint_data, expected_domain=self.domain, path=blueprint_path
|
||||
)
|
||||
|
@@ -21,6 +21,12 @@ from bluetooth_adapters import (
|
||||
adapter_unique_name,
|
||||
get_adapters,
|
||||
)
|
||||
from habluetooth import (
|
||||
BluetoothScanningMode,
|
||||
HaBluetoothConnector,
|
||||
HaScanner,
|
||||
ScannerStartError,
|
||||
)
|
||||
from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak
|
||||
|
||||
from homeassistant.components import usb
|
||||
@@ -59,7 +65,11 @@ from .api import (
|
||||
async_set_fallback_availability_interval,
|
||||
async_track_unavailable,
|
||||
)
|
||||
from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice
|
||||
from .base_scanner import (
|
||||
BaseHaScanner,
|
||||
BluetoothScannerDevice,
|
||||
HomeAssistantRemoteScanner,
|
||||
)
|
||||
from .const import (
|
||||
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
|
||||
CONF_ADAPTER,
|
||||
@@ -71,15 +81,9 @@ from .const import (
|
||||
LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS,
|
||||
SOURCE_LOCAL,
|
||||
)
|
||||
from .manager import BluetoothManager
|
||||
from .manager import MONOTONIC_TIME, HomeAssistantBluetoothManager
|
||||
from .match import BluetoothCallbackMatcher, IntegrationMatcher
|
||||
from .models import (
|
||||
BluetoothCallback,
|
||||
BluetoothChange,
|
||||
BluetoothScanningMode,
|
||||
HaBluetoothConnector,
|
||||
)
|
||||
from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError
|
||||
from .models import BluetoothCallback, BluetoothChange
|
||||
from .storage import BluetoothStorage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -103,7 +107,7 @@ __all__ = [
|
||||
"async_scanner_count",
|
||||
"async_scanner_devices_by_address",
|
||||
"BaseHaScanner",
|
||||
"BaseHaRemoteScanner",
|
||||
"HomeAssistantRemoteScanner",
|
||||
"BluetoothCallbackMatcher",
|
||||
"BluetoothChange",
|
||||
"BluetoothServiceInfo",
|
||||
@@ -139,11 +143,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
await bluetooth_storage.async_setup()
|
||||
slot_manager = BleakSlotManager()
|
||||
await slot_manager.async_setup()
|
||||
manager = BluetoothManager(
|
||||
manager = HomeAssistantBluetoothManager(
|
||||
hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager
|
||||
)
|
||||
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
|
||||
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)
|
||||
mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE
|
||||
new_info_callback = async_get_advertisement_callback(hass)
|
||||
manager: BluetoothManager = hass.data[DATA_MANAGER]
|
||||
scanner = HaScanner(hass, mode, adapter, address, new_info_callback)
|
||||
manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER]
|
||||
scanner = HaScanner(mode, adapter, address, new_info_callback)
|
||||
try:
|
||||
scanner.async_setup()
|
||||
except RuntimeError as err:
|
||||
|
@@ -9,10 +9,10 @@ import logging
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from bleak import BleakError
|
||||
from bluetooth_data_tools import monotonic_time_coarse
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.util.dt import monotonic_time_coarse
|
||||
|
||||
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
|
||||
from .passive_update_coordinator import PassiveBluetoothDataUpdateCoordinator
|
||||
|
@@ -9,10 +9,10 @@ import logging
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from bleak import BleakError
|
||||
from bluetooth_data_tools import monotonic_time_coarse
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.util.dt import monotonic_time_coarse
|
||||
|
||||
from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
|
||||
from .passive_update_processor import PassiveBluetoothProcessorCoordinator
|
||||
|
@@ -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)
|
@@ -9,29 +9,25 @@ from asyncio import Future
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from habluetooth import BluetoothScanningMode
|
||||
from home_assistant_bluetooth import BluetoothServiceInfoBleak
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
|
||||
|
||||
from .base_scanner import BaseHaScanner, BluetoothScannerDevice
|
||||
from .const import DATA_MANAGER
|
||||
from .manager import BluetoothManager
|
||||
from .manager import HomeAssistantBluetoothManager
|
||||
from .match import BluetoothCallbackMatcher
|
||||
from .models import (
|
||||
BluetoothCallback,
|
||||
BluetoothChange,
|
||||
BluetoothScanningMode,
|
||||
ProcessAdvertisementCallback,
|
||||
)
|
||||
from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback
|
||||
from .wrappers import HaBleakScannerWrapper
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bleak.backends.device import BLEDevice
|
||||
|
||||
|
||||
def _get_manager(hass: HomeAssistant) -> BluetoothManager:
|
||||
def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager:
|
||||
"""Get the bluetooth manager."""
|
||||
return cast(BluetoothManager, hass.data[DATA_MANAGER])
|
||||
return cast(HomeAssistantBluetoothManager, hass.data[DATA_MANAGER])
|
||||
|
||||
|
||||
@hass_callback
|
||||
|
@@ -1,19 +1,14 @@
|
||||
"""Base classes for HA Bluetooth scanners for bluetooth."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import contextmanager
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any, Final
|
||||
from typing import Any
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from bleak_retry_connector import NO_RSSI_VALUE
|
||||
from bluetooth_adapters import DiscoveredDeviceAdvertisementData, adapter_human_name
|
||||
from bluetooth_adapters import DiscoveredDeviceAdvertisementData
|
||||
from habluetooth import BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector
|
||||
from home_assistant_bluetooth import BluetoothServiceInfoBleak
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
@@ -23,20 +18,8 @@ from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
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 .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)
|
||||
@@ -48,150 +31,17 @@ class BluetoothScannerDevice:
|
||||
advertisement: AdvertisementData
|
||||
|
||||
|
||||
class BaseHaScanner(ABC):
|
||||
"""Base class for Ha Scanners."""
|
||||
class HomeAssistantRemoteScanner(BaseHaRemoteScanner):
|
||||
"""Home Assistant remote BLE scanner.
|
||||
|
||||
This is the only object that should know about
|
||||
the hass object.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"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",
|
||||
"_cancel_stop",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -204,50 +54,36 @@ class BaseHaRemoteScanner(BaseHaScanner):
|
||||
connectable: bool,
|
||||
) -> None:
|
||||
"""Initialize the scanner."""
|
||||
super().__init__(hass, scanner_id, name, connector)
|
||||
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
|
||||
self.hass = hass
|
||||
assert models.MANAGER is not None
|
||||
self._storage = models.MANAGER.storage
|
||||
self._cancel_stop: CALLBACK_TYPE | None = None
|
||||
super().__init__(scanner_id, name, new_info_callback, connector, connectable)
|
||||
|
||||
@hass_callback
|
||||
def async_setup(self) -> CALLBACK_TYPE:
|
||||
"""Set up the scanner."""
|
||||
super().async_setup()
|
||||
if history := self._storage.async_get_advertisement_history(self.source):
|
||||
self._discovered_device_advertisement_datas = (
|
||||
history.discovered_device_advertisement_datas
|
||||
)
|
||||
self._discovered_device_timestamps = history.discovered_device_timestamps
|
||||
# Expire anything that is too old
|
||||
self._async_expire_devices(dt_util.utcnow())
|
||||
self._async_expire_devices()
|
||||
|
||||
cancel_track = async_track_time_interval(
|
||||
self.hass,
|
||||
self._async_expire_devices,
|
||||
timedelta(seconds=30),
|
||||
name=f"{self.name} Bluetooth scanner device expire",
|
||||
)
|
||||
cancel_stop = self.hass.bus.async_listen(
|
||||
self._cancel_stop = self.hass.bus.async_listen(
|
||||
EVENT_HOMEASSISTANT_STOP, self._async_save_history
|
||||
)
|
||||
self._async_setup_scanner_watchdog()
|
||||
return self._unsetup
|
||||
|
||||
@hass_callback
|
||||
def _cancel() -> None:
|
||||
def _unsetup(self) -> None:
|
||||
super()._unsetup()
|
||||
self._async_save_history()
|
||||
self._async_stop_scanner_watchdog()
|
||||
cancel_track()
|
||||
cancel_stop()
|
||||
|
||||
return _cancel
|
||||
if self._cancel_stop:
|
||||
self._cancel_stop()
|
||||
self._cancel_stop = None
|
||||
|
||||
@hass_callback
|
||||
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]:
|
||||
"""Return diagnostic information about the scanner."""
|
||||
now = MONOTONIC_TIME()
|
||||
return await super().async_diagnostics() | {
|
||||
"storage": self._storage.async_get_advertisement_history_as_dict(
|
||||
diag = await super().async_diagnostics()
|
||||
diag["storage"] = self._storage.async_get_advertisement_history_as_dict(
|
||||
self.source
|
||||
),
|
||||
"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()
|
||||
},
|
||||
}
|
||||
)
|
||||
return diag
|
||||
|
@@ -1,9 +1,15 @@
|
||||
"""Constants for the Bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
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"
|
||||
|
||||
CONF_ADAPTER = "adapter"
|
||||
@@ -19,42 +25,6 @@ UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5
|
||||
|
||||
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
|
||||
# CONFIG_FW_LOADER_USER_HELPER_FALLBACK it
|
||||
|
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Iterable
|
||||
from datetime import datetime, timedelta
|
||||
import itertools
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
@@ -16,6 +15,8 @@ from bluetooth_adapters import (
|
||||
AdapterDetails,
|
||||
BluetoothAdapters,
|
||||
)
|
||||
from bluetooth_data_tools import monotonic_time_coarse
|
||||
from habluetooth import TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import EVENT_LOGGING_CHANGED
|
||||
@@ -26,13 +27,7 @@ from homeassistant.core import (
|
||||
callback as hass_callback,
|
||||
)
|
||||
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 .const import (
|
||||
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
|
||||
@@ -103,16 +98,12 @@ class BluetoothManager:
|
||||
"""Manage Bluetooth."""
|
||||
|
||||
__slots__ = (
|
||||
"hass",
|
||||
"_integration_matcher",
|
||||
"_cancel_unavailable_tracking",
|
||||
"_cancel_logging_listener",
|
||||
"_advertisement_tracker",
|
||||
"_fallback_intervals",
|
||||
"_intervals",
|
||||
"_unavailable_callbacks",
|
||||
"_connectable_unavailable_callbacks",
|
||||
"_callback_index",
|
||||
"_bleak_callbacks",
|
||||
"_all_history",
|
||||
"_connectable_history",
|
||||
@@ -125,21 +116,17 @@ class BluetoothManager:
|
||||
"slot_manager",
|
||||
"_debug",
|
||||
"shutdown",
|
||||
"_loop",
|
||||
)
|
||||
|
||||
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._cancel_unavailable_tracking: CALLBACK_TYPE | None = None
|
||||
self._cancel_logging_listener: CALLBACK_TYPE | None = None
|
||||
self._cancel_unavailable_tracking: asyncio.TimerHandle | None = None
|
||||
|
||||
self._advertisement_tracker = AdvertisementTracker()
|
||||
self._fallback_intervals = self._advertisement_tracker.fallback_intervals
|
||||
@@ -152,7 +139,6 @@ class BluetoothManager:
|
||||
str, list[Callable[[BluetoothServiceInfoBleak], None]]
|
||||
] = {}
|
||||
|
||||
self._callback_index = BluetoothCallbackMatcherIndex()
|
||||
self._bleak_callbacks: list[
|
||||
tuple[AdvertisementDataCallback, dict[str, set[str]]]
|
||||
] = []
|
||||
@@ -167,6 +153,7 @@ class BluetoothManager:
|
||||
self.slot_manager = slot_manager
|
||||
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
|
||||
self.shutdown = False
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
@property
|
||||
def supports_passive_scan(self) -> bool:
|
||||
@@ -209,7 +196,6 @@ class BluetoothManager:
|
||||
return adapter
|
||||
return None
|
||||
|
||||
@hass_callback
|
||||
def async_scanner_by_source(self, source: str) -> BaseHaScanner | None:
|
||||
"""Return the scanner for a source."""
|
||||
return self._sources.get(source)
|
||||
@@ -232,45 +218,22 @@ class BluetoothManager:
|
||||
self._adapters = self._bluetooth_adapters.adapters
|
||||
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:
|
||||
"""Set up the bluetooth manager."""
|
||||
self._loop = asyncio.get_running_loop()
|
||||
await self._bluetooth_adapters.refresh()
|
||||
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()
|
||||
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, event: Event) -> None:
|
||||
def async_stop(self) -> None:
|
||||
"""Stop the Bluetooth integration at shutdown."""
|
||||
_LOGGER.debug("Stopping bluetooth manager")
|
||||
self.shutdown = True
|
||||
if self._cancel_unavailable_tracking:
|
||||
self._cancel_unavailable_tracking()
|
||||
self._cancel_unavailable_tracking.cancel()
|
||||
self._cancel_unavailable_tracking = None
|
||||
if self._cancel_logging_listener:
|
||||
self._cancel_logging_listener()
|
||||
self._cancel_logging_listener = None
|
||||
uninstall_multiple_bleak_catcher()
|
||||
|
||||
@hass_callback
|
||||
def async_scanner_devices_by_address(
|
||||
self, address: str, connectable: bool
|
||||
) -> list[BluetoothScannerDevice]:
|
||||
@@ -291,7 +254,6 @@ class BluetoothManager:
|
||||
)
|
||||
]
|
||||
|
||||
@hass_callback
|
||||
def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]:
|
||||
"""Return all of discovered addresses.
|
||||
|
||||
@@ -307,24 +269,25 @@ class BluetoothManager:
|
||||
for scanner in self._non_connectable_scanners
|
||||
)
|
||||
|
||||
@hass_callback
|
||||
def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]:
|
||||
"""Return all of combined best path to discovered from all the scanners."""
|
||||
histories = self._connectable_history if connectable else self._all_history
|
||||
return [history.device for history in histories.values()]
|
||||
|
||||
@hass_callback
|
||||
def async_setup_unavailable_tracking(self) -> None:
|
||||
"""Set up the unavailable tracking."""
|
||||
self._cancel_unavailable_tracking = async_track_time_interval(
|
||||
self.hass,
|
||||
self._async_check_unavailable,
|
||||
timedelta(seconds=UNAVAILABLE_TRACK_SECONDS),
|
||||
name="Bluetooth manager unavailable tracking",
|
||||
self._schedule_unavailable_tracking()
|
||||
|
||||
def _schedule_unavailable_tracking(self) -> None:
|
||||
"""Schedule the 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, now: datetime) -> None:
|
||||
def _async_check_unavailable(self) -> None:
|
||||
"""Watch for unavailable devices and cleanup state history."""
|
||||
monotonic_now = MONOTONIC_TIME()
|
||||
connectable_history = self._connectable_history
|
||||
@@ -366,8 +329,7 @@ class BluetoothManager:
|
||||
# available for both connectable and non-connectable
|
||||
tracker.async_remove_fallback_interval(address)
|
||||
tracker.async_remove_address(address)
|
||||
self._integration_matcher.async_clear_address(address)
|
||||
self._async_dismiss_discoveries(address)
|
||||
self._address_disappeared(address)
|
||||
|
||||
service_info = history.pop(address)
|
||||
|
||||
@@ -380,13 +342,13 @@ class BluetoothManager:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Error in unavailable callback")
|
||||
|
||||
def _async_dismiss_discoveries(self, address: str) -> None:
|
||||
"""Dismiss all discoveries for the given 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"])
|
||||
self._schedule_unavailable_tracking()
|
||||
|
||||
def _address_disappeared(self, address: str) -> None:
|
||||
"""Call when an address disappears from the stack.
|
||||
|
||||
This method is intended to be overridden by subclasses.
|
||||
"""
|
||||
|
||||
def _prefer_previous_adv_from_different_source(
|
||||
self,
|
||||
@@ -439,7 +401,6 @@ class BluetoothManager:
|
||||
return False
|
||||
return True
|
||||
|
||||
@hass_callback
|
||||
def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None:
|
||||
"""Handle a new advertisement from any scanner.
|
||||
|
||||
@@ -570,16 +531,6 @@ class BluetoothManager:
|
||||
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 (
|
||||
bleak_callbacks := self._bleak_callbacks
|
||||
):
|
||||
@@ -589,22 +540,14 @@ class BluetoothManager:
|
||||
for callback_filters in bleak_callbacks:
|
||||
_dispatch_bleak_callback(*callback_filters, device, advertisement_data)
|
||||
|
||||
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")
|
||||
self._discover_service_info(service_info)
|
||||
|
||||
for domain in matched_domains:
|
||||
discovery_flow.async_create_flow(
|
||||
self.hass,
|
||||
domain,
|
||||
{"source": config_entries.SOURCE_BLUETOOTH},
|
||||
service_info,
|
||||
)
|
||||
def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None:
|
||||
"""Discover a new service info.
|
||||
|
||||
This method is intended to be overridden by subclasses.
|
||||
"""
|
||||
|
||||
@hass_callback
|
||||
def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str:
|
||||
"""Describe a source."""
|
||||
if scanner := self._sources.get(service_info.source):
|
||||
@@ -615,7 +558,6 @@ class BluetoothManager:
|
||||
description += " [connectable]"
|
||||
return description
|
||||
|
||||
@hass_callback
|
||||
def async_track_unavailable(
|
||||
self,
|
||||
callback: Callable[[BluetoothServiceInfoBleak], None],
|
||||
@@ -629,7 +571,6 @@ class BluetoothManager:
|
||||
unavailable_callbacks = self._unavailable_callbacks
|
||||
unavailable_callbacks.setdefault(address, []).append(callback)
|
||||
|
||||
@hass_callback
|
||||
def _async_remove_callback() -> None:
|
||||
unavailable_callbacks[address].remove(callback)
|
||||
if not unavailable_callbacks[address]:
|
||||
@@ -637,50 +578,6 @@ class BluetoothManager:
|
||||
|
||||
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(
|
||||
self, address: str, connectable: bool
|
||||
) -> BLEDevice | None:
|
||||
@@ -690,13 +587,11 @@ class BluetoothManager:
|
||||
return history.device
|
||||
return None
|
||||
|
||||
@hass_callback
|
||||
def async_address_present(self, address: str, connectable: bool) -> bool:
|
||||
"""Return if the address is present."""
|
||||
histories = self._connectable_history if connectable else self._all_history
|
||||
return address in histories
|
||||
|
||||
@hass_callback
|
||||
def async_discovered_service_info(
|
||||
self, connectable: bool
|
||||
) -> Iterable[BluetoothServiceInfoBleak]:
|
||||
@@ -704,7 +599,6 @@ class BluetoothManager:
|
||||
histories = self._connectable_history if connectable else self._all_history
|
||||
return histories.values()
|
||||
|
||||
@hass_callback
|
||||
def async_last_service_info(
|
||||
self, address: str, connectable: bool
|
||||
) -> BluetoothServiceInfoBleak | None:
|
||||
@@ -712,28 +606,6 @@ class BluetoothManager:
|
||||
histories = self._connectable_history if connectable else self._all_history
|
||||
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(
|
||||
self,
|
||||
scanner: BaseHaScanner,
|
||||
@@ -761,7 +633,6 @@ class BluetoothManager:
|
||||
self.slot_manager.register_adapter(scanner.adapter, connection_slots)
|
||||
return _unregister_scanner
|
||||
|
||||
@hass_callback
|
||||
def async_register_bleak_callback(
|
||||
self, callback: AdvertisementDataCallback, filters: dict[str, set[str]]
|
||||
) -> CALLBACK_TYPE:
|
||||
@@ -769,7 +640,6 @@ class BluetoothManager:
|
||||
callback_entry = (callback, filters)
|
||||
self._bleak_callbacks.append(callback_entry)
|
||||
|
||||
@hass_callback
|
||||
def _remove_callback() -> None:
|
||||
self._bleak_callbacks.remove(callback_entry)
|
||||
|
||||
@@ -783,29 +653,180 @@ class BluetoothManager:
|
||||
|
||||
return _remove_callback
|
||||
|
||||
@hass_callback
|
||||
def async_release_connection_slot(self, device: BLEDevice) -> None:
|
||||
"""Release a connection slot."""
|
||||
self.slot_manager.release_slot(device)
|
||||
|
||||
@hass_callback
|
||||
def async_allocate_connection_slot(self, device: BLEDevice) -> bool:
|
||||
"""Allocate a connection slot."""
|
||||
return self.slot_manager.allocate_slot(device)
|
||||
|
||||
@hass_callback
|
||||
def async_get_learned_advertising_interval(self, address: str) -> float | None:
|
||||
"""Get the learned advertising interval for a MAC address."""
|
||||
return self._intervals.get(address)
|
||||
|
||||
@hass_callback
|
||||
def async_get_fallback_availability_interval(self, address: str) -> float | None:
|
||||
"""Get the fallback availability timeout for a MAC address."""
|
||||
return self._fallback_intervals.get(address)
|
||||
|
||||
@hass_callback
|
||||
def async_set_fallback_availability_interval(
|
||||
self, address: str, interval: float
|
||||
) -> None:
|
||||
"""Override the fallback availability timeout for a MAC address."""
|
||||
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
|
||||
|
@@ -18,7 +18,8 @@
|
||||
"bleak-retry-connector==3.3.0",
|
||||
"bluetooth-adapters==0.16.1",
|
||||
"bluetooth-auto-recovery==1.2.3",
|
||||
"bluetooth-data-tools==1.15.0",
|
||||
"dbus-fast==2.14.0"
|
||||
"bluetooth-data-tools==1.17.0",
|
||||
"dbus-fast==2.20.0",
|
||||
"habluetooth==0.10.0"
|
||||
]
|
||||
}
|
||||
|
@@ -2,15 +2,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
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 homeassistant.util.dt import monotonic_time_coarse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manager import BluetoothManager
|
||||
|
||||
@@ -20,22 +17,6 @@ MANAGER: BluetoothManager | None = None
|
||||
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")
|
||||
BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None]
|
||||
ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool]
|
||||
|
@@ -7,6 +7,8 @@ from functools import cache
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast
|
||||
|
||||
from habluetooth import BluetoothScanningMode
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import (
|
||||
ATTR_CONNECTIONS,
|
||||
@@ -33,11 +35,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .models import (
|
||||
BluetoothChange,
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfoBleak,
|
||||
)
|
||||
from .models import BluetoothChange, BluetoothServiceInfoBleak
|
||||
|
||||
STORAGE_KEY = "bluetooth.passive_update_processor"
|
||||
STORAGE_VERSION = 1
|
||||
|
@@ -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)
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
import logging
|
||||
|
||||
from habluetooth import BluetoothScanningMode
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
|
||||
from .api import (
|
||||
@@ -13,7 +15,7 @@ from .api import (
|
||||
async_track_unavailable,
|
||||
)
|
||||
from .match import BluetoothCallbackMatcher
|
||||
from .models import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak
|
||||
from .models import BluetoothChange, BluetoothServiceInfoBleak
|
||||
|
||||
|
||||
class BasePassiveBluetoothCoordinator(ABC):
|
||||
|
@@ -2,10 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.util.dt import monotonic_time_coarse
|
||||
|
||||
from .models import BluetoothServiceInfoBleak
|
||||
from .storage import BluetoothStorage
|
||||
@@ -69,11 +68,3 @@ def async_load_history_from_system(
|
||||
connectable_loaded_history[address] = service_info
|
||||
|
||||
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
|
||||
|
@@ -283,7 +283,6 @@ class HaBleakClientWrapper(BleakClient):
|
||||
self.__disconnected_callback
|
||||
),
|
||||
timeout=self.__timeout,
|
||||
hass=manager.hass,
|
||||
)
|
||||
if debug_logging:
|
||||
# Only lookup the description if we are going to log it
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bimmer_connected"],
|
||||
"requirements": ["bimmer-connected==0.14.3"]
|
||||
"requirements": ["bimmer-connected[china]==0.14.6"]
|
||||
}
|
||||
|
@@ -21,10 +21,10 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util.percentage import (
|
||||
int_states_in_range,
|
||||
percentage_to_ranged_value,
|
||||
ranged_value_to_percentage,
|
||||
)
|
||||
from homeassistant.util.scaling import int_states_in_range
|
||||
|
||||
from .const import DOMAIN, SERVICE_SET_FAN_SPEED_TRACKED_STATE
|
||||
from .entity import BondEntity
|
||||
@@ -199,10 +199,6 @@ class BondFan(BondEntity, FanEntity):
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""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))
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
|
@@ -12,6 +12,9 @@
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"access_token": "[%key:common::config_flow::data::access_token%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The IP address of your Bond hub."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -6,6 +6,9 @@
|
||||
"title": "SHC authentication parameters",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Bosch Smart Home Controller."
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
|
@@ -5,6 +5,9 @@
|
||||
"description": "Ensure that your TV is turned on before trying to set it up.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the Sony Bravia TV to control."
|
||||
}
|
||||
},
|
||||
"authorize": {
|
||||
|
@@ -3,10 +3,13 @@
|
||||
"flow_title": "{name} ({model} at {host})",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to the device",
|
||||
"description": "Connect to the device",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"timeout": "Timeout"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Broadlink device."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
|
@@ -6,6 +6,9 @@
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"type": "Type of the printer"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the Brother printer to control."
|
||||
}
|
||||
},
|
||||
"zeroconf_confirm": {
|
||||
|
@@ -60,8 +60,7 @@ async def async_setup_entry(
|
||||
data.static,
|
||||
entry,
|
||||
)
|
||||
],
|
||||
True,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
@@ -11,6 +11,9 @@
|
||||
"passkey": "Passkey string",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your BSB-Lan device."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -11,7 +11,11 @@ async def async_get_calendars(
|
||||
hass: HomeAssistant, client: caldav.DAVClient, component: str
|
||||
) -> list[caldav.Calendar]:
|
||||
"""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(
|
||||
*[
|
||||
hass.async_add_executor_job(calendar.get_supported_components)
|
||||
|
@@ -2,10 +2,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
|
||||
import caldav
|
||||
from caldav.lib.error import DAVError, NotFoundError
|
||||
@@ -21,6 +21,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
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 .const import DOMAIN
|
||||
@@ -71,6 +72,12 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None:
|
||||
or (summary := get_attr_value(todo, "summary")) is 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(
|
||||
uid=uid,
|
||||
summary=summary,
|
||||
@@ -78,9 +85,25 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None:
|
||||
get_attr_value(todo, "status") or "",
|
||||
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):
|
||||
"""CalDAV To-do list entity."""
|
||||
|
||||
@@ -89,6 +112,9 @@ class WebDavTodoListEntity(TodoListEntity):
|
||||
TodoListEntityFeature.CREATE_TODO_ITEM
|
||||
| TodoListEntityFeature.UPDATE_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:
|
||||
@@ -116,13 +142,7 @@ class WebDavTodoListEntity(TodoListEntity):
|
||||
"""Add an item to the To-do list."""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
self._calendar.save_todo,
|
||||
summary=item.summary,
|
||||
status=TODO_STATUS_MAP_INV.get(
|
||||
item.status or TodoItemStatus.NEEDS_ACTION, "NEEDS-ACTION"
|
||||
),
|
||||
),
|
||||
partial(self._calendar.save_todo, **_to_ics_fields(item)),
|
||||
)
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV save error: {err}") from err
|
||||
@@ -139,10 +159,10 @@ class WebDavTodoListEntity(TodoListEntity):
|
||||
except (requests.ConnectionError, DAVError) as err:
|
||||
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
|
||||
vtodo = todo.icalendar_component # type: ignore[attr-defined]
|
||||
if item.summary:
|
||||
vtodo["summary"] = item.summary
|
||||
if item.status:
|
||||
vtodo["status"] = TODO_STATUS_MAP_INV.get(item.status, "NEEDS-ACTION")
|
||||
updated_fields = _to_ics_fields(item)
|
||||
if "due" in updated_fields:
|
||||
todo.set_due(updated_fields.pop("due")) # type: ignore[attr-defined]
|
||||
vtodo.update(**updated_fields)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
|
@@ -73,7 +73,7 @@
|
||||
}
|
||||
},
|
||||
"get_events": {
|
||||
"name": "Get event",
|
||||
"name": "Get events",
|
||||
"description": "Get events on a calendar within a time range.",
|
||||
"fields": {
|
||||
"start_date_time": {
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "co2signal",
|
||||
"name": "Electricity Maps",
|
||||
"codeowners": ["@jpbede"],
|
||||
"codeowners": ["@jpbede", "@VIKTORVAV99"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/co2signal",
|
||||
"integration_type": "service",
|
||||
|
@@ -68,13 +68,13 @@ class ComelitSerialBridge(DataUpdateCoordinator):
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update device data."""
|
||||
_LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host)
|
||||
|
||||
try:
|
||||
await self.api.login()
|
||||
return await self.api.get_all_devices()
|
||||
except exceptions.CannotConnect as err:
|
||||
_LOGGER.warning("Connection error for %s", self._host)
|
||||
await self.api.close()
|
||||
raise UpdateFailed(f"Error fetching data: {repr(err)}") from err
|
||||
except exceptions.CannotAuthenticate:
|
||||
raise ConfigEntryAuthFailed
|
||||
|
||||
return await self.api.get_all_devices()
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/comelit",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"requirements": ["aiocomelit==0.5.2"]
|
||||
"requirements": ["aiocomelit==0.6.2"]
|
||||
}
|
||||
|
@@ -13,6 +13,9 @@
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"pin": "[%key:common::config_flow::data::pin%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Comelit device."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -22,10 +22,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util.percentage import (
|
||||
int_states_in_range,
|
||||
percentage_to_ranged_value,
|
||||
ranged_value_to_percentage,
|
||||
)
|
||||
from homeassistant.util.scaling import int_states_in_range
|
||||
|
||||
from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge
|
||||
|
||||
|
@@ -649,7 +649,7 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
if device_area is None:
|
||||
return None
|
||||
|
||||
return {"area": device_area.name}
|
||||
return {"area": device_area.id}
|
||||
|
||||
def _get_error_text(
|
||||
self, response_type: ResponseType, lang_intents: LanguageIntents | None
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"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"]
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up your CoolMasterNet connection details.",
|
||||
"description": "Set up your CoolMasterNet connection details.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"off": "Can be turned off",
|
||||
@@ -12,6 +12,9 @@
|
||||
"dry": "Support dry mode",
|
||||
"fan_only": "Support fan only mode",
|
||||
"swing_support": "Control swing mode"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your CoolMasterNet device."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -67,7 +67,7 @@ DECONZ_TO_COLOR_MODE = {
|
||||
LightColorMode.XY: ColorMode.XY,
|
||||
}
|
||||
|
||||
TS0601_EFFECTS = [
|
||||
XMAS_LIGHT_EFFECTS = [
|
||||
"carnival",
|
||||
"collide",
|
||||
"fading",
|
||||
@@ -200,8 +200,8 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity):
|
||||
if device.effect is not None:
|
||||
self._attr_supported_features |= LightEntityFeature.EFFECT
|
||||
self._attr_effect_list = [EFFECT_COLORLOOP]
|
||||
if device.model_id == "TS0601":
|
||||
self._attr_effect_list += TS0601_EFFECTS
|
||||
if device.model_id in ("HG06467", "TS0601"):
|
||||
self._attr_effect_list = XMAS_LIGHT_EFFECTS
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
|
@@ -11,11 +11,14 @@
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your deCONZ host."
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"title": "Link with deCONZ",
|
||||
"description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button"
|
||||
"description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings > Gateway > Advanced\n2. Press \"Authenticate app\" button"
|
||||
},
|
||||
"hassio_confirm": {
|
||||
"title": "deCONZ Zigbee gateway via Home Assistant add-on",
|
||||
|
@@ -9,6 +9,9 @@
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"web_port": "Web port (for visiting service)"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Deluge device."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -161,12 +161,9 @@ class DemoPercentageFan(BaseDemoFan, FanEntity):
|
||||
|
||||
def set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
if self.preset_modes and preset_mode in self.preset_modes:
|
||||
self._preset_mode = preset_mode
|
||||
self._percentage = None
|
||||
self.schedule_update_ha_state()
|
||||
else:
|
||||
raise ValueError(f"Invalid preset mode: {preset_mode}")
|
||||
|
||||
def turn_on(
|
||||
self,
|
||||
@@ -230,10 +227,6 @@ class AsyncDemoPercentageFan(BaseDemoFan, FanEntity):
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""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._percentage = None
|
||||
self.async_write_ha_state()
|
||||
|
@@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
|
||||
class DevialetCoordinator(DataUpdateCoordinator):
|
||||
class DevialetCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Devialet update coordinator."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client: DevialetApi) -> None:
|
||||
@@ -27,6 +27,6 @@ class DevialetCoordinator(DataUpdateCoordinator):
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self):
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data from API endpoint."""
|
||||
await self.client.async_update()
|
||||
|
@@ -46,13 +46,15 @@ async def async_setup_entry(
|
||||
async_add_entities([DevialetMediaPlayerEntity(coordinator, entry)])
|
||||
|
||||
|
||||
class DevialetMediaPlayerEntity(CoordinatorEntity, MediaPlayerEntity):
|
||||
class DevialetMediaPlayerEntity(
|
||||
CoordinatorEntity[DevialetCoordinator], MediaPlayerEntity
|
||||
):
|
||||
"""Devialet media player."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, coordinator, entry: ConfigEntry) -> None:
|
||||
def __init__(self, coordinator: DevialetCoordinator, entry: ConfigEntry) -> None:
|
||||
"""Initialize the Devialet device."""
|
||||
self.coordinator = coordinator
|
||||
super().__init__(coordinator)
|
||||
|
@@ -14,7 +14,11 @@ import voluptuous as vol
|
||||
from homeassistant import util
|
||||
from homeassistant.backports.functools import cached_property
|
||||
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 (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_GPS_ACCURACY,
|
||||
@@ -33,7 +37,6 @@ from homeassistant.const import (
|
||||
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
config_per_platform,
|
||||
config_validation as cv,
|
||||
discovery,
|
||||
entity_registry as er,
|
||||
@@ -284,7 +287,7 @@ class DeviceTrackerPlatform:
|
||||
) -> None:
|
||||
"""Set up a legacy platform."""
|
||||
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)
|
||||
with async_start_setup(hass, [full_name]):
|
||||
try:
|
||||
@@ -1033,6 +1036,19 @@ def update_config(path: str, dev_id: str, device: Device) -> None:
|
||||
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:
|
||||
"""Return an 80px Gravatar for the given email address.
|
||||
|
||||
|
@@ -8,6 +8,9 @@
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your DirectTV device."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -183,6 +183,7 @@ async def async_setup_entry(
|
||||
for description in sensors
|
||||
for value_key in {description.key, *description.alternative_keys}
|
||||
if description.value_fn(coordinator.data, value_key, description.scale)
|
||||
is not None
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
@@ -9,6 +9,7 @@
|
||||
"use_legacy_protocol": "Use legacy protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your D-Link device",
|
||||
"password": "Default: PIN code on the back."
|
||||
}
|
||||
},
|
||||
|
@@ -17,8 +17,11 @@
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"name": "Device Name",
|
||||
"name": "Device name",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your DoorBird device."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -4,6 +4,9 @@
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Dremel 3D printer."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -116,7 +116,7 @@ class DSMRConnection:
|
||||
|
||||
try:
|
||||
transport, protocol = await asyncio.create_task(reader_factory())
|
||||
except (serial.serialutil.SerialException, OSError):
|
||||
except (serial.SerialException, OSError):
|
||||
LOGGER.exception("Error connecting to DSMR")
|
||||
return False
|
||||
|
||||
|
@@ -12,8 +12,6 @@ LOGGER = logging.getLogger(__package__)
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
CONF_DSMR_VERSION = "dsmr_version"
|
||||
CONF_PROTOCOL = "protocol"
|
||||
CONF_RECONNECT_INTERVAL = "reconnect_interval"
|
||||
CONF_PRECISION = "precision"
|
||||
CONF_TIME_BETWEEN_UPDATE = "time_between_update"
|
||||
|
||||
CONF_SERIAL_ID = "serial_id"
|
||||
@@ -29,6 +27,7 @@ DATA_TASK = "task"
|
||||
|
||||
DEVICE_NAME_ELECTRICITY = "Electricity Meter"
|
||||
DEVICE_NAME_GAS = "Gas Meter"
|
||||
DEVICE_NAME_WATER = "Water Meter"
|
||||
|
||||
DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"}
|
||||
|
||||
|
@@ -34,6 +34,7 @@ from homeassistant.const import (
|
||||
UnitOfVolume,
|
||||
)
|
||||
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.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
@@ -45,9 +46,7 @@ from homeassistant.util import Throttle
|
||||
|
||||
from .const import (
|
||||
CONF_DSMR_VERSION,
|
||||
CONF_PRECISION,
|
||||
CONF_PROTOCOL,
|
||||
CONF_RECONNECT_INTERVAL,
|
||||
CONF_SERIAL_ID,
|
||||
CONF_SERIAL_ID_GAS,
|
||||
CONF_TIME_BETWEEN_UPDATE,
|
||||
@@ -57,6 +56,7 @@ from .const import (
|
||||
DEFAULT_TIME_BETWEEN_UPDATE,
|
||||
DEVICE_NAME_ELECTRICITY,
|
||||
DEVICE_NAME_GAS,
|
||||
DEVICE_NAME_WATER,
|
||||
DOMAIN,
|
||||
DSMR_PROTOCOL,
|
||||
LOGGER,
|
||||
@@ -73,10 +73,18 @@ class DSMRSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
dsmr_versions: set[str] | None = None
|
||||
is_gas: bool = False
|
||||
is_water: bool = False
|
||||
obis_reference: str
|
||||
|
||||
|
||||
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(
|
||||
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:
|
||||
"""Return correct entity for 5B Gas meter."""
|
||||
ref = None
|
||||
if obis_references.BELGIUM_MBUS1_METER_READING2 in telegram:
|
||||
ref = obis_references.BELGIUM_MBUS1_METER_READING2
|
||||
elif obis_references.BELGIUM_MBUS2_METER_READING2 in telegram:
|
||||
ref = obis_references.BELGIUM_MBUS2_METER_READING2
|
||||
elif obis_references.BELGIUM_MBUS3_METER_READING2 in telegram:
|
||||
ref = obis_references.BELGIUM_MBUS3_METER_READING2
|
||||
elif obis_references.BELGIUM_MBUS4_METER_READING2 in telegram:
|
||||
ref = obis_references.BELGIUM_MBUS4_METER_READING2
|
||||
elif ref is None:
|
||||
ref = obis_references.BELGIUM_MBUS1_METER_READING2
|
||||
def create_mbus_entity(
|
||||
mbus: int, mtype: int, telegram: dict[str, DSMRObject]
|
||||
) -> DSMRSensorEntityDescription | None:
|
||||
"""Create a new MBUS Entity."""
|
||||
if (
|
||||
mtype == 3
|
||||
and (
|
||||
obis_reference := getattr(
|
||||
obis_references, f"BELGIUM_MBUS{mbus}_METER_READING2"
|
||||
)
|
||||
)
|
||||
in telegram
|
||||
):
|
||||
return DSMRSensorEntityDescription(
|
||||
key="belgium_5min_gas_meter_reading",
|
||||
key=f"mbus{mbus}_gas_reading",
|
||||
translation_key="gas_meter_reading",
|
||||
obis_reference=ref,
|
||||
dsmr_versions={"5B"},
|
||||
obis_reference=obis_reference,
|
||||
is_gas=True,
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
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(
|
||||
@@ -415,25 +533,10 @@ async def async_setup_entry(
|
||||
add_entities_handler()
|
||||
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":
|
||||
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(
|
||||
[
|
||||
@@ -443,7 +546,7 @@ async def async_setup_entry(
|
||||
telegram,
|
||||
*device_class_and_uom(telegram, description), # type: ignore[arg-type]
|
||||
)
|
||||
for description in all_sensors
|
||||
for description in SENSORS
|
||||
if (
|
||||
description.dsmr_versions is None
|
||||
or dsmr_version in description.dsmr_versions
|
||||
@@ -549,11 +652,9 @@ async def async_setup_entry(
|
||||
update_entities_telegram(None)
|
||||
|
||||
# throttle reconnect attempts
|
||||
await asyncio.sleep(
|
||||
entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL)
|
||||
)
|
||||
await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
|
||||
|
||||
except (serial.serialutil.SerialException, OSError):
|
||||
except (serial.SerialException, OSError):
|
||||
# Log any error while establishing connection and drop to retry
|
||||
# connection wait
|
||||
LOGGER.exception("Error connecting to DSMR")
|
||||
@@ -565,9 +666,7 @@ async def async_setup_entry(
|
||||
update_entities_telegram(None)
|
||||
|
||||
# throttle reconnect attempts
|
||||
await asyncio.sleep(
|
||||
entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL)
|
||||
)
|
||||
await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
|
||||
except CancelledError:
|
||||
# Reflect disconnect state in devices state by setting an
|
||||
# None telegram resulting in `unavailable` states
|
||||
@@ -618,6 +717,8 @@ class DSMREntity(SensorEntity):
|
||||
telegram: dict[str, DSMRObject],
|
||||
device_class: SensorDeviceClass,
|
||||
native_unit_of_measurement: str | None,
|
||||
serial_id: str = "",
|
||||
mbus_id: int = 0,
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
self.entity_description = entity_description
|
||||
@@ -629,8 +730,15 @@ class DSMREntity(SensorEntity):
|
||||
device_serial = entry.data[CONF_SERIAL_ID]
|
||||
device_name = DEVICE_NAME_ELECTRICITY
|
||||
if entity_description.is_gas:
|
||||
if serial_id:
|
||||
device_serial = serial_id
|
||||
else:
|
||||
device_serial = entry.data[CONF_SERIAL_ID_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:
|
||||
device_serial = entry.entry_id
|
||||
|
||||
@@ -638,6 +746,12 @@ class DSMREntity(SensorEntity):
|
||||
identifiers={(DOMAIN, device_serial)},
|
||||
name=device_name,
|
||||
)
|
||||
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
|
||||
@@ -682,9 +796,11 @@ class DSMREntity(SensorEntity):
|
||||
return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION])
|
||||
|
||||
with suppress(TypeError):
|
||||
value = round(
|
||||
float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION)
|
||||
)
|
||||
value = round(float(value), 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
|
||||
|
||||
|
@@ -147,6 +147,9 @@
|
||||
},
|
||||
"voltage_swell_l3_count": {
|
||||
"name": "Voltage swells phase L3"
|
||||
},
|
||||
"water_meter_reading": {
|
||||
"name": "Water consumption"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -5,6 +5,9 @@
|
||||
"description": "Ensure that your player is turned on.",
|
||||
"data": {
|
||||
"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
Reference in New Issue
Block a user