Merge branch 'dev' into fuelcell

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

View File

@@ -404,6 +404,9 @@ omit =
homeassistant/components/fjaraskupan/sensor.py
homeassistant/components/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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 }}

View File

@@ -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.*

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

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

View File

@@ -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."
}
}
},

View File

@@ -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

View File

@@ -13,7 +13,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.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()

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@
"device_path": "Device Path"
},
"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)"
}
}

View File

@@ -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(

View File

@@ -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,13 +1333,11 @@ async def async_api_set_range(
range_value = int(range_value)
if range_value == 0:
service = fan.SERVICE_TURN_OFF
elif supported & fan.FanEntityFeature.SET_SPEED:
service = fan.SERVICE_SET_PERCENTAGE
data[fan.ATTR_PERCENTAGE] = range_value
else:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported and fan.FanEntityFeature.SET_SPEED:
service = fan.SERVICE_SET_PERCENTAGE
data[fan.ATTR_PERCENTAGE] = range_value
else:
service = fan.SERVICE_TURN_ON
service = fan.SERVICE_TURN_ON
# Humidifier target humidity
elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}":

View File

@@ -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."
}
}
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,9 @@ from datetime import timedelta
import logging
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

View File

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

View File

@@ -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,17 +391,14 @@ 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(
domain, service, data, blocking=True, context=context
)
# shield the service call from cancellation on connection drop
await shield(
hass.services.async_call(
domain, service, data, blocking=True, context=context
)
)
except (vol.Invalid, ServiceNotFound) as ex:
raise HTTPBadRequest() from ex
except TimeoutError:
pass
finally:
cancel_listen()

View File

@@ -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,43 +1024,40 @@ class PipelineRun:
)
)
try:
# Synthesize audio and get URL
tts_media_id = tts_generate_media_source_id(
self.hass,
tts_input,
engine=self.tts_engine,
language=self.pipeline.tts_language,
options=self.tts_options,
)
tts_media = await media_source.async_resolve_media(
self.hass,
tts_media_id,
None,
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during text-to-speech")
raise TextToSpeechError(
code="tts-failed",
message="Unexpected error during text-to-speech",
) from src_error
if tts_input := tts_input.strip():
try:
# Synthesize audio and get URL
tts_media_id = tts_generate_media_source_id(
self.hass,
tts_input,
engine=self.tts_engine,
language=self.pipeline.tts_language,
options=self.tts_options,
)
tts_media = await media_source.async_resolve_media(
self.hass,
tts_media_id,
None,
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during text-to-speech")
raise TextToSpeechError(
code="tts-failed",
message="Unexpected error during text-to-speech",
) from src_error
_LOGGER.debug("TTS result %s", tts_media)
_LOGGER.debug("TTS result %s", tts_media)
tts_output = {
"media_id": tts_media_id,
**asdict(tts_media),
}
else:
tts_output = {}
self.process_event(
PipelineEvent(
PipelineEventType.TTS_END,
{
"tts_output": {
"media_id": tts_media_id,
**asdict(tts_media),
}
},
)
PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output})
)
return tts_media.url
def _capture_chunk(self, audio_bytes: bytes | None) -> None:
"""Forward audio chunk to various capturing mechanisms."""
if self.debug_recording_queue is not None:
@@ -1247,6 +1244,8 @@ def _pipeline_debug_recording_thread_proc(
# Chunk of 16-bit mono audio at 16Khz
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:

View File

@@ -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))

View File

@@ -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",

View File

@@ -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."
}
}
},

View File

@@ -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:

View File

@@ -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,

View File

@@ -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"
}
}
}

View File

@@ -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

View File

@@ -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."
}
}
},

View File

@@ -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:

View File

@@ -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."
}
}
},

View File

@@ -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

View File

@@ -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,

View File

@@ -1,14 +1,28 @@
# Describes the format for available Blink services
blink_update:
fields:
device_id:
required: true
selector:
device:
integration: blink
trigger_camera:
target:
entity:
integration: blink
domain: camera
fields:
device_id:
required: true
selector:
device:
integration: blink
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:

View File

@@ -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"
}
}
}

View File

@@ -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
)

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -9,29 +9,25 @@ from asyncio import Future
from collections.abc import Callable, Iterable
from 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

View File

@@ -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:
self._async_save_history()
self._async_stop_scanner_watchdog()
cancel_track()
cancel_stop()
return _cancel
@hass_callback
def _unsetup(self) -> None:
super()._unsetup()
self._async_save_history()
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(
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()
},
}
diag = await super().async_diagnostics()
diag["storage"] = self._storage.async_get_advertisement_history_as_dict(
self.source
)
return diag

View File

@@ -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

View File

@@ -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

View File

@@ -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"
]
}

View File

@@ -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]

View File

@@ -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

View File

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

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
from abc import ABC, abstractmethod
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):

View File

@@ -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

View File

@@ -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

View File

@@ -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"]
}

View File

@@ -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:

View File

@@ -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."
}
}
},

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

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

View File

@@ -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."
}
}
},

View File

@@ -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)

View File

@@ -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(

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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()

View File

@@ -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"]
}

View File

@@ -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."
}
}
},

View File

@@ -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

View File

@@ -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

View File

@@ -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"]
}

View File

@@ -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."
}
}
},

View File

@@ -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:

View File

@@ -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",

View File

@@ -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."
}
}
},

View File

@@ -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}")
self._preset_mode = preset_mode
self._percentage = None
self.schedule_update_ha_state()
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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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.

View File

@@ -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."
}
}
},

View File

@@ -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)

View File

@@ -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."
}
},

View File

@@ -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."
}
}
},

View File

@@ -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."
}
}
},

View File

@@ -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

View File

@@ -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"}

View File

@@ -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
return DSMRSensorEntityDescription(
key="belgium_5min_gas_meter_reading",
translation_key="gas_meter_reading",
obis_reference=ref,
dsmr_versions={"5B"},
is_gas=True,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
)
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=f"mbus{mbus}_gas_reading",
translation_key="gas_meter_reading",
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:
device_serial = entry.data[CONF_SERIAL_ID_GAS]
if serial_id:
device_serial = serial_id
else:
device_serial = entry.data[CONF_SERIAL_ID_GAS]
device_name = DEVICE_NAME_GAS
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,7 +746,13 @@ class DSMREntity(SensorEntity):
identifiers={(DOMAIN, device_serial)},
name=device_name,
)
self._attr_unique_id = f"{device_serial}_{entity_description.key}"
if mbus_id != 0:
if serial_id:
self._attr_unique_id = f"{device_serial}"
else:
self._attr_unique_id = f"{device_serial}_{mbus_id}"
else:
self._attr_unique_id = f"{device_serial}_{entity_description.key}"
@callback
def update_data(self, telegram: dict[str, DSMRObject] | None) -> None:
@@ -682,9 +796,11 @@ class DSMREntity(SensorEntity):
return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION])
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

View File

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

View File

@@ -5,6 +5,9 @@
"description": "Ensure that your player is turned on.",
"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