Merge branch 'dev' into AddClimate_MideaCCM15

This commit is contained in:
Oscar Calvo
2023-08-26 15:28:26 -07:00
committed by GitHub
521 changed files with 11840 additions and 5003 deletions

View File

@@ -1509,7 +1509,6 @@ omit =
homeassistant/components/yale_smart_alarm/alarm_control_panel.py homeassistant/components/yale_smart_alarm/alarm_control_panel.py
homeassistant/components/yale_smart_alarm/binary_sensor.py homeassistant/components/yale_smart_alarm/binary_sensor.py
homeassistant/components/yale_smart_alarm/button.py homeassistant/components/yale_smart_alarm/button.py
homeassistant/components/yale_smart_alarm/coordinator.py
homeassistant/components/yale_smart_alarm/entity.py homeassistant/components/yale_smart_alarm/entity.py
homeassistant/components/yale_smart_alarm/lock.py homeassistant/components/yale_smart_alarm/lock.py
homeassistant/components/yalexs_ble/__init__.py homeassistant/components/yalexs_ble/__init__.py
@@ -1524,6 +1523,9 @@ omit =
homeassistant/components/yamaha_musiccast/select.py homeassistant/components/yamaha_musiccast/select.py
homeassistant/components/yamaha_musiccast/switch.py homeassistant/components/yamaha_musiccast/switch.py
homeassistant/components/yandex_transport/sensor.py homeassistant/components/yandex_transport/sensor.py
homeassistant/components/yardian/__init__.py
homeassistant/components/yardian/coordinator.py
homeassistant/components/yardian/switch.py
homeassistant/components/yeelightsunflower/light.py homeassistant/components/yeelightsunflower/light.py
homeassistant/components/yi/camera.py homeassistant/components/yi/camera.py
homeassistant/components/yolink/__init__.py homeassistant/components/yolink/__init__.py

View File

@@ -24,7 +24,7 @@ jobs:
publish: ${{ steps.version.outputs.publish }} publish: ${{ steps.version.outputs.publish }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -56,7 +56,7 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.0 uses: actions/setup-python@v4.7.0
@@ -98,7 +98,7 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Download nightly wheels of frontend - name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
@@ -254,7 +254,7 @@ jobs:
- green - green
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set build additional args - name: Set build additional args
run: | run: |
@@ -293,7 +293,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Initialize git - name: Initialize git
uses: home-assistant/actions/helpers/git-init@master uses: home-assistant/actions/helpers/git-init@master
@@ -331,7 +331,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@v3.1.1 uses: sigstore/cosign-installer@v3.1.1

View File

@@ -87,7 +87,7 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Generate partial Python venv restore key - name: Generate partial Python venv restore key
id: generate_python_cache_key id: generate_python_cache_key
run: >- run: >-
@@ -220,7 +220,7 @@ jobs:
- info - info
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.0 uses: actions/setup-python@v4.7.0
@@ -265,7 +265,7 @@ jobs:
- pre-commit - pre-commit
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.0 uses: actions/setup-python@v4.7.0
id: python id: python
@@ -311,7 +311,7 @@ jobs:
- pre-commit - pre-commit
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.0 uses: actions/setup-python@v4.7.0
id: python id: python
@@ -360,7 +360,7 @@ jobs:
- pre-commit - pre-commit
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.0 uses: actions/setup-python@v4.7.0
id: python id: python
@@ -454,7 +454,7 @@ jobs:
python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} python-version: ${{ fromJSON(needs.info.outputs.python_versions) }}
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v4.7.0 uses: actions/setup-python@v4.7.0
@@ -522,7 +522,7 @@ jobs:
- base - base
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.0 uses: actions/setup-python@v4.7.0
@@ -554,7 +554,7 @@ jobs:
- base - base
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.0 uses: actions/setup-python@v4.7.0
@@ -587,7 +587,7 @@ jobs:
- base - base
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.0 uses: actions/setup-python@v4.7.0
@@ -631,7 +631,7 @@ jobs:
- base - base
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@v4.7.0 uses: actions/setup-python@v4.7.0
@@ -713,7 +713,7 @@ jobs:
bluez \ bluez \
ffmpeg ffmpeg
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v4.7.0 uses: actions/setup-python@v4.7.0
@@ -865,7 +865,7 @@ jobs:
ffmpeg \ ffmpeg \
libmariadb-dev-compat libmariadb-dev-compat
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v4.7.0 uses: actions/setup-python@v4.7.0
@@ -989,7 +989,7 @@ jobs:
ffmpeg \ ffmpeg \
postgresql-server-dev-14 postgresql-server-dev-14
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v4.7.0 uses: actions/setup-python@v4.7.0
@@ -1084,7 +1084,7 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
- name: Upload coverage to Codecov (full coverage) - name: Upload coverage to Codecov (full coverage)

View File

@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.0 uses: actions/setup-python@v4.7.0

View File

@@ -26,7 +26,7 @@ jobs:
architectures: ${{ steps.info.outputs.architectures }} architectures: ${{ steps.info.outputs.architectures }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Get information - name: Get information
id: info id: info
@@ -84,7 +84,7 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Download env_file - name: Download env_file
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
@@ -122,7 +122,7 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v3.5.3 uses: actions/checkout@v3.6.0
- name: Download env_file - name: Download env_file
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3

View File

@@ -183,6 +183,7 @@ homeassistant.components.imap.*
homeassistant.components.input_button.* homeassistant.components.input_button.*
homeassistant.components.input_select.* homeassistant.components.input_select.*
homeassistant.components.integration.* homeassistant.components.integration.*
homeassistant.components.ipp.*
homeassistant.components.iqvia.* homeassistant.components.iqvia.*
homeassistant.components.isy994.* homeassistant.components.isy994.*
homeassistant.components.jellyfin.* homeassistant.components.jellyfin.*
@@ -325,6 +326,7 @@ homeassistant.components.tplink.*
homeassistant.components.tplink_omada.* homeassistant.components.tplink_omada.*
homeassistant.components.tractive.* homeassistant.components.tractive.*
homeassistant.components.tradfri.* homeassistant.components.tradfri.*
homeassistant.components.trafikverket_camera.*
homeassistant.components.trafikverket_ferry.* homeassistant.components.trafikverket_ferry.*
homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_train.*
homeassistant.components.trafikverket_weatherstation.* homeassistant.components.trafikverket_weatherstation.*

View File

@@ -1302,6 +1302,8 @@ build.json @home-assistant/supervisor
/tests/components/trace/ @home-assistant/core /tests/components/trace/ @home-assistant/core
/homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu /homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu
/tests/components/tractive/ @Danielhiversen @zhulik @bieniu /tests/components/tractive/ @Danielhiversen @zhulik @bieniu
/homeassistant/components/trafikverket_camera/ @gjohansson-ST
/tests/components/trafikverket_camera/ @gjohansson-ST
/homeassistant/components/trafikverket_ferry/ @gjohansson-ST /homeassistant/components/trafikverket_ferry/ @gjohansson-ST
/tests/components/trafikverket_ferry/ @gjohansson-ST /tests/components/trafikverket_ferry/ @gjohansson-ST
/homeassistant/components/trafikverket_train/ @endor-force @gjohansson-ST /homeassistant/components/trafikverket_train/ @endor-force @gjohansson-ST
@@ -1444,6 +1446,7 @@ build.json @home-assistant/supervisor
/tests/components/yamaha_musiccast/ @vigonotion @micha91 /tests/components/yamaha_musiccast/ @vigonotion @micha91
/homeassistant/components/yandex_transport/ @rishatik92 @devbis /homeassistant/components/yandex_transport/ @rishatik92 @devbis
/tests/components/yandex_transport/ @rishatik92 @devbis /tests/components/yandex_transport/ @rishatik92 @devbis
/homeassistant/components/yardian/ @h3l1o5
/homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
/tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
/homeassistant/components/yeelightsunflower/ @lindsaymarkward /homeassistant/components/yeelightsunflower/ @lindsaymarkward

View File

@@ -2,6 +2,7 @@
"domain": "trafikverket", "domain": "trafikverket",
"name": "Trafikverket", "name": "Trafikverket",
"integrations": [ "integrations": [
"trafikverket_camera",
"trafikverket_ferry", "trafikverket_ferry",
"trafikverket_train", "trafikverket_train",
"trafikverket_weatherstation" "trafikverket_weatherstation"

View File

@@ -30,7 +30,7 @@ async def async_setup_entry(
data: AbodeSystem = hass.data[DOMAIN] data: AbodeSystem = hass.data[DOMAIN]
async_add_entities( async_add_entities(
AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE) # pylint: disable=no-member AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE)
for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA) for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA)
) )

View File

@@ -17,7 +17,8 @@ from homeassistant.components.weather import (
ATTR_FORECAST_UV_INDEX, ATTR_FORECAST_UV_INDEX,
ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_BEARING,
Forecast, Forecast,
WeatherEntity, SingleCoordinatorWeatherEntity,
WeatherEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
@@ -27,9 +28,8 @@ from homeassistant.const import (
UnitOfSpeed, UnitOfSpeed,
UnitOfTemperature, UnitOfTemperature,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import utc_from_timestamp from homeassistant.util.dt import utc_from_timestamp
from . import AccuWeatherDataUpdateCoordinator from . import AccuWeatherDataUpdateCoordinator
@@ -58,7 +58,7 @@ async def async_setup_entry(
class AccuWeatherEntity( class AccuWeatherEntity(
CoordinatorEntity[AccuWeatherDataUpdateCoordinator], WeatherEntity SingleCoordinatorWeatherEntity[AccuWeatherDataUpdateCoordinator]
): ):
"""Define an AccuWeather entity.""" """Define an AccuWeather entity."""
@@ -76,6 +76,8 @@ class AccuWeatherEntity(
self._attr_unique_id = coordinator.location_key self._attr_unique_id = coordinator.location_key
self._attr_attribution = ATTRIBUTION self._attr_attribution = ATTRIBUTION
self._attr_device_info = coordinator.device_info self._attr_device_info = coordinator.device_info
if self.coordinator.forecast:
self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY
@property @property
def condition(self) -> str | None: def condition(self) -> str | None:
@@ -174,3 +176,8 @@ class AccuWeatherEntity(
} }
for item in self.coordinator.data[ATTR_FORECAST] for item in self.coordinator.data[ATTR_FORECAST]
] ]
@callback
def _async_forecast_daily(self) -> list[Forecast] | None:
"""Return the daily forecast in native units."""
return self.forecast

View File

@@ -1,7 +1,7 @@
"""The AEMET OpenData component.""" """The AEMET OpenData component."""
import logging import logging
from aemet_opendata.interface import AEMET from aemet_opendata.interface import AEMET, ConnectionOptions
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
@@ -28,7 +28,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
longitude = entry.data[CONF_LONGITUDE] longitude = entry.data[CONF_LONGITUDE]
station_updates = entry.options.get(CONF_STATION_UPDATES, True) station_updates = entry.options.get(CONF_STATION_UPDATES, True)
aemet = AEMET(aiohttp_client.async_get_clientsession(hass), api_key) options = ConnectionOptions(api_key, station_updates)
aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options)
weather_coordinator = WeatherUpdateCoordinator( weather_coordinator = WeatherUpdateCoordinator(
hass, aemet, latitude, longitude, station_updates hass, aemet, latitude, longitude, station_updates
) )

View File

@@ -1,8 +1,8 @@
"""Config flow for AEMET OpenData.""" """Config flow for AEMET OpenData."""
from __future__ import annotations from __future__ import annotations
from aemet_opendata import AEMET
from aemet_opendata.exceptions import AuthError from aemet_opendata.exceptions import AuthError
from aemet_opendata.interface import AEMET, ConnectionOptions
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
@@ -40,10 +40,8 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(f"{latitude}-{longitude}") await self.async_set_unique_id(f"{latitude}-{longitude}")
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
aemet = AEMET( options = ConnectionOptions(user_input[CONF_API_KEY], False)
aiohttp_client.async_get_clientsession(self.hass), aemet = AEMET(aiohttp_client.async_get_clientsession(self.hass), options)
user_input[CONF_API_KEY],
)
try: try:
await aemet.get_conventional_observation_stations(False) await aemet.get_conventional_observation_stations(False)
except AuthError: except AuthError:

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aemet", "documentation": "https://www.home-assistant.io/integrations/aemet",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aemet_opendata"], "loggers": ["aemet_opendata"],
"requirements": ["AEMET-OpenData==0.3.0"] "requirements": ["AEMET-OpenData==0.4.0"]
} }

View File

@@ -11,8 +11,8 @@ from homeassistant.components.weather import (
ATTR_FORECAST_TIME, ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_BEARING,
DOMAIN as WEATHER_DOMAIN, DOMAIN as WEATHER_DOMAIN,
CoordinatorWeatherEntity,
Forecast, Forecast,
SingleCoordinatorWeatherEntity,
WeatherEntityFeature, WeatherEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfSpeed, UnitOfSpeed,
UnitOfTemperature, UnitOfTemperature,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -110,7 +110,7 @@ async def async_setup_entry(
async_add_entities(entities, False) async_add_entities(entities, False)
class AemetWeather(CoordinatorWeatherEntity[WeatherUpdateCoordinator]): class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
"""Implementation of an AEMET OpenData sensor.""" """Implementation of an AEMET OpenData sensor."""
_attr_attribution = ATTRIBUTION _attr_attribution = ATTRIBUTION
@@ -160,11 +160,13 @@ class AemetWeather(CoordinatorWeatherEntity[WeatherUpdateCoordinator]):
"""Return the forecast array.""" """Return the forecast array."""
return self._forecast(self._forecast_mode) return self._forecast(self._forecast_mode)
async def async_forecast_daily(self) -> list[Forecast]: @callback
def _async_forecast_daily(self) -> list[Forecast]:
"""Return the daily forecast in native units.""" """Return the daily forecast in native units."""
return self._forecast(FORECAST_MODE_DAILY) return self._forecast(FORECAST_MODE_DAILY)
async def async_forecast_hourly(self) -> list[Forecast]: @callback
def _async_forecast_hourly(self) -> list[Forecast]:
"""Return the hourly forecast in native units.""" """Return the hourly forecast in native units."""
return self._forecast(FORECAST_MODE_HOURLY) return self._forecast(FORECAST_MODE_HOURLY)

View File

@@ -11,6 +11,7 @@ from aemet_opendata.const import (
AEMET_ATTR_DAY, AEMET_ATTR_DAY,
AEMET_ATTR_DIRECTION, AEMET_ATTR_DIRECTION,
AEMET_ATTR_ELABORATED, AEMET_ATTR_ELABORATED,
AEMET_ATTR_FEEL_TEMPERATURE,
AEMET_ATTR_FORECAST, AEMET_ATTR_FORECAST,
AEMET_ATTR_HUMIDITY, AEMET_ATTR_HUMIDITY,
AEMET_ATTR_ID, AEMET_ATTR_ID,
@@ -32,7 +33,6 @@ from aemet_opendata.const import (
AEMET_ATTR_STATION_TEMPERATURE, AEMET_ATTR_STATION_TEMPERATURE,
AEMET_ATTR_STORM_PROBABILITY, AEMET_ATTR_STORM_PROBABILITY,
AEMET_ATTR_TEMPERATURE, AEMET_ATTR_TEMPERATURE,
AEMET_ATTR_TEMPERATURE_FEELING,
AEMET_ATTR_WIND, AEMET_ATTR_WIND,
AEMET_ATTR_WIND_GUST, AEMET_ATTR_WIND_GUST,
ATTR_DATA, ATTR_DATA,
@@ -563,7 +563,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
@staticmethod @staticmethod
def _get_temperature_feeling(day_data, hour): def _get_temperature_feeling(day_data, hour):
"""Get temperature from weather data.""" """Get temperature from weather data."""
val = get_forecast_hour_value(day_data[AEMET_ATTR_TEMPERATURE_FEELING], hour) val = get_forecast_hour_value(day_data[AEMET_ATTR_FEEL_TEMPERATURE], hour)
return format_int(val) return format_int(val)
def _get_town_id(self): def _get_town_id(self):

View File

@@ -166,7 +166,6 @@ class AirthingsSensor(
name += f" ({identifier})" name += f" ({identifier})"
self._attr_unique_id = f"{name}_{entity_description.key}" self._attr_unique_id = f"{name}_{entity_description.key}"
self._id = airthings_device.address
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections={ connections={
( (

View File

@@ -52,6 +52,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_pipeline_from_audio_stream( async def async_pipeline_from_audio_stream(
hass: HomeAssistant, hass: HomeAssistant,
*,
context: Context, context: Context,
event_callback: PipelineEventCallback, event_callback: PipelineEventCallback,
stt_metadata: stt.SpeechMetadata, stt_metadata: stt.SpeechMetadata,

View File

@@ -49,6 +49,7 @@ from .error import (
WakeWordDetectionError, WakeWordDetectionError,
WakeWordTimeoutError, WakeWordTimeoutError,
) )
from .ring_buffer import RingBuffer
from .vad import VoiceActivityTimeout, VoiceCommandSegmenter from .vad import VoiceActivityTimeout, VoiceCommandSegmenter
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -425,7 +426,6 @@ class PipelineRun:
async def prepare_wake_word_detection(self) -> None: async def prepare_wake_word_detection(self) -> None:
"""Prepare wake-word-detection.""" """Prepare wake-word-detection."""
# Need to add to pipeline store
engine = wake_word.async_default_engine(self.hass) engine = wake_word.async_default_engine(self.hass)
if engine is None: if engine is None:
raise WakeWordDetectionError( raise WakeWordDetectionError(
@@ -448,7 +448,7 @@ class PipelineRun:
async def wake_word_detection( async def wake_word_detection(
self, self,
stream: AsyncIterable[bytes], stream: AsyncIterable[bytes],
audio_buffer: list[bytes], audio_chunks_for_stt: list[bytes],
) -> wake_word.DetectionResult | None: ) -> wake_word.DetectionResult | None:
"""Run wake-word-detection portion of pipeline. Returns detection result.""" """Run wake-word-detection portion of pipeline. Returns detection result."""
metadata_dict = asdict( metadata_dict = asdict(
@@ -484,46 +484,29 @@ class PipelineRun:
# Use VAD to determine timeout # Use VAD to determine timeout
wake_word_vad = VoiceActivityTimeout(wake_word_settings.timeout) wake_word_vad = VoiceActivityTimeout(wake_word_settings.timeout)
# Audio chunk buffer. # Audio chunk buffer. This audio will be forwarded to speech-to-text
audio_bytes_to_buffer = int( # after wake-word-detection.
wake_word_settings.audio_seconds_to_buffer * 16000 * 2 num_audio_bytes_to_buffer = int(
wake_word_settings.audio_seconds_to_buffer * 16000 * 2 # 16-bit @ 16Khz
) )
audio_ring_buffer = b"" stt_audio_buffer: RingBuffer | None = None
if num_audio_bytes_to_buffer > 0:
async def timestamped_stream() -> AsyncIterable[tuple[bytes, int]]: stt_audio_buffer = RingBuffer(num_audio_bytes_to_buffer)
"""Yield audio with timestamps (milliseconds since start of stream)."""
nonlocal audio_ring_buffer
timestamp_ms = 0
async for chunk in stream:
yield chunk, timestamp_ms
timestamp_ms += (len(chunk) // 2) // 16 # milliseconds @ 16Khz
# Keeping audio right before wake word detection allows the
# voice command to be spoken immediately after the wake word.
if audio_bytes_to_buffer > 0:
audio_ring_buffer += chunk
if len(audio_ring_buffer) > audio_bytes_to_buffer:
# A proper ring buffer would be far more efficient
audio_ring_buffer = audio_ring_buffer[
len(audio_ring_buffer) - audio_bytes_to_buffer :
]
if (wake_word_vad is not None) and (not wake_word_vad.process(chunk)):
raise WakeWordTimeoutError(
code="wake-word-timeout", message="Wake word was not detected"
)
try: try:
# Detect wake word(s) # Detect wake word(s)
result = await self.wake_word_provider.async_process_audio_stream( result = await self.wake_word_provider.async_process_audio_stream(
timestamped_stream() _wake_word_audio_stream(
audio_stream=stream,
stt_audio_buffer=stt_audio_buffer,
wake_word_vad=wake_word_vad,
)
) )
if audio_ring_buffer: if stt_audio_buffer is not None:
# All audio kept from right before the wake word was detected as # All audio kept from right before the wake word was detected as
# a single chunk. # a single chunk.
audio_buffer.append(audio_ring_buffer) audio_chunks_for_stt.append(stt_audio_buffer.getvalue())
except WakeWordTimeoutError: except WakeWordTimeoutError:
_LOGGER.debug("Timeout during wake word detection") _LOGGER.debug("Timeout during wake word detection")
raise raise
@@ -540,9 +523,14 @@ class PipelineRun:
wake_word_output: dict[str, Any] = {} wake_word_output: dict[str, Any] = {}
else: else:
if result.queued_audio: if result.queued_audio:
# Add audio that was pending at detection # Add audio that was pending at detection.
#
# Because detection occurs *after* the wake word was actually
# spoken, we need to make sure pending audio is forwarded to
# speech-to-text so the user does not have to pause before
# speaking the voice command.
for chunk_ts in result.queued_audio: for chunk_ts in result.queued_audio:
audio_buffer.append(chunk_ts[0]) audio_chunks_for_stt.append(chunk_ts[0])
wake_word_output = asdict(result) wake_word_output = asdict(result)
@@ -608,41 +596,12 @@ class PipelineRun:
) )
try: try:
segmenter = VoiceCommandSegmenter()
async def segment_stream(
stream: AsyncIterable[bytes],
) -> AsyncGenerator[bytes, None]:
"""Stop stream when voice command is finished."""
sent_vad_start = False
timestamp_ms = 0
async for chunk in stream:
if not segmenter.process(chunk):
# Silence detected at the end of voice command
self.process_event(
PipelineEvent(
PipelineEventType.STT_VAD_END,
{"timestamp": timestamp_ms},
)
)
break
if segmenter.in_command and (not sent_vad_start):
# Speech detected at start of voice command
self.process_event(
PipelineEvent(
PipelineEventType.STT_VAD_START,
{"timestamp": timestamp_ms},
)
)
sent_vad_start = True
yield chunk
timestamp_ms += (len(chunk) // 2) // 16 # milliseconds @ 16Khz
# Transcribe audio stream # Transcribe audio stream
result = await self.stt_provider.async_process_audio_stream( result = await self.stt_provider.async_process_audio_stream(
metadata, segment_stream(stream) metadata,
self._speech_to_text_stream(
audio_stream=stream, stt_vad=VoiceCommandSegmenter()
),
) )
except Exception as src_error: except Exception as src_error:
_LOGGER.exception("Unexpected error during speech-to-text") _LOGGER.exception("Unexpected error during speech-to-text")
@@ -677,6 +636,42 @@ class PipelineRun:
return result.text return result.text
async def _speech_to_text_stream(
self,
audio_stream: AsyncIterable[bytes],
stt_vad: VoiceCommandSegmenter | None,
sample_rate: int = 16000,
sample_width: int = 2,
) -> AsyncGenerator[bytes, None]:
"""Yield audio chunks until VAD detects silence or speech-to-text completes."""
ms_per_sample = sample_rate // 1000
sent_vad_start = False
timestamp_ms = 0
async for chunk in audio_stream:
if stt_vad is not None:
if not stt_vad.process(chunk):
# Silence detected at the end of voice command
self.process_event(
PipelineEvent(
PipelineEventType.STT_VAD_END,
{"timestamp": timestamp_ms},
)
)
break
if stt_vad.in_command and (not sent_vad_start):
# Speech detected at start of voice command
self.process_event(
PipelineEvent(
PipelineEventType.STT_VAD_START,
{"timestamp": timestamp_ms},
)
)
sent_vad_start = True
yield chunk
timestamp_ms += (len(chunk) // sample_width) // ms_per_sample
async def prepare_recognize_intent(self) -> None: async def prepare_recognize_intent(self) -> None:
"""Prepare recognizing an intent.""" """Prepare recognizing an intent."""
agent_info = conversation.async_get_agent_info( agent_info = conversation.async_get_agent_info(
@@ -861,13 +856,14 @@ class PipelineInput:
"""Run pipeline.""" """Run pipeline."""
self.run.start() self.run.start()
current_stage: PipelineStage | None = self.run.start_stage current_stage: PipelineStage | None = self.run.start_stage
audio_buffer: list[bytes] = [] stt_audio_buffer: list[bytes] = []
try: try:
if current_stage == PipelineStage.WAKE_WORD: if current_stage == PipelineStage.WAKE_WORD:
# wake-word-detection
assert self.stt_stream is not None assert self.stt_stream is not None
detect_result = await self.run.wake_word_detection( detect_result = await self.run.wake_word_detection(
self.stt_stream, audio_buffer self.stt_stream, stt_audio_buffer
) )
if detect_result is None: if detect_result is None:
# No wake word. Abort the rest of the pipeline. # No wake word. Abort the rest of the pipeline.
@@ -882,19 +878,22 @@ class PipelineInput:
assert self.stt_metadata is not None assert self.stt_metadata is not None
assert self.stt_stream is not None assert self.stt_stream is not None
if audio_buffer: stt_stream = self.stt_stream
async def buffered_stream() -> AsyncGenerator[bytes, None]: if stt_audio_buffer:
for chunk in audio_buffer: # Send audio in the buffer first to speech-to-text, then move on to stt_stream.
# This is basically an async itertools.chain.
async def buffer_then_audio_stream() -> AsyncGenerator[bytes, None]:
# Buffered audio
for chunk in stt_audio_buffer:
yield chunk yield chunk
# Streamed audio
assert self.stt_stream is not None assert self.stt_stream is not None
async for chunk in self.stt_stream: async for chunk in self.stt_stream:
yield chunk yield chunk
stt_stream = cast(AsyncIterable[bytes], buffered_stream()) stt_stream = buffer_then_audio_stream()
else:
stt_stream = self.stt_stream
intent_input = await self.run.speech_to_text( intent_input = await self.run.speech_to_text(
self.stt_metadata, self.stt_metadata,
@@ -906,6 +905,7 @@ class PipelineInput:
tts_input = self.tts_input tts_input = self.tts_input
if current_stage == PipelineStage.INTENT: if current_stage == PipelineStage.INTENT:
# intent-recognition
assert intent_input is not None assert intent_input is not None
tts_input = await self.run.recognize_intent( tts_input = await self.run.recognize_intent(
intent_input, intent_input,
@@ -915,6 +915,7 @@ class PipelineInput:
current_stage = PipelineStage.TTS current_stage = PipelineStage.TTS
if self.run.end_stage != PipelineStage.INTENT: if self.run.end_stage != PipelineStage.INTENT:
# text-to-speech
if current_stage == PipelineStage.TTS: if current_stage == PipelineStage.TTS:
assert tts_input is not None assert tts_input is not None
await self.run.text_to_speech(tts_input) await self.run.text_to_speech(tts_input)
@@ -999,6 +1000,36 @@ class PipelineInput:
await asyncio.gather(*prepare_tasks) await asyncio.gather(*prepare_tasks)
async def _wake_word_audio_stream(
audio_stream: AsyncIterable[bytes],
stt_audio_buffer: RingBuffer | None,
wake_word_vad: VoiceActivityTimeout | None,
sample_rate: int = 16000,
sample_width: int = 2,
) -> AsyncIterable[tuple[bytes, int]]:
"""Yield audio chunks with timestamps (milliseconds since start of stream).
Adds audio to a ring buffer that will be forwarded to speech-to-text after
detection. Times out if VAD detects enough silence.
"""
ms_per_sample = sample_rate // 1000
timestamp_ms = 0
async for chunk in audio_stream:
yield chunk, timestamp_ms
timestamp_ms += (len(chunk) // sample_width) // ms_per_sample
# Wake-word-detection occurs *after* the wake word was actually
# spoken. Keeping audio right before detection allows the voice
# command to be spoken immediately after the wake word.
if stt_audio_buffer is not None:
stt_audio_buffer.put(chunk)
if (wake_word_vad is not None) and (not wake_word_vad.process(chunk)):
raise WakeWordTimeoutError(
code="wake-word-timeout", message="Wake word was not detected"
)
class PipelinePreferred(CollectionError): class PipelinePreferred(CollectionError):
"""Raised when attempting to delete the preferred pipelen.""" """Raised when attempting to delete the preferred pipelen."""

View File

@@ -0,0 +1,57 @@
"""Implementation of a ring buffer using bytearray."""
class RingBuffer:
"""Basic ring buffer using a bytearray.
Not threadsafe.
"""
def __init__(self, maxlen: int) -> None:
"""Initialize empty buffer."""
self._buffer = bytearray(maxlen)
self._pos = 0
self._length = 0
self._maxlen = maxlen
@property
def maxlen(self) -> int:
"""Return the maximum size of the buffer."""
return self._maxlen
@property
def pos(self) -> int:
"""Return the current put position."""
return self._pos
def __len__(self) -> int:
"""Return the length of data stored in the buffer."""
return self._length
def put(self, data: bytes) -> None:
"""Put a chunk of data into the buffer, possibly wrapping around."""
data_len = len(data)
new_pos = self._pos + data_len
if new_pos >= self._maxlen:
# Split into two chunks
num_bytes_1 = self._maxlen - self._pos
num_bytes_2 = new_pos - self._maxlen
self._buffer[self._pos : self._maxlen] = data[:num_bytes_1]
self._buffer[:num_bytes_2] = data[num_bytes_1:]
new_pos = new_pos - self._maxlen
else:
# Entire chunk fits at current position
self._buffer[self._pos : self._pos + data_len] = data
self._pos = new_pos
self._length = min(self._maxlen, self._length + data_len)
def getvalue(self) -> bytes:
"""Get bytes written to the buffer."""
if (self._pos + self._length) <= self._maxlen:
# Single chunk
return bytes(self._buffer[: self._length])
# Two chunks
return bytes(self._buffer[self._pos :] + self._buffer[: self._pos])

View File

@@ -1,12 +1,15 @@
"""Voice activity detection.""" """Voice activity detection."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import StrEnum from enum import StrEnum
from typing import Final
import webrtcvad import webrtcvad
_SAMPLE_RATE = 16000 _SAMPLE_RATE: Final = 16000 # Hz
_SAMPLE_WIDTH: Final = 2 # bytes
class VadSensitivity(StrEnum): class VadSensitivity(StrEnum):
@@ -29,6 +32,45 @@ class VadSensitivity(StrEnum):
return 1.0 return 1.0
class AudioBuffer:
"""Fixed-sized audio buffer with variable internal length."""
def __init__(self, maxlen: int) -> None:
"""Initialize buffer."""
self._buffer = bytearray(maxlen)
self._length = 0
@property
def length(self) -> int:
"""Get number of bytes currently in the buffer."""
return self._length
def clear(self) -> None:
"""Clear the buffer."""
self._length = 0
def append(self, data: bytes) -> None:
"""Append bytes to the buffer, increasing the internal length."""
data_len = len(data)
if (self._length + data_len) > len(self._buffer):
raise ValueError("Length cannot be greater than buffer size")
self._buffer[self._length : self._length + data_len] = data
self._length += data_len
def bytes(self) -> bytes:
"""Convert written portion of buffer to bytes."""
return bytes(self._buffer[: self._length])
def __len__(self) -> int:
"""Get the number of bytes currently in the buffer."""
return self._length
def __bool__(self) -> bool:
"""Return True if there are bytes in the buffer."""
return self._length > 0
@dataclass @dataclass
class VoiceCommandSegmenter: class VoiceCommandSegmenter:
"""Segments an audio stream into voice commands using webrtcvad.""" """Segments an audio stream into voice commands using webrtcvad."""
@@ -36,7 +78,7 @@ class VoiceCommandSegmenter:
vad_mode: int = 3 vad_mode: int = 3
"""Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" """Aggressiveness in filtering out non-speech. 3 is the most aggressive."""
vad_frames: int = 480 # 30 ms vad_samples_per_chunk: int = 480 # 30 ms
"""Must be 10, 20, or 30 ms at 16Khz.""" """Must be 10, 20, or 30 ms at 16Khz."""
speech_seconds: float = 0.3 speech_seconds: float = 0.3
@@ -67,20 +109,23 @@ class VoiceCommandSegmenter:
"""Seconds left before resetting start/stop time counters.""" """Seconds left before resetting start/stop time counters."""
_vad: webrtcvad.Vad = None _vad: webrtcvad.Vad = None
_audio_buffer: bytes = field(default_factory=bytes) _leftover_chunk_buffer: AudioBuffer = field(init=False)
_bytes_per_chunk: int = 480 * 2 # 16-bit samples _bytes_per_chunk: int = field(init=False)
_seconds_per_chunk: float = 0.03 # 30 ms _seconds_per_chunk: float = field(init=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
"""Initialize VAD.""" """Initialize VAD."""
self._vad = webrtcvad.Vad(self.vad_mode) self._vad = webrtcvad.Vad(self.vad_mode)
self._bytes_per_chunk = self.vad_frames * 2 self._bytes_per_chunk = self.vad_samples_per_chunk * _SAMPLE_WIDTH
self._seconds_per_chunk = self.vad_frames / _SAMPLE_RATE self._seconds_per_chunk = self.vad_samples_per_chunk / _SAMPLE_RATE
self._leftover_chunk_buffer = AudioBuffer(
self.vad_samples_per_chunk * _SAMPLE_WIDTH
)
self.reset() self.reset()
def reset(self) -> None: def reset(self) -> None:
"""Reset all counters and state.""" """Reset all counters and state."""
self._audio_buffer = b"" self._leftover_chunk_buffer.clear()
self._speech_seconds_left = self.speech_seconds self._speech_seconds_left = self.speech_seconds
self._silence_seconds_left = self.silence_seconds self._silence_seconds_left = self.silence_seconds
self._timeout_seconds_left = self.timeout_seconds self._timeout_seconds_left = self.timeout_seconds
@@ -92,27 +137,20 @@ class VoiceCommandSegmenter:
Returns False when command is done. Returns False when command is done.
""" """
self._audio_buffer += samples for chunk in chunk_samples(
samples, self._bytes_per_chunk, self._leftover_chunk_buffer
# Process in 10, 20, or 30 ms chunks. ):
num_chunks = len(self._audio_buffer) // self._bytes_per_chunk
for chunk_idx in range(num_chunks):
chunk_offset = chunk_idx * self._bytes_per_chunk
chunk = self._audio_buffer[
chunk_offset : chunk_offset + self._bytes_per_chunk
]
if not self._process_chunk(chunk): if not self._process_chunk(chunk):
self.reset() self.reset()
return False return False
if num_chunks > 0:
# Remove from buffer
self._audio_buffer = self._audio_buffer[
num_chunks * self._bytes_per_chunk :
]
return True return True
@property
def audio_buffer(self) -> bytes:
"""Get partial chunk in the audio buffer."""
return self._leftover_chunk_buffer.bytes()
def _process_chunk(self, chunk: bytes) -> bool: def _process_chunk(self, chunk: bytes) -> bool:
"""Process a single chunk of 16-bit 16Khz mono audio. """Process a single chunk of 16-bit 16Khz mono audio.
@@ -163,7 +201,7 @@ class VoiceActivityTimeout:
vad_mode: int = 3 vad_mode: int = 3
"""Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" """Aggressiveness in filtering out non-speech. 3 is the most aggressive."""
vad_frames: int = 480 # 30 ms vad_samples_per_chunk: int = 480 # 30 ms
"""Must be 10, 20, or 30 ms at 16Khz.""" """Must be 10, 20, or 30 ms at 16Khz."""
_silence_seconds_left: float = 0.0 _silence_seconds_left: float = 0.0
@@ -173,20 +211,23 @@ class VoiceActivityTimeout:
"""Seconds left before resetting start/stop time counters.""" """Seconds left before resetting start/stop time counters."""
_vad: webrtcvad.Vad = None _vad: webrtcvad.Vad = None
_audio_buffer: bytes = field(default_factory=bytes) _leftover_chunk_buffer: AudioBuffer = field(init=False)
_bytes_per_chunk: int = 480 * 2 # 16-bit samples _bytes_per_chunk: int = field(init=False)
_seconds_per_chunk: float = 0.03 # 30 ms _seconds_per_chunk: float = field(init=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
"""Initialize VAD.""" """Initialize VAD."""
self._vad = webrtcvad.Vad(self.vad_mode) self._vad = webrtcvad.Vad(self.vad_mode)
self._bytes_per_chunk = self.vad_frames * 2 self._bytes_per_chunk = self.vad_samples_per_chunk * _SAMPLE_WIDTH
self._seconds_per_chunk = self.vad_frames / _SAMPLE_RATE self._seconds_per_chunk = self.vad_samples_per_chunk / _SAMPLE_RATE
self._leftover_chunk_buffer = AudioBuffer(
self.vad_samples_per_chunk * _SAMPLE_WIDTH
)
self.reset() self.reset()
def reset(self) -> None: def reset(self) -> None:
"""Reset all counters and state.""" """Reset all counters and state."""
self._audio_buffer = b"" self._leftover_chunk_buffer.clear()
self._silence_seconds_left = self.silence_seconds self._silence_seconds_left = self.silence_seconds
self._reset_seconds_left = self.reset_seconds self._reset_seconds_left = self.reset_seconds
@@ -195,24 +236,12 @@ class VoiceActivityTimeout:
Returns False when timeout is reached. Returns False when timeout is reached.
""" """
self._audio_buffer += samples for chunk in chunk_samples(
samples, self._bytes_per_chunk, self._leftover_chunk_buffer
# Process in 10, 20, or 30 ms chunks. ):
num_chunks = len(self._audio_buffer) // self._bytes_per_chunk
for chunk_idx in range(num_chunks):
chunk_offset = chunk_idx * self._bytes_per_chunk
chunk = self._audio_buffer[
chunk_offset : chunk_offset + self._bytes_per_chunk
]
if not self._process_chunk(chunk): if not self._process_chunk(chunk):
return False return False
if num_chunks > 0:
# Remove from buffer
self._audio_buffer = self._audio_buffer[
num_chunks * self._bytes_per_chunk :
]
return True return True
def _process_chunk(self, chunk: bytes) -> bool: def _process_chunk(self, chunk: bytes) -> bool:
@@ -239,3 +268,37 @@ class VoiceActivityTimeout:
) )
return True return True
def chunk_samples(
samples: bytes,
bytes_per_chunk: int,
leftover_chunk_buffer: AudioBuffer,
) -> Iterable[bytes]:
"""Yield fixed-sized chunks from samples, keeping leftover bytes from previous call(s)."""
if (len(leftover_chunk_buffer) + len(samples)) < bytes_per_chunk:
# Extend leftover chunk, but not enough samples to complete it
leftover_chunk_buffer.append(samples)
return
next_chunk_idx = 0
if leftover_chunk_buffer:
# Add to leftover chunk from previous call(s).
bytes_to_copy = bytes_per_chunk - len(leftover_chunk_buffer)
leftover_chunk_buffer.append(samples[:bytes_to_copy])
next_chunk_idx = bytes_to_copy
# Process full chunk in buffer
yield leftover_chunk_buffer.bytes()
leftover_chunk_buffer.clear()
while next_chunk_idx < len(samples) - bytes_per_chunk + 1:
# Process full chunk
yield samples[next_chunk_idx : next_chunk_idx + bytes_per_chunk]
next_chunk_idx += bytes_per_chunk
# Capture leftover chunks
if rest_samples := samples[next_chunk_idx:]:
leftover_chunk_buffer.append(rest_samples)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
from atenpdu import AtenPE, AtenPEError # pylint: disable=import-error from atenpdu import AtenPE, AtenPEError
import voluptuous as vol import voluptuous as vol
from homeassistant.components.switch import ( from homeassistant.components.switch import (

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Any from typing import Any
import avea # pylint: disable=import-error import avea
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,

View File

@@ -4,13 +4,8 @@ from __future__ import annotations
import json import json
import logging import logging
# pylint: disable-next=import-error, no-name-in-module
from azure.servicebus import ServiceBusMessage from azure.servicebus import ServiceBusMessage
# pylint: disable-next=import-error, no-name-in-module
from azure.servicebus.aio import ServiceBusClient, ServiceBusSender from azure.servicebus.aio import ServiceBusClient, ServiceBusSender
# pylint: disable-next=import-error, no-name-in-module
from azure.servicebus.exceptions import ( from azure.servicebus.exceptions import (
MessagingEntityNotFoundError, MessagingEntityNotFoundError,
ServiceBusConnectionError, ServiceBusConnectionError,

View File

@@ -6,6 +6,7 @@ from asyncio import timeout
from aiobafi6 import Device, Service from aiobafi6 import Device, Service
from aiobafi6.discovery import PORT from aiobafi6.discovery import PORT
from aiobafi6.exceptions import DeviceUUIDMismatchError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, Platform from homeassistant.const import CONF_IP_ADDRESS, Platform
@@ -37,6 +38,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try: try:
async with timeout(RUN_TIMEOUT): async with timeout(RUN_TIMEOUT):
await device.async_wait_available() await device.async_wait_available()
except DeviceUUIDMismatchError as ex:
raise ConfigEntryNotReady(
f"Unexpected device found at {ip_address}; expected {entry.unique_id}, found {device.dns_sd_uuid}"
) from ex
except asyncio.TimeoutError as ex: except asyncio.TimeoutError as ex:
run_future.cancel() run_future.cancel()
raise ConfigEntryNotReady(f"Timed out connecting to {ip_address}") from ex raise ConfigEntryNotReady(f"Timed out connecting to {ip_address}") from ex

View File

@@ -5,7 +5,7 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/baf", "documentation": "https://www.home-assistant.io/integrations/baf",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["aiobafi6==0.8.2"], "requirements": ["aiobafi6==0.9.0"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_api._tcp.local.", "type": "_api._tcp.local.",

View File

@@ -1,7 +1,7 @@
"""Platform for beewi_smartclim integration.""" """Platform for beewi_smartclim integration."""
from __future__ import annotations from __future__ import annotations
from beewi_smartclim import BeewiSmartClimPoller # pylint: disable=import-error from beewi_smartclim import BeewiSmartClimPoller
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (

View File

@@ -49,7 +49,7 @@ def setup_platform(
class BloomSkySensor(BinarySensorEntity): class BloomSkySensor(BinarySensorEntity):
"""Representation of a single binary sensor in a BloomSky device.""" """Representation of a single binary sensor in a BloomSky device."""
def __init__(self, bs, device, sensor_name): # pylint: disable=invalid-name def __init__(self, bs, device, sensor_name):
"""Initialize a BloomSky binary sensor.""" """Initialize a BloomSky binary sensor."""
self._bloomsky = bs self._bloomsky = bs
self._device_id = device["DeviceID"] self._device_id = device["DeviceID"]

View File

@@ -93,7 +93,7 @@ def setup_platform(
class BloomSkySensor(SensorEntity): class BloomSkySensor(SensorEntity):
"""Representation of a single sensor in a BloomSky device.""" """Representation of a single sensor in a BloomSky device."""
def __init__(self, bs, device, sensor_name): # pylint: disable=invalid-name def __init__(self, bs, device, sensor_name):
"""Initialize a BloomSky sensor.""" """Initialize a BloomSky sensor."""
self._bloomsky = bs self._bloomsky = bs
self._device_id = device["DeviceID"] self._device_id = device["DeviceID"]

View File

@@ -18,7 +18,7 @@
"bleak-retry-connector==3.1.1", "bleak-retry-connector==3.1.1",
"bluetooth-adapters==0.16.0", "bluetooth-adapters==0.16.0",
"bluetooth-auto-recovery==1.2.1", "bluetooth-auto-recovery==1.2.1",
"bluetooth-data-tools==1.8.0", "bluetooth-data-tools==1.9.0",
"dbus-fast==1.93.0" "dbus-fast==1.94.0"
] ]
} }

View File

@@ -39,6 +39,7 @@ class BasePassiveBluetoothCoordinator(ABC):
self.mode = mode self.mode = mode
self._last_unavailable_time = 0.0 self._last_unavailable_time = 0.0
self._last_name = address self._last_name = address
self._available = async_address_present(hass, address, connectable)
@callback @callback
def async_start(self) -> CALLBACK_TYPE: def async_start(self) -> CALLBACK_TYPE:
@@ -85,7 +86,17 @@ class BasePassiveBluetoothCoordinator(ABC):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if the device is available.""" """Return if the device is available."""
return async_address_present(self.hass, self.address, self.connectable) return self._available
@callback
def _async_handle_bluetooth_event_internal(
self,
service_info: BluetoothServiceInfoBleak,
change: BluetoothChange,
) -> None:
"""Handle a bluetooth event."""
self._available = True
self._async_handle_bluetooth_event(service_info, change)
@callback @callback
def _async_start(self) -> None: def _async_start(self) -> None:
@@ -93,7 +104,7 @@ class BasePassiveBluetoothCoordinator(ABC):
self._on_stop.append( self._on_stop.append(
async_register_callback( async_register_callback(
self.hass, self.hass,
self._async_handle_bluetooth_event, self._async_handle_bluetooth_event_internal,
BluetoothCallbackMatcher( BluetoothCallbackMatcher(
address=self.address, connectable=self.connectable address=self.address, connectable=self.connectable
), ),
@@ -123,3 +134,4 @@ class BasePassiveBluetoothCoordinator(ABC):
"""Handle the device going unavailable.""" """Handle the device going unavailable."""
self._last_unavailable_time = service_info.time self._last_unavailable_time = service_info.time
self._last_name = service_info.name self._last_name = service_info.name
self._available = False

View File

@@ -199,7 +199,7 @@ class HaBleakClientWrapper(BleakClient):
when an integration does this. when an integration does this.
""" """
def __init__( # pylint: disable=super-init-not-called, keyword-arg-before-vararg def __init__( # pylint: disable=super-init-not-called
self, self,
address_or_ble_device: str | BLEDevice, address_or_ble_device: str | BLEDevice,
disconnected_callback: Callable[[BleakClient], None] | None = None, disconnected_callback: Callable[[BleakClient], None] | None = None,

View File

@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
import logging import logging
from typing import Final from typing import Final
import bluetooth # pylint: disable=import-error import bluetooth
from bt_proximity import BluetoothRSSI from bt_proximity import BluetoothRSSI
import voluptuous as vol import voluptuous as vol

View File

@@ -16,8 +16,7 @@ SERVICE_BROWSE_URL = "browse_url"
SERVICE_BROWSE_URL_SCHEMA = vol.Schema( SERVICE_BROWSE_URL_SCHEMA = vol.Schema(
{ {
# pylint: disable-next=no-value-for-parameter vol.Required(ATTR_URL, default=ATTR_URL_DEFAULT): vol.Url(),
vol.Required(ATTR_URL, default=ATTR_URL_DEFAULT): vol.Url()
} }
) )

View File

@@ -7,5 +7,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["bsblan"], "loggers": ["bsblan"],
"requirements": ["python-bsblan==0.5.11"] "requirements": ["python-bsblan==0.5.14"]
} }

View File

@@ -43,7 +43,6 @@ OFFSET = "!!"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{ {
# pylint: disable=no-value-for-parameter
vol.Required(CONF_URL): vol.Url(), vol.Required(CONF_URL): vol.Url(),
vol.Optional(CONF_CALENDARS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_CALENDARS, default=[]): vol.All(cv.ensure_list, [cv.string]),
vol.Inclusive(CONF_USERNAME, "authentication"): cv.string, vol.Inclusive(CONF_USERNAME, "authentication"): cv.string,

View File

@@ -20,10 +20,12 @@ from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import ( from homeassistant.core import (
CALLBACK_TYPE,
HomeAssistant, HomeAssistant,
ServiceCall, ServiceCall,
ServiceResponse, ServiceResponse,
SupportsResponse, SupportsResponse,
callback,
) )
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
@@ -34,6 +36,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401
) )
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_point_in_time
from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.template import DATE_STR_FORMAT
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@@ -478,6 +481,8 @@ def is_offset_reached(
class CalendarEntity(Entity): class CalendarEntity(Entity):
"""Base class for calendar event entities.""" """Base class for calendar event entities."""
_alarm_unsubs: list[CALLBACK_TYPE] = []
@property @property
def event(self) -> CalendarEvent | None: def event(self) -> CalendarEvent | None:
"""Return the next upcoming event.""" """Return the next upcoming event."""
@@ -513,6 +518,48 @@ class CalendarEntity(Entity):
return STATE_OFF return STATE_OFF
@callback
def async_write_ha_state(self) -> None:
"""Write the state to the state machine.
This sets up listeners to handle state transitions for start or end of
the current or upcoming event.
"""
super().async_write_ha_state()
for unsub in self._alarm_unsubs:
unsub()
now = dt_util.now()
event = self.event
if event is None or now >= event.end_datetime_local:
return
@callback
def update(_: datetime.datetime) -> None:
"""Run when the active or upcoming event starts or ends."""
self._async_write_ha_state()
if now < event.start_datetime_local:
self._alarm_unsubs.append(
async_track_point_in_time(
self.hass,
update,
event.start_datetime_local,
)
)
self._alarm_unsubs.append(
async_track_point_in_time(self.hass, update, event.end_datetime_local)
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass.
To be extended by integrations.
"""
for unsub in self._alarm_unsubs:
unsub()
async def async_get_events( async def async_get_events(
self, self,
hass: HomeAssistant, hass: HomeAssistant,

View File

@@ -168,9 +168,14 @@ async def _async_get_image(
""" """
with suppress(asyncio.CancelledError, asyncio.TimeoutError): with suppress(asyncio.CancelledError, asyncio.TimeoutError):
async with asyncio.timeout(timeout): async with asyncio.timeout(timeout):
if image_bytes := await camera.async_camera_image( image_bytes = (
width=width, height=height await _async_get_stream_image(
): camera, width=width, height=height, wait_for_next_keyframe=False
)
if camera.use_stream_for_stills
else await camera.async_camera_image(width=width, height=height)
)
if image_bytes:
content_type = camera.content_type content_type = camera.content_type
image = Image(content_type, image_bytes) image = Image(content_type, image_bytes)
if ( if (
@@ -205,6 +210,21 @@ async def async_get_image(
return await _async_get_image(camera, timeout, width, height) return await _async_get_image(camera, timeout, width, height)
async def _async_get_stream_image(
camera: Camera,
width: int | None = None,
height: int | None = None,
wait_for_next_keyframe: bool = False,
) -> bytes | None:
if not camera.stream and camera.supported_features & SUPPORT_STREAM:
camera.stream = await camera.async_create_stream()
if camera.stream:
return await camera.stream.async_get_image(
width=width, height=height, wait_for_next_keyframe=wait_for_next_keyframe
)
return None
@bind_hass @bind_hass
async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None: async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None:
"""Fetch the stream source for a camera entity.""" """Fetch the stream source for a camera entity."""
@@ -360,6 +380,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await component.async_setup(config) await component.async_setup(config)
async def preload_stream(_event: Event) -> None: async def preload_stream(_event: Event) -> None:
"""Load stream prefs and start stream if preload_stream is True."""
for camera in list(component.entities): for camera in list(component.entities):
stream_prefs = await prefs.get_dynamic_stream_settings(camera.entity_id) stream_prefs = await prefs.get_dynamic_stream_settings(camera.entity_id)
if not stream_prefs.preload_stream: if not stream_prefs.preload_stream:
@@ -459,6 +480,11 @@ class Camera(Entity):
return self._attr_entity_picture return self._attr_entity_picture
return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1]) return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1])
@property
def use_stream_for_stills(self) -> bool:
"""Whether or not to use stream to generate stills."""
return False
@property @property
def supported_features(self) -> CameraEntityFeature: def supported_features(self) -> CameraEntityFeature:
"""Flag supported features.""" """Flag supported features."""
@@ -926,7 +952,12 @@ async def async_handle_snapshot_service(
f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
) )
image = await camera.async_camera_image() async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT):
image = (
await _async_get_stream_image(camera, wait_for_next_keyframe=True)
if camera.use_stream_for_stills
else await camera.async_camera_image()
)
if image is None: if image is None:
return return

View File

@@ -8,5 +8,5 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["hass_nabucasa"], "loggers": ["hass_nabucasa"],
"requirements": ["hass-nabucasa==0.69.0"] "requirements": ["hass-nabucasa==0.70.0"]
} }

View File

@@ -81,7 +81,7 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
except UnidentifiedImageError as ex: except UnidentifiedImageError as ex:
_LOGGER.error( _LOGGER.error(
"Bad image from %s '%s' provided, are you sure it's an image? %s", "Bad image from %s '%s' provided, are you sure it's an image? %s",
image_type, # pylint: disable=used-before-assignment image_type,
image_reference, image_reference,
ex, ex,
) )

View File

@@ -29,7 +29,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template from homeassistant.helpers.template import Template
from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util

View File

@@ -30,7 +30,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template from homeassistant.helpers.template import Template
from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util, slugify from homeassistant.util import dt as dt_util, slugify

View File

@@ -35,7 +35,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template from homeassistant.helpers.template import Template
from homeassistant.helpers.template_entity import ( from homeassistant.helpers.trigger_template_entity import (
CONF_AVAILABILITY, CONF_AVAILABILITY,
CONF_PICTURE, CONF_PICTURE,
ManualTriggerSensorEntity, ManualTriggerSensorEntity,

View File

@@ -32,7 +32,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template from homeassistant.helpers.template import Template
from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util, slugify from homeassistant.util import dt as dt_util, slugify

View File

@@ -143,7 +143,6 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView):
) )
async def post(self, request): async def post(self, request):
"""Handle a POST request.""" """Handle a POST request."""
# pylint: disable=no-value-for-parameter
try: try:
return await super().post(request) return await super().post(request)
except DependencyError as exc: except DependencyError as exc:
@@ -175,7 +174,6 @@ class ConfigManagerFlowResourceView(FlowManagerResourceView):
) )
async def post(self, request, flow_id): async def post(self, request, flow_id):
"""Handle a POST request.""" """Handle a POST request."""
# pylint: disable=no-value-for-parameter
return await super().post(request, flow_id) return await super().post(request, flow_id)
def _prepare_result_json(self, result): def _prepare_result_json(self, result):
@@ -212,7 +210,6 @@ class OptionManagerFlowIndexView(FlowManagerIndexView):
handler in request is entry_id. handler in request is entry_id.
""" """
# pylint: disable=no-value-for-parameter
return await super().post(request) return await super().post(request)
@@ -234,7 +231,6 @@ class OptionManagerFlowResourceView(FlowManagerResourceView):
) )
async def post(self, request, flow_id): async def post(self, request, flow_id):
"""Handle a POST request.""" """Handle a POST request."""
# pylint: disable=no-value-for-parameter
return await super().post(request, flow_id) return await super().post(request, flow_id)

View File

@@ -54,9 +54,7 @@ _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
REGEX_TYPE = type(re.compile("")) REGEX_TYPE = type(re.compile(""))
TRIGGER_CALLBACK_TYPE = Callable[ # pylint: disable=invalid-name TRIGGER_CALLBACK_TYPE = Callable[[str, RecognizeResult], Awaitable[str | None]]
[str, RecognizeResult], Awaitable[str | None]
]
def json_load(fp: IO[str]) -> JsonObjectType: def json_load(fp: IO[str]) -> JsonObjectType:

View File

@@ -8,8 +8,8 @@ import logging
import time import time
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar
from bluepy.btle import BTLEException # pylint: disable=import-error from bluepy.btle import BTLEException
import decora # pylint: disable=import-error import decora
import voluptuous as vol import voluptuous as vol
from homeassistant import util from homeassistant import util

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
# pylint: disable=import-error
from decora_wifi import DecoraWiFiSession from decora_wifi import DecoraWiFiSession
from decora_wifi.models.person import Person from decora_wifi.models.person import Person
from decora_wifi.models.residence import Residence from decora_wifi.models.residence import Residence

View File

@@ -26,6 +26,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.BUTTON, Platform.BUTTON,
Platform.CAMERA, Platform.CAMERA,
Platform.CALENDAR,
Platform.CLIMATE, Platform.CLIMATE,
Platform.COVER, Platform.COVER,
Platform.DATE, Platform.DATE,
@@ -54,7 +55,6 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.MAILBOX, Platform.MAILBOX,
Platform.NOTIFY, Platform.NOTIFY,
Platform.IMAGE_PROCESSING, Platform.IMAGE_PROCESSING,
Platform.CALENDAR,
Platform.DEVICE_TRACKER, Platform.DEVICE_TRACKER,
Platform.WEATHER, Platform.WEATHER,
] ]

View File

@@ -1,23 +1,22 @@
"""Demo platform that has two fake binary sensors.""" """Demo platform that has two fake calendars."""
from __future__ import annotations from __future__ import annotations
import datetime import datetime
from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
def setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, config_entry: ConfigEntry,
add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the Demo Calendar platform.""" """Set up the Demo Calendar config entry."""
add_entities( async_add_entities(
[ [
DemoCalendar(calendar_data_future(), "Calendar 1"), DemoCalendar(calendar_data_future(), "Calendar 1"),
DemoCalendar(calendar_data_current(), "Calendar 2"), DemoCalendar(calendar_data_current(), "Calendar 2"),

View File

@@ -106,7 +106,7 @@ class DemoLight(LightEntity):
state: bool, state: bool,
available: bool = False, available: bool = False,
brightness: int = 180, brightness: int = 180,
ct: int | None = None, # pylint: disable=invalid-name ct: int | None = None,
effect_list: list[str] | None = None, effect_list: list[str] | None = None,
effect: str | None = None, effect: str | None = None,
hs_color: tuple[int, int] | None = None, hs_color: tuple[int, int] | None = None,

View File

@@ -415,9 +415,7 @@ class DHCPWatcher(WatcherBase):
"""Start watching for dhcp packets.""" """Start watching for dhcp packets."""
# Local import because importing from scapy has side effects such as opening # Local import because importing from scapy has side effects such as opening
# sockets # sockets
from scapy import ( # pylint: disable=import-outside-toplevel,unused-import # noqa: F401 from scapy import arch # pylint: disable=import-outside-toplevel # noqa: F401
arch,
)
from scapy.layers.dhcp import DHCP # pylint: disable=import-outside-toplevel from scapy.layers.dhcp import DHCP # pylint: disable=import-outside-toplevel
from scapy.layers.inet import IP # pylint: disable=import-outside-toplevel from scapy.layers.inet import IP # pylint: disable=import-outside-toplevel
from scapy.layers.l2 import Ether # pylint: disable=import-outside-toplevel from scapy.layers.l2 import Ether # pylint: disable=import-outside-toplevel

View File

@@ -65,7 +65,7 @@ class DigitalOceanBinarySensor(BinarySensorEntity):
_attr_attribution = ATTRIBUTION _attr_attribution = ATTRIBUTION
def __init__(self, do, droplet_id): # pylint: disable=invalid-name def __init__(self, do, droplet_id):
"""Initialize a new Digital Ocean sensor.""" """Initialize a new Digital Ocean sensor."""
self._digital_ocean = do self._digital_ocean = do
self._droplet_id = droplet_id self._droplet_id = droplet_id

View File

@@ -63,7 +63,7 @@ class DigitalOceanSwitch(SwitchEntity):
_attr_attribution = ATTRIBUTION _attr_attribution = ATTRIBUTION
def __init__(self, do, droplet_id): # pylint: disable=invalid-name def __init__(self, do, droplet_id):
"""Initialize a new Digital Ocean sensor.""" """Initialize a new Digital Ocean sensor."""
self._digital_ocean = do self._digital_ocean = do
self._droplet_id = droplet_id self._droplet_id = droplet_id

View File

@@ -51,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
discovergy_data.meters = await discovergy_data.api_client.meters() discovergy_data.meters = await discovergy_data.api_client.meters()
except discovergyError.InvalidLogin as err: except discovergyError.InvalidLogin as err:
raise ConfigEntryAuthFailed("Invalid email or password") from err raise ConfigEntryAuthFailed("Invalid email or password") from err
except Exception as err: # pylint: disable=broad-except except Exception as err:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
"Unexpected error while while getting meters" "Unexpected error while while getting meters"
) from err ) from err

View File

@@ -8,14 +8,11 @@ from pydiscovergy.models import Meter
from homeassistant.components.diagnostics import async_redact_data from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import DiscovergyData from . import DiscovergyData
from .const import DOMAIN from .const import DOMAIN
TO_REDACT_CONFIG_ENTRY = {CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID, "title"}
TO_REDACT_METER = { TO_REDACT_METER = {
"serial_number", "serial_number",
"full_serial_number", "full_serial_number",
@@ -44,7 +41,6 @@ async def async_get_config_entry_diagnostics(
last_readings[meter.meter_id] = asdict(coordinator.data) last_readings[meter.meter_id] = asdict(coordinator.data)
return { return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT_CONFIG_ENTRY),
"meters": flattened_meter, "meters": flattened_meter,
"readings": last_readings, "readings": last_readings,
} }

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import io import io
import face_recognition # pylint: disable=import-error import face_recognition
from homeassistant.components.image_processing import ImageProcessingFaceEntity from homeassistant.components.image_processing import ImageProcessingFaceEntity
from homeassistant.const import ATTR_LOCATION, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.const import ATTR_LOCATION, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE
@@ -11,7 +11,6 @@ from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
# pylint: disable=unused-import
from homeassistant.components.image_processing import ( # noqa: F401, isort:skip from homeassistant.components.image_processing import ( # noqa: F401, isort:skip
PLATFORM_SCHEMA, PLATFORM_SCHEMA,
) )

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import io import io
import logging import logging
# pylint: disable=import-error
import face_recognition import face_recognition
import voluptuous as vol import voluptuous as vol

View File

@@ -509,7 +509,7 @@ async def async_setup_entry(
if stop_listener and ( if stop_listener and (
hass.state == CoreState.not_running or hass.is_running hass.state == CoreState.not_running or hass.is_running
): ):
stop_listener() # pylint: disable=not-callable stop_listener()
if transport: if transport:
transport.close() transport.close()

View File

@@ -138,6 +138,6 @@ def async_track_time_interval_backoff(
def remove_listener() -> None: def remove_listener() -> None:
"""Remove interval listener.""" """Remove interval listener."""
if remove: if remove:
remove() # pylint: disable=not-callable remove()
return remove_listener return remove_listener

View File

@@ -7,7 +7,6 @@ from __future__ import annotations
import logging import logging
# pylint: disable=import-error
from beacontools import BeaconScanner, EddystoneFilter, EddystoneTLMFrame from beacontools import BeaconScanner, EddystoneFilter, EddystoneTLMFrame
import voluptuous as vol import voluptuous as vol

View File

@@ -46,16 +46,18 @@ class ElectricKiwiHOPSensorEntityDescription(
def _check_and_move_time(hop: Hop, time: str) -> datetime: def _check_and_move_time(hop: Hop, time: str) -> datetime:
"""Return the time a day forward if HOP end_time is in the past.""" """Return the time a day forward if HOP end_time is in the past."""
date_time = datetime.combine( date_time = datetime.combine(
datetime.today(), dt_util.start_of_local_day(),
datetime.strptime(time, "%I:%M %p").time(), datetime.strptime(time, "%I:%M %p").time(),
).astimezone(dt_util.DEFAULT_TIME_ZONE) dt_util.DEFAULT_TIME_ZONE,
)
end_time = datetime.combine( end_time = datetime.combine(
datetime.today(), dt_util.start_of_local_day(),
datetime.strptime(hop.end.end_time, "%I:%M %p").time(), datetime.strptime(hop.end.end_time, "%I:%M %p").time(),
).astimezone(dt_util.DEFAULT_TIME_ZONE) dt_util.DEFAULT_TIME_ZONE,
)
if end_time < datetime.now().astimezone(dt_util.DEFAULT_TIME_ZONE): if end_time < dt_util.now():
return date_time + timedelta(days=1) return date_time + timedelta(days=1)
return date_time return date_time

View File

@@ -225,7 +225,7 @@ class Config:
@callback @callback
def _clear_exposed_cache(self, event: EventType[EventStateChangedData]) -> None: def _clear_exposed_cache(self, event: EventType[EventStateChangedData]) -> None:
"""Clear the cache of exposed states.""" """Clear the cache of exposed states."""
self.get_exposed_states.cache_clear() # pylint: disable=no-member self.get_exposed_states.cache_clear()
def is_state_exposed(self, state: State) -> bool: def is_state_exposed(self, state: State) -> bool:
"""Cache determine if an entity should be exposed on the emulated bridge.""" """Cache determine if an entity should be exposed on the emulated bridge."""

View File

@@ -50,6 +50,7 @@ async def async_get_config_entry_diagnostics(
"highest_price_time": coordinator.data.energy_today.highest_price_time, "highest_price_time": coordinator.data.energy_today.highest_price_time,
"lowest_price_time": coordinator.data.energy_today.lowest_price_time, "lowest_price_time": coordinator.data.energy_today.lowest_price_time,
"percentage_of_max": coordinator.data.energy_today.pct_of_max_price, "percentage_of_max": coordinator.data.energy_today.pct_of_max_price,
"hours_priced_equal_or_lower": coordinator.data.energy_today.hours_priced_equal_or_lower,
}, },
"gas": { "gas": {
"current_hour_price": get_gas_price(coordinator.data, 0), "current_hour_price": get_gas_price(coordinator.data, 0),

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/energyzero", "documentation": "https://www.home-assistant.io/integrations/energyzero",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["energyzero==0.4.1"] "requirements": ["energyzero==0.5.0"]
} }

View File

@@ -13,7 +13,13 @@ from homeassistant.components.sensor import (
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CURRENCY_EURO, PERCENTAGE, UnitOfEnergy, UnitOfVolume from homeassistant.const import (
CURRENCY_EURO,
PERCENTAGE,
UnitOfEnergy,
UnitOfTime,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -114,6 +120,14 @@ SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = (
icon="mdi:percent", icon="mdi:percent",
value_fn=lambda data: data.energy_today.pct_of_max_price, value_fn=lambda data: data.energy_today.pct_of_max_price,
), ),
EnergyZeroSensorEntityDescription(
key="hours_priced_equal_or_lower",
translation_key="hours_priced_equal_or_lower",
service_type="today_energy",
native_unit_of_measurement=UnitOfTime.HOURS,
icon="mdi:clock",
value_fn=lambda data: data.energy_today.hours_priced_equal_or_lower,
),
) )

View File

@@ -37,9 +37,6 @@
}, },
"hours_priced_equal_or_lower": { "hours_priced_equal_or_lower": {
"name": "Hours priced equal or lower than current - today" "name": "Hours priced equal or lower than current - today"
},
"hours_priced_equal_or_higher": {
"name": "Hours priced equal or higher than current - today"
} }
} }
} }

View File

@@ -22,8 +22,8 @@ from homeassistant.components.weather import (
ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME, ATTR_FORECAST_TIME,
DOMAIN as WEATHER_DOMAIN, DOMAIN as WEATHER_DOMAIN,
CoordinatorWeatherEntity,
Forecast, Forecast,
SingleCoordinatorWeatherEntity,
WeatherEntityFeature, WeatherEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -33,7 +33,7 @@ from homeassistant.const import (
UnitOfSpeed, UnitOfSpeed,
UnitOfTemperature, UnitOfTemperature,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@@ -86,7 +86,7 @@ def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> st
return f"{config_entry_unique_id}{'-hourly' if hourly else '-daily'}" return f"{config_entry_unique_id}{'-hourly' if hourly else '-daily'}"
class ECWeather(CoordinatorWeatherEntity): class ECWeather(SingleCoordinatorWeatherEntity):
"""Representation of a weather condition.""" """Representation of a weather condition."""
_attr_has_entity_name = True _attr_has_entity_name = True
@@ -182,11 +182,13 @@ class ECWeather(CoordinatorWeatherEntity):
"""Return the forecast array.""" """Return the forecast array."""
return get_forecast(self.ec_data, self._hourly) return get_forecast(self.ec_data, self._hourly)
async def async_forecast_daily(self) -> list[Forecast] | None: @callback
def _async_forecast_daily(self) -> list[Forecast] | None:
"""Return the daily forecast in native units.""" """Return the daily forecast in native units."""
return get_forecast(self.ec_data, False) return get_forecast(self.ec_data, False)
async def async_forecast_hourly(self) -> list[Forecast] | None: @callback
def _async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast in native units.""" """Return the hourly forecast in native units."""
return get_forecast(self.ec_data, True) return get_forecast(self.ec_data, True)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
import eq3bt as eq3 # pylint: disable=import-error import eq3bt as eq3
import voluptuous as vol import voluptuous as vol
from homeassistant.components.climate import ( from homeassistant.components.climate import (

View File

@@ -51,9 +51,7 @@ CCCD_INDICATE_BYTES = b"\x02\x00"
DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_WrapFuncType = TypeVar( # pylint: disable=invalid-name _WrapFuncType = TypeVar("_WrapFuncType", bound=Callable[..., Any])
"_WrapFuncType", bound=Callable[..., Any]
)
def mac_to_int(address: str) -> int: def mac_to_int(address: str) -> int:

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
import functools import functools
import math import math
from typing import Any, Generic, TypeVar, cast # pylint: disable=unused-import from typing import Any, Generic, TypeVar, cast
from aioesphomeapi import ( from aioesphomeapi import (
EntityCategory as EsphomeEntityCategory, EntityCategory as EsphomeEntityCategory,

View File

@@ -181,7 +181,6 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
try_keep_current_mode = False try_keep_current_mode = False
if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None: if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None:
# pylint: disable-next=invalid-name
*rgb, w = tuple(x / 255 for x in rgbw_ha) # type: ignore[assignment] *rgb, w = tuple(x / 255 for x in rgbw_ha) # type: ignore[assignment]
color_bri = max(rgb) color_bri = max(rgb)
# normalize rgb # normalize rgb
@@ -194,7 +193,6 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
try_keep_current_mode = False try_keep_current_mode = False
if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None: if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None:
# pylint: disable-next=invalid-name
*rgb, cw, ww = tuple(x / 255 for x in rgbww_ha) # type: ignore[assignment] *rgb, cw, ww = tuple(x / 255 for x in rgbww_ha) # type: ignore[assignment]
color_bri = max(rgb) color_bri = max(rgb)
# normalize rgb # normalize rgb

View File

@@ -17,7 +17,7 @@
"requirements": [ "requirements": [
"async_interrupt==1.1.1", "async_interrupt==1.1.1",
"aioesphomeapi==16.0.1", "aioesphomeapi==16.0.1",
"bluetooth-data-tools==1.8.0", "bluetooth-data-tools==1.9.0",
"esphome-dashboard-api==1.2.3" "esphome-dashboard-api==1.2.3"
], ],
"zeroconf": ["_esphomelib._tcp.local."] "zeroconf": ["_esphomelib._tcp.local."]

View File

@@ -134,7 +134,6 @@ class EufyHomeLight(LightEntity):
"""Turn the specified light on.""" """Turn the specified light on."""
brightness = kwargs.get(ATTR_BRIGHTNESS) brightness = kwargs.get(ATTR_BRIGHTNESS)
colortemp = kwargs.get(ATTR_COLOR_TEMP) colortemp = kwargs.get(ATTR_COLOR_TEMP)
# pylint: disable-next=invalid-name
hs = kwargs.get(ATTR_HS_COLOR) hs = kwargs.get(ATTR_HS_COLOR)
if brightness is not None: if brightness is not None:

View File

@@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util.dt import utc_from_timestamp from homeassistant.util import dt as dt_util
_LOGGER = getLogger(__name__) _LOGGER = getLogger(__name__)
@@ -207,7 +207,7 @@ class FeedManager:
self._firstrun = False self._firstrun = False
else: else:
# Set last entry timestamp as epoch time if not available # Set last entry timestamp as epoch time if not available
self._last_entry_timestamp = datetime.utcfromtimestamp(0).timetuple() self._last_entry_timestamp = dt_util.utc_from_timestamp(0).timetuple()
for entry in self._feed.entries: for entry in self._feed.entries:
if ( if (
self._firstrun self._firstrun
@@ -286,6 +286,6 @@ class StoredData:
def _async_save_data(self) -> dict[str, str]: def _async_save_data(self) -> dict[str, str]:
"""Save feed data to storage.""" """Save feed data to storage."""
return { return {
feed_id: utc_from_timestamp(timegm(struct_utc)).isoformat() feed_id: dt_util.utc_from_timestamp(timegm(struct_utc)).isoformat()
for feed_id, struct_utc in self._data.items() for feed_id, struct_utc in self._data.items()
} }

View File

@@ -102,9 +102,7 @@ class FileSizeCoordinator(DataUpdateCoordinator):
raise UpdateFailed(f"Can not retrieve file statistics {error}") from error raise UpdateFailed(f"Can not retrieve file statistics {error}") from error
size = statinfo.st_size size = statinfo.st_size
last_updated = datetime.utcfromtimestamp(statinfo.st_mtime).replace( last_updated = dt_util.utc_from_timestamp(statinfo.st_mtime)
tzinfo=dt_util.UTC
)
_LOGGER.debug("size %s, last updated %s", size, last_updated) _LOGGER.debug("size %s, last updated %s", size, last_updated)
data: dict[str, int | float | datetime] = { data: dict[str, int | float | datetime] = {

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from gardena_bluetooth.const import Valve from gardena_bluetooth.const import Sensor, Valve
from gardena_bluetooth.parse import CharacteristicBool from gardena_bluetooth.parse import CharacteristicBool
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
@@ -26,6 +26,11 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio
char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool("")) char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool(""))
@property
def context(self) -> set[str]:
"""Context needed for update coordinator."""
return {self.char.uuid}
DESCRIPTIONS = ( DESCRIPTIONS = (
GardenaBluetoothBinarySensorEntityDescription( GardenaBluetoothBinarySensorEntityDescription(
@@ -35,6 +40,13 @@ DESCRIPTIONS = (
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
char=Valve.connected_state, char=Valve.connected_state,
), ),
GardenaBluetoothBinarySensorEntityDescription(
key=Sensor.connected_state.uuid,
translation_key="sensor_connected_state",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
char=Sensor.connected_state,
),
) )
@@ -44,7 +56,7 @@ async def async_setup_entry(
"""Set up binary sensor based on a config entry.""" """Set up binary sensor based on a config entry."""
coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id]
entities = [ entities = [
GardenaBluetoothBinarySensor(coordinator, description) GardenaBluetoothBinarySensor(coordinator, description, description.context)
for description in DESCRIPTIONS for description in DESCRIPTIONS
if description.key in coordinator.characteristics if description.key in coordinator.characteristics
] ]

View File

@@ -22,6 +22,11 @@ class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription):
char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool("")) char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool(""))
@property
def context(self) -> set[str]:
"""Context needed for update coordinator."""
return {self.char.uuid}
DESCRIPTIONS = ( DESCRIPTIONS = (
GardenaBluetoothButtonEntityDescription( GardenaBluetoothButtonEntityDescription(
@@ -40,7 +45,7 @@ async def async_setup_entry(
"""Set up button based on a config entry.""" """Set up button based on a config entry."""
coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id]
entities = [ entities = [
GardenaBluetoothButton(coordinator, description) GardenaBluetoothButton(coordinator, description, description.context)
for description in DESCRIPTIONS for description in DESCRIPTIONS
if description.key in coordinator.characteristics if description.key in coordinator.characteristics
] ]

View File

@@ -117,8 +117,12 @@ class GardenaBluetoothEntity(CoordinatorEntity[Coordinator]):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if entity is available.""" """Return if entity is available."""
return super().available and bluetooth.async_address_present( return (
self.hass, self.coordinator.address, True self.coordinator.last_update_success
and bluetooth.async_address_present(
self.hass, self.coordinator.address, True
)
and self._attr_available
) )
@@ -126,9 +130,12 @@ class GardenaBluetoothDescriptorEntity(GardenaBluetoothEntity):
"""Coordinator entity for entities with entity description.""" """Coordinator entity for entities with entity description."""
def __init__( def __init__(
self, coordinator: Coordinator, description: EntityDescription self,
coordinator: Coordinator,
description: EntityDescription,
context: set[str],
) -> None: ) -> None:
"""Initialize description entity.""" """Initialize description entity."""
super().__init__(coordinator, {description.key}) super().__init__(coordinator, context)
self._attr_unique_id = f"{coordinator.address}-{description.key}" self._attr_unique_id = f"{coordinator.address}-{description.key}"
self.entity_description = description self.entity_description = description

View File

@@ -3,8 +3,9 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from gardena_bluetooth.const import DeviceConfiguration, Valve from gardena_bluetooth.const import DeviceConfiguration, Sensor, Valve
from gardena_bluetooth.parse import ( from gardena_bluetooth.parse import (
Characteristic,
CharacteristicInt, CharacteristicInt,
CharacteristicLong, CharacteristicLong,
CharacteristicUInt16, CharacteristicUInt16,
@@ -16,7 +17,7 @@ from homeassistant.components.number import (
NumberMode, NumberMode,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -35,6 +36,15 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription):
char: CharacteristicInt | CharacteristicUInt16 | CharacteristicLong = field( char: CharacteristicInt | CharacteristicUInt16 | CharacteristicLong = field(
default_factory=lambda: CharacteristicInt("") default_factory=lambda: CharacteristicInt("")
) )
connected_state: Characteristic | None = None
@property
def context(self) -> set[str]:
"""Context needed for update coordinator."""
data = {self.char.uuid}
if self.connected_state:
data.add(self.connected_state.uuid)
return data
DESCRIPTIONS = ( DESCRIPTIONS = (
@@ -81,6 +91,18 @@ DESCRIPTIONS = (
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
char=DeviceConfiguration.seasonal_adjust, char=DeviceConfiguration.seasonal_adjust,
), ),
GardenaBluetoothNumberEntityDescription(
key=Sensor.threshold.uuid,
translation_key="sensor_threshold",
native_unit_of_measurement=PERCENTAGE,
mode=NumberMode.BOX,
native_min_value=0.0,
native_max_value=100.0,
native_step=1.0,
entity_category=EntityCategory.CONFIG,
char=Sensor.threshold,
connected_state=Sensor.connected_state,
),
) )
@@ -90,7 +112,7 @@ async def async_setup_entry(
"""Set up entity based on a config entry.""" """Set up entity based on a config entry."""
coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[NumberEntity] = [ entities: list[NumberEntity] = [
GardenaBluetoothNumber(coordinator, description) GardenaBluetoothNumber(coordinator, description, description.context)
for description in DESCRIPTIONS for description in DESCRIPTIONS
if description.key in coordinator.characteristics if description.key in coordinator.characteristics
] ]
@@ -110,6 +132,12 @@ class GardenaBluetoothNumber(GardenaBluetoothDescriptorEntity, NumberEntity):
self._attr_native_value = None self._attr_native_value = None
else: else:
self._attr_native_value = float(data) self._attr_native_value = float(data)
if char := self.entity_description.connected_state:
self._attr_available = bool(self.coordinator.get_cached(char))
else:
self._attr_available = True
super()._handle_coordinator_update() super()._handle_coordinator_update()
async def async_set_native_value(self, value: float) -> None: async def async_set_native_value(self, value: float) -> None:

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from gardena_bluetooth.const import Battery, Valve from gardena_bluetooth.const import Battery, Sensor, Valve
from gardena_bluetooth.parse import Characteristic from gardena_bluetooth.parse import Characteristic
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@@ -32,6 +32,15 @@ class GardenaBluetoothSensorEntityDescription(SensorEntityDescription):
"""Description of entity.""" """Description of entity."""
char: Characteristic = field(default_factory=lambda: Characteristic("")) char: Characteristic = field(default_factory=lambda: Characteristic(""))
connected_state: Characteristic | None = None
@property
def context(self) -> set[str]:
"""Context needed for update coordinator."""
data = {self.char.uuid}
if self.connected_state:
data.add(self.connected_state.uuid)
return data
DESCRIPTIONS = ( DESCRIPTIONS = (
@@ -51,6 +60,40 @@ DESCRIPTIONS = (
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
char=Battery.battery_level, char=Battery.battery_level,
), ),
GardenaBluetoothSensorEntityDescription(
key=Sensor.battery_level.uuid,
translation_key="sensor_battery_level",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE,
char=Sensor.battery_level,
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.value.uuid,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.MOISTURE,
native_unit_of_measurement=PERCENTAGE,
char=Sensor.value,
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.type.uuid,
translation_key="sensor_type",
entity_category=EntityCategory.DIAGNOSTIC,
char=Sensor.type,
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.measurement_timestamp.uuid,
translation_key="sensor_measurement_timestamp",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
char=Sensor.measurement_timestamp,
connected_state=Sensor.connected_state,
),
) )
@@ -60,7 +103,7 @@ async def async_setup_entry(
"""Set up Gardena Bluetooth sensor based on a config entry.""" """Set up Gardena Bluetooth sensor based on a config entry."""
coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[GardenaBluetoothEntity] = [ entities: list[GardenaBluetoothEntity] = [
GardenaBluetoothSensor(coordinator, description) GardenaBluetoothSensor(coordinator, description, description.context)
for description in DESCRIPTIONS for description in DESCRIPTIONS
if description.key in coordinator.characteristics if description.key in coordinator.characteristics
] ]
@@ -81,6 +124,12 @@ class GardenaBluetoothSensor(GardenaBluetoothDescriptorEntity, SensorEntity):
tzinfo=dt_util.get_time_zone(self.hass.config.time_zone) tzinfo=dt_util.get_time_zone(self.hass.config.time_zone)
) )
self._attr_native_value = value self._attr_native_value = value
if char := self.entity_description.connected_state:
self._attr_available = bool(self.coordinator.get_cached(char))
else:
self._attr_available = True
super()._handle_coordinator_update() super()._handle_coordinator_update()

View File

@@ -23,6 +23,9 @@
"binary_sensor": { "binary_sensor": {
"valve_connected_state": { "valve_connected_state": {
"name": "Valve connection" "name": "Valve connection"
},
"sensor_connected_state": {
"name": "Sensor connection"
} }
}, },
"button": { "button": {
@@ -45,12 +48,24 @@
}, },
"seasonal_adjust": { "seasonal_adjust": {
"name": "Seasonal adjust" "name": "Seasonal adjust"
},
"sensor_threshold": {
"name": "Sensor threshold"
} }
}, },
"sensor": { "sensor": {
"activation_reason": { "activation_reason": {
"name": "Activation reason" "name": "Activation reason"
}, },
"sensor_battery_level": {
"name": "Sensor battery"
},
"sensor_type": {
"name": "Sensor type"
},
"sensor_measurement_timestamp": {
"name": "Sensor timestamp"
},
"remaining_open_timestamp": { "remaining_open_timestamp": {
"name": "Valve closing" "name": "Valve closing"
} }

View File

@@ -172,15 +172,16 @@ class GenericCamera(Camera):
self._last_url = None self._last_url = None
self._last_image = None self._last_image = None
@property
def use_stream_for_stills(self) -> bool:
"""Whether or not to use stream to generate stills."""
return not self._still_image_url
async def async_camera_image( async def async_camera_image(
self, width: int | None = None, height: int | None = None self, width: int | None = None, height: int | None = None
) -> bytes | None: ) -> bytes | None:
"""Return a still image response from the camera.""" """Return a still image response from the camera."""
if not self._still_image_url: if not self._still_image_url:
if not self.stream:
await self.async_create_stream()
if self.stream:
return await self.stream.async_get_image(width, height)
return None return None
try: try:
url = self._still_image_url.async_render(parse_result=False) url = self._still_image_url.async_render(parse_result=False)

View File

@@ -36,7 +36,7 @@ from homeassistant.components.calendar import (
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET
from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers import entity_platform, entity_registry as er
from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity import generate_entity_id
@@ -383,7 +383,6 @@ class GoogleCalendarEntity(
self._event: CalendarEvent | None = None self._event: CalendarEvent | None = None
self._attr_name = data[CONF_NAME].capitalize() self._attr_name = data[CONF_NAME].capitalize()
self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET) self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET)
self._offset_value: timedelta | None = None
self.entity_id = entity_id self.entity_id = entity_id
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
self._attr_entity_registry_enabled_default = entity_enabled self._attr_entity_registry_enabled_default = entity_enabled
@@ -392,17 +391,6 @@ class GoogleCalendarEntity(
CalendarEntityFeature.CREATE_EVENT | CalendarEntityFeature.DELETE_EVENT CalendarEntityFeature.CREATE_EVENT | CalendarEntityFeature.DELETE_EVENT
) )
@property
def should_poll(self) -> bool:
"""Enable polling for the entity.
The coordinator is not used by multiple entities, but instead
is used to poll the calendar API at a separate interval from the
entity state updates itself which happen more frequently (e.g. to
fire an alarm when the next event starts).
"""
return True
@property @property
def extra_state_attributes(self) -> dict[str, bool]: def extra_state_attributes(self) -> dict[str, bool]:
"""Return the device state attributes.""" """Return the device state attributes."""
@@ -411,16 +399,16 @@ class GoogleCalendarEntity(
@property @property
def offset_reached(self) -> bool: def offset_reached(self) -> bool:
"""Return whether or not the event offset was reached.""" """Return whether or not the event offset was reached."""
if self._event and self._offset_value: (event, offset_value) = self._event_with_offset()
return is_offset_reached( if event is not None and offset_value is not None:
self._event.start_datetime_local, self._offset_value return is_offset_reached(event.start_datetime_local, offset_value)
)
return False return False
@property @property
def event(self) -> CalendarEvent | None: def event(self) -> CalendarEvent | None:
"""Return the next upcoming event.""" """Return the next upcoming event."""
return self._event (event, _) = self._event_with_offset()
return event
def _event_filter(self, event: Event) -> bool: def _event_filter(self, event: Event) -> bool:
"""Return True if the event is visible.""" """Return True if the event is visible."""
@@ -435,12 +423,10 @@ class GoogleCalendarEntity(
# We do not ask for an update with async_add_entities() # We do not ask for an update with async_add_entities()
# because it will update disabled entities. This is started as a # because it will update disabled entities. This is started as a
# task to let if sync in the background without blocking startup # task to let if sync in the background without blocking startup
async def refresh() -> None:
await self.coordinator.async_request_refresh()
self._apply_coordinator_update()
self.coordinator.config_entry.async_create_background_task( self.coordinator.config_entry.async_create_background_task(
self.hass, refresh(), "google.calendar-refresh" self.hass,
self.coordinator.async_request_refresh(),
"google.calendar-refresh",
) )
async def async_get_events( async def async_get_events(
@@ -453,8 +439,10 @@ class GoogleCalendarEntity(
for event in filter(self._event_filter, result_items) for event in filter(self._event_filter, result_items)
] ]
def _apply_coordinator_update(self) -> None: def _event_with_offset(
"""Copy state from the coordinator to this entity.""" self,
) -> tuple[CalendarEvent | None, timedelta | None]:
"""Get the calendar event and offset if any."""
if api_event := next( if api_event := next(
filter( filter(
self._event_filter, self._event_filter,
@@ -462,27 +450,13 @@ class GoogleCalendarEntity(
), ),
None, None,
): ):
self._event = _get_calendar_event(api_event) event = _get_calendar_event(api_event)
(self._event.summary, self._offset_value) = extract_offset( if self._offset:
self._event.summary, self._offset (event.summary, offset_value) = extract_offset(
) event.summary, self._offset
else: )
self._event = None return event, offset_value
return None, None
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._apply_coordinator_update()
super()._handle_coordinator_update()
async def async_update(self) -> None:
"""Disable update behavior.
This relies on the coordinator callback update to write home assistant
state with the next calendar event. This update is a no-op as no new data
fetch is needed to evaluate the state to determine if the next event has
started, handled by CalendarEntity parent class.
"""
async def async_create_event(self, **kwargs: Any) -> None: async def async_create_event(self, **kwargs: Any) -> None:
"""Add a new event to calendar.""" """Add a new event to calendar."""

View File

@@ -147,6 +147,6 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig
def unsub_all(): def unsub_all():
unsub() unsub()
if unsub_pending: if unsub_pending:
unsub_pending() # pylint: disable=not-callable unsub_pending()
return unsub_all return unsub_all

View File

@@ -60,9 +60,7 @@ class OAuth2FlowHandler(
def _get_profile() -> str: def _get_profile() -> str:
"""Get profile from inside the executor.""" """Get profile from inside the executor."""
users = build( # pylint: disable=no-member users = build("gmail", "v1", credentials=credentials).users()
"gmail", "v1", credentials=credentials
).users()
return users.getProfile(userId="me").execute()["emailAddress"] return users.getProfile(userId="me").execute()["emailAddress"]
credentials = Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) credentials = Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN])

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from abc import abstractmethod from abc import abstractmethod
import asyncio import asyncio
from collections.abc import Collection, Iterable from collections.abc import Callable, Collection, Iterable, Mapping
from contextvars import ContextVar from contextvars import ContextVar
import logging import logging
from typing import Any, Protocol, cast from typing import Any, Protocol, cast
@@ -473,9 +473,60 @@ class GroupEntity(Entity):
"""Representation of a Group of entities.""" """Representation of a Group of entities."""
_attr_should_poll = False _attr_should_poll = False
_entity_ids: list[str]
@callback
def async_start_preview(
self,
preview_callback: Callable[[str, Mapping[str, Any]], None],
) -> CALLBACK_TYPE:
"""Render a preview."""
for entity_id in self._entity_ids:
if (state := self.hass.states.get(entity_id)) is None:
continue
self.async_update_supported_features(entity_id, state)
@callback
def async_state_changed_listener(
event: EventType[EventStateChangedData] | None,
) -> None:
"""Handle child updates."""
self.async_update_group_state()
if event:
self.async_update_supported_features(
event.data["entity_id"], event.data["new_state"]
)
preview_callback(*self._async_generate_attributes())
async_state_changed_listener(None)
return async_track_state_change_event(
self.hass, self._entity_ids, async_state_changed_listener
)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Register listeners.""" """Register listeners."""
for entity_id in self._entity_ids:
if (state := self.hass.states.get(entity_id)) is None:
continue
self.async_update_supported_features(entity_id, state)
@callback
def async_state_changed_listener(
event: EventType[EventStateChangedData],
) -> None:
"""Handle child updates."""
self.async_set_context(event.context)
self.async_update_supported_features(
event.data["entity_id"], event.data["new_state"]
)
self.async_defer_or_update_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, self._entity_ids, async_state_changed_listener
)
)
async def _update_at_start(_: HomeAssistant) -> None: async def _update_at_start(_: HomeAssistant) -> None:
self.async_update_group_state() self.async_update_group_state()
@@ -493,9 +544,18 @@ class GroupEntity(Entity):
self.async_write_ha_state() self.async_write_ha_state()
@abstractmethod @abstractmethod
@callback
def async_update_group_state(self) -> None: def async_update_group_state(self) -> None:
"""Abstract method to update the entity.""" """Abstract method to update the entity."""
@callback
def async_update_supported_features(
self,
entity_id: str,
new_state: State | None,
) -> None:
"""Update dictionaries with supported features."""
class Group(Entity): class Group(Entity):
"""Track a group of entity ids.""" """Track a group of entity ids."""

View File

@@ -1,7 +1,6 @@
"""Platform allowing several binary sensor to be grouped into one binary sensor.""" """Platform allowing several binary sensor to be grouped into one binary sensor."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
@@ -24,14 +23,10 @@ from homeassistant.const import (
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
STATE_UNKNOWN, STATE_UNKNOWN,
) )
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
EventStateChangedData,
async_track_state_change_event,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType
from . import GroupEntity from . import GroupEntity
@@ -92,6 +87,20 @@ async def async_setup_entry(
) )
@callback
def async_create_preview_binary_sensor(
name: str, validated_config: dict[str, Any]
) -> BinarySensorGroup:
"""Create a preview sensor."""
return BinarySensorGroup(
None,
name,
None,
validated_config[CONF_ENTITIES],
validated_config[CONF_ALL],
)
class BinarySensorGroup(GroupEntity, BinarySensorEntity): class BinarySensorGroup(GroupEntity, BinarySensorEntity):
"""Representation of a BinarySensorGroup.""" """Representation of a BinarySensorGroup."""
@@ -116,45 +125,6 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity):
if mode: if mode:
self.mode = all self.mode = all
@callback
def async_start_preview(
self,
preview_callback: Callable[[str, Mapping[str, Any]], None],
) -> CALLBACK_TYPE:
"""Render a preview."""
@callback
def async_state_changed_listener(
event: EventType[EventStateChangedData] | None,
) -> None:
"""Handle child updates."""
self.async_update_group_state()
preview_callback(*self._async_generate_attributes())
async_state_changed_listener(None)
return async_track_state_change_event(
self.hass, self._entity_ids, async_state_changed_listener
)
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@callback
def async_state_changed_listener(
event: EventType[EventStateChangedData],
) -> None:
"""Handle child updates."""
self.async_set_context(event.context)
self.async_defer_or_update_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, self._entity_ids, async_state_changed_listener
)
)
await super().async_added_to_hass()
@callback @callback
def async_update_group_state(self) -> None: def async_update_group_state(self) -> None:
"""Query all members and determine the binary sensor group state.""" """Query all members and determine the binary sensor group state."""

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Callable, Coroutine, Mapping from collections.abc import Callable, Coroutine, Mapping
from functools import partial from functools import partial
from typing import Any, Literal, cast from typing import Any, cast
import voluptuous as vol import voluptuous as vol
@@ -21,10 +21,17 @@ from homeassistant.helpers.schema_config_entry_flow import (
entity_selector_without_own_entities, entity_selector_without_own_entities,
) )
from . import DOMAIN from . import DOMAIN, GroupEntity
from .binary_sensor import CONF_ALL, BinarySensorGroup from .binary_sensor import CONF_ALL, async_create_preview_binary_sensor
from .const import CONF_HIDE_MEMBERS, CONF_IGNORE_NON_NUMERIC from .const import CONF_HIDE_MEMBERS, CONF_IGNORE_NON_NUMERIC
from .sensor import SensorGroup from .cover import async_create_preview_cover
from .event import async_create_preview_event
from .fan import async_create_preview_fan
from .light import async_create_preview_light
from .lock import async_create_preview_lock
from .media_player import MediaPlayerGroup, async_create_preview_media_player
from .sensor import async_create_preview_sensor
from .switch import async_create_preview_switch
_STATISTIC_MEASURES = [ _STATISTIC_MEASURES = [
"min", "min",
@@ -122,7 +129,7 @@ SENSOR_CONFIG_SCHEMA = basic_group_config_schema(
async def light_switch_options_schema( async def light_switch_options_schema(
domain: str, handler: SchemaCommonFlowHandler domain: str, handler: SchemaCommonFlowHandler | None
) -> vol.Schema: ) -> vol.Schema:
"""Generate options schema.""" """Generate options schema."""
return (await basic_group_options_schema(domain, handler)).extend( return (await basic_group_options_schema(domain, handler)).extend(
@@ -137,6 +144,7 @@ async def light_switch_options_schema(
GROUP_TYPES = [ GROUP_TYPES = [
"binary_sensor", "binary_sensor",
"cover", "cover",
"event",
"fan", "fan",
"light", "light",
"lock", "lock",
@@ -171,36 +179,47 @@ CONFIG_FLOW = {
"user": SchemaFlowMenuStep(GROUP_TYPES), "user": SchemaFlowMenuStep(GROUP_TYPES),
"binary_sensor": SchemaFlowFormStep( "binary_sensor": SchemaFlowFormStep(
BINARY_SENSOR_CONFIG_SCHEMA, BINARY_SENSOR_CONFIG_SCHEMA,
preview="group",
validate_user_input=set_group_type("binary_sensor"), validate_user_input=set_group_type("binary_sensor"),
preview="group_binary_sensor",
), ),
"cover": SchemaFlowFormStep( "cover": SchemaFlowFormStep(
basic_group_config_schema("cover"), basic_group_config_schema("cover"),
preview="group",
validate_user_input=set_group_type("cover"), validate_user_input=set_group_type("cover"),
), ),
"event": SchemaFlowFormStep(
basic_group_config_schema("event"),
preview="group",
validate_user_input=set_group_type("event"),
),
"fan": SchemaFlowFormStep( "fan": SchemaFlowFormStep(
basic_group_config_schema("fan"), basic_group_config_schema("fan"),
preview="group",
validate_user_input=set_group_type("fan"), validate_user_input=set_group_type("fan"),
), ),
"light": SchemaFlowFormStep( "light": SchemaFlowFormStep(
basic_group_config_schema("light"), basic_group_config_schema("light"),
preview="group",
validate_user_input=set_group_type("light"), validate_user_input=set_group_type("light"),
), ),
"lock": SchemaFlowFormStep( "lock": SchemaFlowFormStep(
basic_group_config_schema("lock"), basic_group_config_schema("lock"),
preview="group",
validate_user_input=set_group_type("lock"), validate_user_input=set_group_type("lock"),
), ),
"media_player": SchemaFlowFormStep( "media_player": SchemaFlowFormStep(
basic_group_config_schema("media_player"), basic_group_config_schema("media_player"),
preview="group",
validate_user_input=set_group_type("media_player"), validate_user_input=set_group_type("media_player"),
), ),
"sensor": SchemaFlowFormStep( "sensor": SchemaFlowFormStep(
SENSOR_CONFIG_SCHEMA, SENSOR_CONFIG_SCHEMA,
preview="group",
validate_user_input=set_group_type("sensor"), validate_user_input=set_group_type("sensor"),
preview="group_sensor",
), ),
"switch": SchemaFlowFormStep( "switch": SchemaFlowFormStep(
basic_group_config_schema("switch"), basic_group_config_schema("switch"),
preview="group",
validate_user_input=set_group_type("switch"), validate_user_input=set_group_type("switch"),
), ),
} }
@@ -210,20 +229,57 @@ OPTIONS_FLOW = {
"init": SchemaFlowFormStep(next_step=choose_options_step), "init": SchemaFlowFormStep(next_step=choose_options_step),
"binary_sensor": SchemaFlowFormStep( "binary_sensor": SchemaFlowFormStep(
binary_sensor_options_schema, binary_sensor_options_schema,
preview="group_binary_sensor", preview="group",
),
"cover": SchemaFlowFormStep(
partial(basic_group_options_schema, "cover"),
preview="group",
),
"event": SchemaFlowFormStep(
partial(basic_group_options_schema, "event"),
preview="group",
),
"fan": SchemaFlowFormStep(
partial(basic_group_options_schema, "fan"),
preview="group",
),
"light": SchemaFlowFormStep(
partial(light_switch_options_schema, "light"),
preview="group",
),
"lock": SchemaFlowFormStep(
partial(basic_group_options_schema, "lock"),
preview="group",
), ),
"cover": SchemaFlowFormStep(partial(basic_group_options_schema, "cover")),
"fan": SchemaFlowFormStep(partial(basic_group_options_schema, "fan")),
"light": SchemaFlowFormStep(partial(light_switch_options_schema, "light")),
"lock": SchemaFlowFormStep(partial(basic_group_options_schema, "lock")),
"media_player": SchemaFlowFormStep( "media_player": SchemaFlowFormStep(
partial(basic_group_options_schema, "media_player") partial(basic_group_options_schema, "media_player"),
preview="group",
), ),
"sensor": SchemaFlowFormStep( "sensor": SchemaFlowFormStep(
partial(sensor_options_schema, "sensor"), partial(sensor_options_schema, "sensor"),
preview="group_sensor", preview="group",
), ),
"switch": SchemaFlowFormStep(partial(light_switch_options_schema, "switch")), "switch": SchemaFlowFormStep(
partial(light_switch_options_schema, "switch"),
preview="group",
),
}
PREVIEW_OPTIONS_SCHEMA: dict[str, vol.Schema] = {}
CREATE_PREVIEW_ENTITY: dict[
str,
Callable[[str, dict[str, Any]], GroupEntity | MediaPlayerGroup],
] = {
"binary_sensor": async_create_preview_binary_sensor,
"cover": async_create_preview_cover,
"event": async_create_preview_event,
"fan": async_create_preview_fan,
"light": async_create_preview_light,
"lock": async_create_preview_lock,
"media_player": async_create_preview_media_player,
"sensor": async_create_preview_sensor,
"switch": async_create_preview_switch,
} }
@@ -261,12 +317,20 @@ class GroupConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
) )
_async_hide_members(hass, options[CONF_ENTITIES], hidden_by) _async_hide_members(hass, options[CONF_ENTITIES], hidden_by)
@callback
@staticmethod @staticmethod
def async_setup_preview(hass: HomeAssistant) -> None: async def async_setup_preview(hass: HomeAssistant) -> None:
"""Set up preview WS API.""" """Set up preview WS API."""
websocket_api.async_register_command(hass, ws_preview_sensor) for group_type, form_step in OPTIONS_FLOW.items():
websocket_api.async_register_command(hass, ws_preview_binary_sensor) if group_type not in GROUP_TYPES:
continue
schema = cast(
Callable[
[SchemaCommonFlowHandler | None], Coroutine[Any, Any, vol.Schema]
],
form_step.schema,
)
PREVIEW_OPTIONS_SCHEMA[group_type] = await schema(None)
websocket_api.async_register_command(hass, ws_start_preview)
def _async_hide_members( def _async_hide_members(
@@ -282,127 +346,50 @@ def _async_hide_members(
registry.async_update_entity(entity_id, hidden_by=hidden_by) registry.async_update_entity(entity_id, hidden_by=hidden_by)
@websocket_api.websocket_command(
{
vol.Required("type"): "group/start_preview",
vol.Required("flow_id"): str,
vol.Required("flow_type"): vol.Any("config_flow", "options_flow"),
vol.Required("user_input"): dict,
}
)
@callback @callback
def _async_handle_ws_preview( def ws_start_preview(
hass: HomeAssistant, hass: HomeAssistant,
connection: websocket_api.ActiveConnection, connection: websocket_api.ActiveConnection,
msg: dict[str, Any], msg: dict[str, Any],
config_schema: vol.Schema,
options_schema: vol.Schema,
create_preview_entity: Callable[
[Literal["config_flow", "options_flow"], str, dict[str, Any]],
BinarySensorGroup | SensorGroup,
],
) -> None: ) -> None:
"""Generate a preview.""" """Generate a preview."""
if msg["flow_type"] == "config_flow": if msg["flow_type"] == "config_flow":
validated = config_schema(msg["user_input"]) flow_status = hass.config_entries.flow.async_get(msg["flow_id"])
group_type = flow_status["step_id"]
form_step = cast(SchemaFlowFormStep, CONFIG_FLOW[group_type])
schema = cast(vol.Schema, form_step.schema)
validated = schema(msg["user_input"])
name = validated["name"] name = validated["name"]
else: else:
validated = options_schema(msg["user_input"])
flow_status = hass.config_entries.options.async_get(msg["flow_id"]) flow_status = hass.config_entries.options.async_get(msg["flow_id"])
config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
if not config_entry: if not config_entry:
raise HomeAssistantError raise HomeAssistantError
group_type = config_entry.options["group_type"]
name = config_entry.options["name"] name = config_entry.options["name"]
validated = PREVIEW_OPTIONS_SCHEMA[group_type](msg["user_input"])
@callback @callback
def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None:
"""Forward config entry state events to websocket.""" """Forward config entry state events to websocket."""
connection.send_message( connection.send_message(
websocket_api.event_message( websocket_api.event_message(
msg["id"], {"state": state, "attributes": attributes} msg["id"], {"attributes": attributes, "state": state}
) )
) )
preview_entity = create_preview_entity(msg["flow_type"], name, validated) preview_entity = CREATE_PREVIEW_ENTITY[group_type](name, validated)
preview_entity.hass = hass preview_entity.hass = hass
connection.send_result(msg["id"]) connection.send_result(msg["id"])
connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( connection.subscriptions[msg["id"]] = preview_entity.async_start_preview(
async_preview_updated async_preview_updated
) )
@websocket_api.websocket_command(
{
vol.Required("type"): "group/binary_sensor/start_preview",
vol.Required("flow_id"): str,
vol.Required("flow_type"): vol.Any("config_flow", "options_flow"),
vol.Required("user_input"): dict,
}
)
@websocket_api.async_response
async def ws_preview_binary_sensor(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Generate a preview."""
def create_preview_binary_sensor(
flow_type: Literal["config_flow", "options_flow"],
name: str,
validated_config: dict[str, Any],
) -> BinarySensorGroup:
"""Create a preview sensor."""
return BinarySensorGroup(
None,
name,
None,
validated_config[CONF_ENTITIES],
validated_config[CONF_ALL],
)
_async_handle_ws_preview(
hass,
connection,
msg,
BINARY_SENSOR_CONFIG_SCHEMA,
await binary_sensor_options_schema(None),
create_preview_binary_sensor,
)
@websocket_api.websocket_command(
{
vol.Required("type"): "group/sensor/start_preview",
vol.Required("flow_id"): str,
vol.Required("flow_type"): vol.Any("config_flow", "options_flow"),
vol.Required("user_input"): dict,
}
)
@websocket_api.async_response
async def ws_preview_sensor(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Generate a preview."""
def create_preview_sensor(
flow_type: Literal["config_flow", "options_flow"],
name: str,
validated_config: dict[str, Any],
) -> SensorGroup:
"""Create a preview sensor."""
ignore_non_numeric = (
False
if flow_type == "config_flow"
else validated_config[CONF_IGNORE_NON_NUMERIC]
)
return SensorGroup(
None,
name,
validated_config[CONF_ENTITIES],
ignore_non_numeric,
validated_config[CONF_TYPE],
None,
None,
None,
)
_async_handle_ws_preview(
hass,
connection,
msg,
SENSOR_CONFIG_SCHEMA,
await sensor_options_schema("sensor", None),
create_preview_sensor,
)

View File

@@ -41,11 +41,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, State, callback from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
EventStateChangedData,
async_track_state_change_event,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType
from . import GroupEntity from . import GroupEntity
from .util import attribute_equal, reduce_attribute from .util import attribute_equal, reduce_attribute
@@ -100,6 +96,18 @@ async def async_setup_entry(
) )
@callback
def async_create_preview_cover(
name: str, validated_config: dict[str, Any]
) -> CoverGroup:
"""Create a preview sensor."""
return CoverGroup(
None,
name,
validated_config[CONF_ENTITIES],
)
class CoverGroup(GroupEntity, CoverEntity): class CoverGroup(GroupEntity, CoverEntity):
"""Representation of a CoverGroup.""" """Representation of a CoverGroup."""
@@ -112,7 +120,7 @@ class CoverGroup(GroupEntity, CoverEntity):
def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None:
"""Initialize a CoverGroup entity.""" """Initialize a CoverGroup entity."""
self._entities = entities self._entity_ids = entities
self._covers: dict[str, set[str]] = { self._covers: dict[str, set[str]] = {
KEY_OPEN_CLOSE: set(), KEY_OPEN_CLOSE: set(),
KEY_STOP: set(), KEY_STOP: set(),
@@ -128,21 +136,11 @@ class CoverGroup(GroupEntity, CoverEntity):
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities} self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities}
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
@callback
def _update_supported_features_event(
self, event: EventType[EventStateChangedData]
) -> None:
self.async_set_context(event.context)
self.async_update_supported_features(
event.data["entity_id"], event.data["new_state"]
)
@callback @callback
def async_update_supported_features( def async_update_supported_features(
self, self,
entity_id: str, entity_id: str,
new_state: State | None, new_state: State | None,
update_state: bool = True,
) -> None: ) -> None:
"""Update dictionaries with supported features.""" """Update dictionaries with supported features."""
if not new_state: if not new_state:
@@ -150,8 +148,6 @@ class CoverGroup(GroupEntity, CoverEntity):
values.discard(entity_id) values.discard(entity_id)
for values in self._tilts.values(): for values in self._tilts.values():
values.discard(entity_id) values.discard(entity_id)
if update_state:
self.async_defer_or_update_ha_state()
return return
features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
@@ -182,25 +178,6 @@ class CoverGroup(GroupEntity, CoverEntity):
else: else:
self._tilts[KEY_POSITION].discard(entity_id) self._tilts[KEY_POSITION].discard(entity_id)
if update_state:
self.async_defer_or_update_ha_state()
async def async_added_to_hass(self) -> None:
"""Register listeners."""
for entity_id in self._entities:
if (new_state := self.hass.states.get(entity_id)) is None:
continue
self.async_update_supported_features(
entity_id, new_state, update_state=False
)
self.async_on_remove(
async_track_state_change_event(
self.hass, self._entities, self._update_supported_features_event
)
)
await super().async_added_to_hass()
async def async_open_cover(self, **kwargs: Any) -> None: async def async_open_cover(self, **kwargs: Any) -> None:
"""Move the covers up.""" """Move the covers up."""
data = {ATTR_ENTITY_ID: self._covers[KEY_OPEN_CLOSE]} data = {ATTR_ENTITY_ID: self._covers[KEY_OPEN_CLOSE]}
@@ -278,7 +255,7 @@ class CoverGroup(GroupEntity, CoverEntity):
states = [ states = [
state.state state.state
for entity_id in self._entities for entity_id in self._entity_ids
if (state := self.hass.states.get(entity_id)) is not None if (state := self.hass.states.get(entity_id)) is not None
] ]
@@ -292,7 +269,7 @@ class CoverGroup(GroupEntity, CoverEntity):
self._attr_is_closed = True self._attr_is_closed = True
self._attr_is_closing = False self._attr_is_closing = False
self._attr_is_opening = False self._attr_is_opening = False
for entity_id in self._entities: for entity_id in self._entity_ids:
if not (state := self.hass.states.get(entity_id)): if not (state := self.hass.states.get(entity_id)):
continue continue
if state.state == STATE_OPEN: if state.state == STATE_OPEN:
@@ -347,7 +324,7 @@ class CoverGroup(GroupEntity, CoverEntity):
self._attr_supported_features = supported_features self._attr_supported_features = supported_features
if not self._attr_assumed_state: if not self._attr_assumed_state:
for entity_id in self._entities: for entity_id in self._entity_ids:
if (state := self.hass.states.get(entity_id)) is None: if (state := self.hass.states.get(entity_id)) is None:
continue continue
if state and state.attributes.get(ATTR_ASSUMED_STATE): if state and state.attributes.get(ATTR_ASSUMED_STATE):

View File

@@ -0,0 +1,193 @@
"""Platform allowing several event entities to be grouped into one event."""
from __future__ import annotations
import itertools
from typing import Any
import voluptuous as vol
from homeassistant.components.event import (
ATTR_EVENT_TYPE,
ATTR_EVENT_TYPES,
DOMAIN,
PLATFORM_SCHEMA,
EventEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_FRIENDLY_NAME,
CONF_ENTITIES,
CONF_NAME,
CONF_UNIQUE_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import (
EventStateChangedData,
async_track_state_change_event,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType
from . import GroupEntity
DEFAULT_NAME = "Event group"
# No limit on parallel updates to enable a group calling another group
PARALLEL_UPDATES = 0
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
async def async_setup_platform(
_: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
__: DiscoveryInfoType | None = None,
) -> None:
"""Set up the event group platform."""
async_add_entities(
[
EventGroup(
config.get(CONF_UNIQUE_ID),
config[CONF_NAME],
config[CONF_ENTITIES],
)
]
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Initialize event group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
async_add_entities(
[
EventGroup(
config_entry.entry_id,
config_entry.title,
entities,
)
]
)
@callback
def async_create_preview_event(
name: str, validated_config: dict[str, Any]
) -> EventGroup:
"""Create a preview sensor."""
return EventGroup(
None,
name,
validated_config[CONF_ENTITIES],
)
class EventGroup(GroupEntity, EventEntity):
"""Representation of an event group."""
_attr_available = False
_attr_should_poll = False
def __init__(
self,
unique_id: str | None,
name: str,
entity_ids: list[str],
) -> None:
"""Initialize an event group."""
self._entity_ids = entity_ids
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
self._attr_event_types = []
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@callback
def async_state_changed_listener(
event: EventType[EventStateChangedData],
) -> None:
"""Handle child updates."""
if not self.hass.is_running:
return
self.async_set_context(event.context)
# Update all properties of the group
self.async_update_group_state()
# Re-fire if one of the members fires an event, but only
# if the original state was not unavailable or unknown.
if (
(old_state := event.data["old_state"])
and old_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and (new_state := event.data["new_state"])
and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and (event_type := new_state.attributes.get(ATTR_EVENT_TYPE))
):
event_attributes = new_state.attributes.copy()
# We should not propagate the event properties as
# fired event attributes.
del event_attributes[ATTR_EVENT_TYPE]
del event_attributes[ATTR_EVENT_TYPES]
event_attributes.pop(ATTR_DEVICE_CLASS, None)
event_attributes.pop(ATTR_FRIENDLY_NAME, None)
# Fire the group event
self._trigger_event(event_type, event_attributes)
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, self._entity_ids, async_state_changed_listener
)
)
await super().async_added_to_hass()
@callback
def async_update_group_state(self) -> None:
"""Query all members and determine the event group properties."""
states = [
state
for entity_id in self._entity_ids
if (state := self.hass.states.get(entity_id)) is not None
]
# None of the members are available
if not states:
self._attr_available = False
return
# Gather and combine all possible event types from all entities
self._attr_event_types = list(
set(
itertools.chain.from_iterable(
state.attributes.get(ATTR_EVENT_TYPES, []) for state in states
)
)
)
# Set group as unavailable if all members are unavailable or missing
self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states)

View File

@@ -38,11 +38,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, State, callback from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
EventStateChangedData,
async_track_state_change_event,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType
from . import GroupEntity from . import GroupEntity
from .util import ( from .util import (
@@ -100,6 +96,16 @@ async def async_setup_entry(
async_add_entities([FanGroup(config_entry.entry_id, config_entry.title, entities)]) async_add_entities([FanGroup(config_entry.entry_id, config_entry.title, entities)])
@callback
def async_create_preview_fan(name: str, validated_config: dict[str, Any]) -> FanGroup:
"""Create a preview sensor."""
return FanGroup(
None,
name,
validated_config[CONF_ENTITIES],
)
class FanGroup(GroupEntity, FanEntity): class FanGroup(GroupEntity, FanEntity):
"""Representation of a FanGroup.""" """Representation of a FanGroup."""
@@ -108,7 +114,7 @@ class FanGroup(GroupEntity, FanEntity):
def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None:
"""Initialize a FanGroup entity.""" """Initialize a FanGroup entity."""
self._entities = entities self._entity_ids = entities
self._fans: dict[int, set[str]] = {flag: set() for flag in SUPPORTED_FLAGS} self._fans: dict[int, set[str]] = {flag: set() for flag in SUPPORTED_FLAGS}
self._percentage = None self._percentage = None
self._oscillating = None self._oscillating = None
@@ -144,21 +150,11 @@ class FanGroup(GroupEntity, FanEntity):
"""Return whether or not the fan is currently oscillating.""" """Return whether or not the fan is currently oscillating."""
return self._oscillating return self._oscillating
@callback
def _update_supported_features_event(
self, event: EventType[EventStateChangedData]
) -> None:
self.async_set_context(event.context)
self.async_update_supported_features(
event.data["entity_id"], event.data["new_state"]
)
@callback @callback
def async_update_supported_features( def async_update_supported_features(
self, self,
entity_id: str, entity_id: str,
new_state: State | None, new_state: State | None,
update_state: bool = True,
) -> None: ) -> None:
"""Update dictionaries with supported features.""" """Update dictionaries with supported features."""
if not new_state: if not new_state:
@@ -172,25 +168,6 @@ class FanGroup(GroupEntity, FanEntity):
else: else:
self._fans[feature].discard(entity_id) self._fans[feature].discard(entity_id)
if update_state:
self.async_defer_or_update_ha_state()
async def async_added_to_hass(self) -> None:
"""Register listeners."""
for entity_id in self._entities:
if (new_state := self.hass.states.get(entity_id)) is None:
continue
self.async_update_supported_features(
entity_id, new_state, update_state=False
)
self.async_on_remove(
async_track_state_change_event(
self.hass, self._entities, self._update_supported_features_event
)
)
await super().async_added_to_hass()
async def async_set_percentage(self, percentage: int) -> None: async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage.""" """Set the speed of the fan, as a percentage."""
if percentage == 0: if percentage == 0:
@@ -250,7 +227,7 @@ class FanGroup(GroupEntity, FanEntity):
await self.hass.services.async_call( await self.hass.services.async_call(
DOMAIN, DOMAIN,
service, service,
{ATTR_ENTITY_ID: self._entities}, {ATTR_ENTITY_ID: self._entity_ids},
blocking=True, blocking=True,
context=self._context, context=self._context,
) )
@@ -275,7 +252,7 @@ class FanGroup(GroupEntity, FanEntity):
states = [ states = [
state state
for entity_id in self._entities for entity_id in self._entity_ids
if (state := self.hass.states.get(entity_id)) is not None if (state := self.hass.states.get(entity_id)) is not None
] ]
self._attr_assumed_state |= not states_equal(states) self._attr_assumed_state |= not states_equal(states)

View File

@@ -47,11 +47,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
EventStateChangedData,
async_track_state_change_event,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType
from . import GroupEntity from . import GroupEntity
from .util import find_state_attributes, mean_tuple, reduce_attribute from .util import find_state_attributes, mean_tuple, reduce_attribute
@@ -114,6 +110,19 @@ async def async_setup_entry(
) )
@callback
def async_create_preview_light(
name: str, validated_config: dict[str, Any]
) -> LightGroup:
"""Create a preview sensor."""
return LightGroup(
None,
name,
validated_config[CONF_ENTITIES],
validated_config.get(CONF_ALL, False),
)
FORWARDED_ATTRIBUTES = frozenset( FORWARDED_ATTRIBUTES = frozenset(
{ {
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
@@ -153,25 +162,6 @@ class LightGroup(GroupEntity, LightEntity):
if mode: if mode:
self.mode = all self.mode = all
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@callback
def async_state_changed_listener(
event: EventType[EventStateChangedData],
) -> None:
"""Handle child updates."""
self.async_set_context(event.context)
self.async_defer_or_update_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, self._entity_ids, async_state_changed_listener
)
)
await super().async_added_to_hass()
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Forward the turn_on command to all lights in the light group.""" """Forward the turn_on command to all lights in the light group."""
data = { data = {

View File

@@ -31,11 +31,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
EventStateChangedData,
async_track_state_change_event,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType
from . import GroupEntity from . import GroupEntity
@@ -94,6 +90,16 @@ async def async_setup_entry(
) )
@callback
def async_create_preview_lock(name: str, validated_config: dict[str, Any]) -> LockGroup:
"""Create a preview sensor."""
return LockGroup(
None,
name,
validated_config[CONF_ENTITIES],
)
class LockGroup(GroupEntity, LockEntity): class LockGroup(GroupEntity, LockEntity):
"""Representation of a lock group.""" """Representation of a lock group."""
@@ -114,25 +120,6 @@ class LockGroup(GroupEntity, LockEntity):
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@callback
def async_state_changed_listener(
event: EventType[EventStateChangedData],
) -> None:
"""Handle child updates."""
self.async_set_context(event.context)
self.async_defer_or_update_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, self._entity_ids, async_state_changed_listener
)
)
await super().async_added_to_hass()
async def async_lock(self, **kwargs: Any) -> None: async def async_lock(self, **kwargs: Any) -> None:
"""Forward the lock command to all locks in the group.""" """Forward the lock command to all locks in the group."""
data = {ATTR_ENTITY_ID: self._entity_ids} data = {ATTR_ENTITY_ID: self._entity_ids}

View File

@@ -1,7 +1,7 @@
"""Platform allowing several media players to be grouped into one media player.""" """Platform allowing several media players to be grouped into one media player."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Callable, Mapping
from contextlib import suppress from contextlib import suppress
from typing import Any from typing import Any
@@ -44,7 +44,7 @@ from homeassistant.const import (
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
STATE_UNKNOWN, STATE_UNKNOWN,
) )
from homeassistant.core import HomeAssistant, State, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import ( from homeassistant.helpers.event import (
@@ -107,6 +107,18 @@ async def async_setup_entry(
) )
@callback
def async_create_preview_media_player(
name: str, validated_config: dict[str, Any]
) -> MediaPlayerGroup:
"""Create a preview sensor."""
return MediaPlayerGroup(
None,
name,
validated_config[CONF_ENTITIES],
)
class MediaPlayerGroup(MediaPlayerEntity): class MediaPlayerGroup(MediaPlayerEntity):
"""Representation of a Media Group.""" """Representation of a Media Group."""
@@ -139,7 +151,8 @@ class MediaPlayerGroup(MediaPlayerEntity):
self.async_update_supported_features( self.async_update_supported_features(
event.data["entity_id"], event.data["new_state"] event.data["entity_id"], event.data["new_state"]
) )
self.async_update_state() self.async_update_group_state()
self.async_write_ha_state()
@callback @callback
def async_update_supported_features( def async_update_supported_features(
@@ -208,6 +221,26 @@ class MediaPlayerGroup(MediaPlayerEntity):
else: else:
self._features[KEY_ENQUEUE].discard(entity_id) self._features[KEY_ENQUEUE].discard(entity_id)
@callback
def async_start_preview(
self,
preview_callback: Callable[[str, Mapping[str, Any]], None],
) -> CALLBACK_TYPE:
"""Render a preview."""
@callback
def async_state_changed_listener(
event: EventType[EventStateChangedData] | None,
) -> None:
"""Handle child updates."""
self.async_update_group_state()
preview_callback(*self._async_generate_attributes())
async_state_changed_listener(None)
return async_track_state_change_event(
self.hass, self._entities, async_state_changed_listener
)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Register listeners.""" """Register listeners."""
for entity_id in self._entities: for entity_id in self._entities:
@@ -216,7 +249,8 @@ class MediaPlayerGroup(MediaPlayerEntity):
async_track_state_change_event( async_track_state_change_event(
self.hass, self._entities, self.async_on_state_change self.hass, self._entities, self.async_on_state_change
) )
self.async_update_state() self.async_update_group_state()
self.async_write_ha_state()
@property @property
def name(self) -> str: def name(self) -> str:
@@ -391,7 +425,7 @@ class MediaPlayerGroup(MediaPlayerEntity):
await self.async_set_volume_level(max(0, volume_level - 0.1)) await self.async_set_volume_level(max(0, volume_level - 0.1))
@callback @callback
def async_update_state(self) -> None: def async_update_group_state(self) -> None:
"""Query all members and determine the media group state.""" """Query all members and determine the media group state."""
states = [ states = [
state.state state.state
@@ -455,4 +489,3 @@ class MediaPlayerGroup(MediaPlayerEntity):
supported_features |= MediaPlayerEntityFeature.MEDIA_ENQUEUE supported_features |= MediaPlayerEntityFeature.MEDIA_ENQUEUE
self._attr_supported_features = supported_features self._attr_supported_features = supported_features
self.async_write_ha_state()

View File

@@ -1,7 +1,7 @@
"""Platform allowing several sensors to be grouped into one sensor to provide numeric combinations.""" """Platform allowing several sensors to be grouped into one sensor to provide numeric combinations."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Mapping from collections.abc import Callable
from datetime import datetime from datetime import datetime
import logging import logging
import statistics import statistics
@@ -33,19 +33,10 @@ from homeassistant.const import (
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
STATE_UNKNOWN, STATE_UNKNOWN,
) )
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
EventStateChangedData,
async_track_state_change_event,
)
from homeassistant.helpers.typing import (
ConfigType,
DiscoveryInfoType,
EventType,
StateType,
)
from . import GroupEntity from . import GroupEntity
from .const import CONF_IGNORE_NON_NUMERIC from .const import CONF_IGNORE_NON_NUMERIC
@@ -145,6 +136,23 @@ async def async_setup_entry(
) )
@callback
def async_create_preview_sensor(
name: str, validated_config: dict[str, Any]
) -> SensorGroup:
"""Create a preview sensor."""
return SensorGroup(
None,
name,
validated_config[CONF_ENTITIES],
validated_config.get(CONF_IGNORE_NON_NUMERIC, False),
validated_config[CONF_TYPE],
None,
None,
None,
)
def calc_min( def calc_min(
sensor_values: list[tuple[str, float, State]] sensor_values: list[tuple[str, float, State]]
) -> tuple[dict[str, str | None], float | None]: ) -> tuple[dict[str, str | None], float | None]:
@@ -303,45 +311,6 @@ class SensorGroup(GroupEntity, SensorEntity):
self._state_incorrect: set[str] = set() self._state_incorrect: set[str] = set()
self._extra_state_attribute: dict[str, Any] = {} self._extra_state_attribute: dict[str, Any] = {}
@callback
def async_start_preview(
self,
preview_callback: Callable[[str, Mapping[str, Any]], None],
) -> CALLBACK_TYPE:
"""Render a preview."""
@callback
def async_state_changed_listener(
event: EventType[EventStateChangedData] | None,
) -> None:
"""Handle child updates."""
self.async_update_group_state()
preview_callback(*self._async_generate_attributes())
async_state_changed_listener(None)
return async_track_state_change_event(
self.hass, self._entity_ids, async_state_changed_listener
)
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@callback
def async_state_changed_listener(
event: EventType[EventStateChangedData],
) -> None:
"""Handle child updates."""
self.async_set_context(event.context)
self.async_defer_or_update_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, self._entity_ids, async_state_changed_listener
)
)
await super().async_added_to_hass()
@callback @callback
def async_update_group_state(self) -> None: def async_update_group_state(self) -> None:
"""Query all members and determine the sensor group state.""" """Query all members and determine the sensor group state."""

View File

@@ -8,6 +8,7 @@
"menu_options": { "menu_options": {
"binary_sensor": "Binary sensor group", "binary_sensor": "Binary sensor group",
"cover": "Cover group", "cover": "Cover group",
"event": "Event group",
"fan": "Fan group", "fan": "Fan group",
"light": "Light group", "light": "Light group",
"lock": "Lock group", "lock": "Lock group",
@@ -34,6 +35,14 @@
"name": "[%key:component::group::config::step::binary_sensor::data::name%]" "name": "[%key:component::group::config::step::binary_sensor::data::name%]"
} }
}, },
"event": {
"title": "[%key:component::group::config::step::user::title%]",
"data": {
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]",
"name": "[%key:component::group::config::step::binary_sensor::data::name%]"
}
},
"fan": { "fan": {
"title": "[%key:component::group::config::step::user::title%]", "title": "[%key:component::group::config::step::user::title%]",
"data": { "data": {

View File

@@ -22,11 +22,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
EventStateChangedData,
async_track_state_change_event,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType
from . import GroupEntity from . import GroupEntity
@@ -89,6 +85,19 @@ async def async_setup_entry(
) )
@callback
def async_create_preview_switch(
name: str, validated_config: dict[str, Any]
) -> SwitchGroup:
"""Create a preview sensor."""
return SwitchGroup(
None,
name,
validated_config[CONF_ENTITIES],
validated_config.get(CONF_ALL, False),
)
class SwitchGroup(GroupEntity, SwitchEntity): class SwitchGroup(GroupEntity, SwitchEntity):
"""Representation of a switch group.""" """Representation of a switch group."""
@@ -112,25 +121,6 @@ class SwitchGroup(GroupEntity, SwitchEntity):
if mode: if mode:
self.mode = all self.mode = all
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@callback
def async_state_changed_listener(
event: EventType[EventStateChangedData],
) -> None:
"""Handle child updates."""
self.async_set_context(event.context)
self.async_defer_or_update_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, self._entity_ids, async_state_changed_listener
)
)
await super().async_added_to_hass()
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Forward the turn_on command to all switches in the group.""" """Forward the turn_on command to all switches in the group."""
data = {ATTR_ENTITY_ID: self._entity_ids} data = {ATTR_ENTITY_ID: self._entity_ids}

View File

@@ -505,7 +505,6 @@ def setup_platform(
joined_path = os.path.join(gtfs_dir, sqlite_file) joined_path = os.path.join(gtfs_dir, sqlite_file)
gtfs = pygtfs.Schedule(joined_path) gtfs = pygtfs.Schedule(joined_path)
# pylint: disable=no-member
if not gtfs.feeds: if not gtfs.feeds:
pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data)) pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data))

View File

@@ -82,7 +82,6 @@ NO_STORE = re.compile(
r"|app/entrypoint.js" r"|app/entrypoint.js"
r")$" r")$"
) )
# pylint: enable=implicit-str-concat
# fmt: on # fmt: on
RESPONSE_HEADERS_FILTER = { RESPONSE_HEADERS_FILTER = {

View File

@@ -41,7 +41,6 @@ SCHEMA_WEBSOCKET_EVENT = vol.Schema(
) )
# Endpoints needed for ingress can't require admin because addons can set `panel_admin: false` # Endpoints needed for ingress can't require admin because addons can set `panel_admin: false`
# pylint: disable=implicit-str-concat
# fmt: off # fmt: off
WS_NO_ADMIN_ENDPOINTS = re.compile( WS_NO_ADMIN_ENDPOINTS = re.compile(
r"^(?:" r"^(?:"
@@ -50,7 +49,6 @@ WS_NO_ADMIN_ENDPOINTS = re.compile(
r")$" # noqa: ISC001 r")$" # noqa: ISC001
) )
# fmt: on # fmt: on
# pylint: enable=implicit-str-concat
_LOGGER: logging.Logger = logging.getLogger(__package__) _LOGGER: logging.Logger = logging.getLogger(__package__)

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