Merge branch 'dev' into feature/starlink-device-tracker

This commit is contained in:
Jack Boswell
2023-08-19 15:26:06 +12:00
committed by GitHub
613 changed files with 12266 additions and 3469 deletions

View File

@@ -168,6 +168,10 @@ omit =
homeassistant/components/cmus/media_player.py
homeassistant/components/coinbase/sensor.py
homeassistant/components/comed_hourly_pricing/sensor.py
homeassistant/components/comelit/__init__.py
homeassistant/components/comelit/const.py
homeassistant/components/comelit/coordinator.py
homeassistant/components/comelit/light.py
homeassistant/components/comfoconnect/fan.py
homeassistant/components/concord232/alarm_control_panel.py
homeassistant/components/concord232/binary_sensor.py
@@ -212,8 +216,9 @@ omit =
homeassistant/components/dominos/*
homeassistant/components/doods/*
homeassistant/components/doorbird/__init__.py
homeassistant/components/doorbird/button.py
homeassistant/components/doorbird/camera.py
homeassistant/components/doorbird/button.py
homeassistant/components/doorbird/device.py
homeassistant/components/doorbird/entity.py
homeassistant/components/doorbird/util.py
homeassistant/components/dormakaba_dkey/__init__.py
@@ -305,6 +310,8 @@ omit =
homeassistant/components/enphase_envoy/binary_sensor.py
homeassistant/components/enphase_envoy/coordinator.py
homeassistant/components/enphase_envoy/entity.py
homeassistant/components/enphase_envoy/number.py
homeassistant/components/enphase_envoy/select.py
homeassistant/components/enphase_envoy/sensor.py
homeassistant/components/enphase_envoy/switch.py
homeassistant/components/entur_public_transport/*
@@ -341,6 +348,7 @@ omit =
homeassistant/components/ezviz/entity.py
homeassistant/components/ezviz/select.py
homeassistant/components/ezviz/sensor.py
homeassistant/components/ezviz/siren.py
homeassistant/components/ezviz/switch.py
homeassistant/components/ezviz/update.py
homeassistant/components/faa_delays/__init__.py
@@ -1325,9 +1333,6 @@ omit =
homeassistant/components/tplink_omada/__init__.py
homeassistant/components/tplink_omada/binary_sensor.py
homeassistant/components/tplink_omada/controller.py
homeassistant/components/tplink_omada/coordinator.py
homeassistant/components/tplink_omada/entity.py
homeassistant/components/tplink_omada/switch.py
homeassistant/components/tplink_omada/update.py
homeassistant/components/traccar/device_tracker.py
homeassistant/components/tractive/__init__.py

View File

@@ -734,9 +734,14 @@ jobs:
- name: Run pytest (fully)
if: needs.info.outputs.test_full_suite == 'true'
timeout-minutes: 60
id: pytest-full
env:
PYTHONDONTWRITEBYTECODE: 1
run: |
. venv/bin/activate
python --version
set -o pipefail
python3 -X dev -m pytest \
-qq \
--timeout=9 \
@@ -749,14 +754,19 @@ jobs:
--cov-report=xml \
-o console_output_style=count \
-p no:sugar \
tests
tests \
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Run pytest (partially)
if: needs.info.outputs.test_full_suite == 'false'
timeout-minutes: 10
id: pytest-partial
shell: bash
env:
PYTHONDONTWRITEBYTECODE: 1
run: |
. venv/bin/activate
python --version
set -o pipefail
if [[ ! -f "tests/components/${{ matrix.group }}/__init__.py" ]]; then
echo "::error:: missing file tests/components/${{ matrix.group }}/__init__.py"
@@ -774,7 +784,14 @@ jobs:
--durations=0 \
--durations-min=1 \
-p no:sugar \
tests/components/${{ matrix.group }}
tests/components/${{ matrix.group }} \
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output
if: success() || failure() && (steps.pytest-full.conclusion == 'failure' || steps.pytest-partial.conclusion == 'failure')
uses: actions/upload-artifact@v3.1.2
with:
name: pytest-${{ github.run_number }}
path: pytest-*.txt
- name: Upload coverage artifact
uses: actions/upload-artifact@v3.1.2
with:
@@ -862,10 +879,15 @@ jobs:
python3 -m script.translations develop --all
- name: Run pytest (partially)
timeout-minutes: 20
id: pytest-partial
shell: bash
env:
PYTHONDONTWRITEBYTECODE: 1
run: |
. venv/bin/activate
python --version
set -o pipefail
mariadb=$(echo "${{ matrix.mariadb-group }}" | sed "s/:/-/g")
python3 -X dev -m pytest \
-qq \
@@ -881,7 +903,14 @@ jobs:
tests/components/history \
tests/components/logbook \
tests/components/recorder \
tests/components/sensor
tests/components/sensor \
2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v3.1.2
with:
name: pytest-${{ github.run_number }}
path: pytest-*.txt
- name: Upload coverage artifact
uses: actions/upload-artifact@v3.1.2
with:
@@ -969,10 +998,15 @@ jobs:
python3 -m script.translations develop --all
- name: Run pytest (partially)
timeout-minutes: 20
id: pytest-partial
shell: bash
env:
PYTHONDONTWRITEBYTECODE: 1
run: |
. venv/bin/activate
python --version
set -o pipefail
postgresql=$(echo "${{ matrix.postgresql-group }}" | sed "s/:/-/g")
python3 -X dev -m pytest \
-qq \
@@ -989,7 +1023,14 @@ jobs:
tests/components/history \
tests/components/logbook \
tests/components/recorder \
tests/components/sensor
tests/components/sensor \
2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v3.1.2
with:
name: pytest-${{ github.run_number }}
path: pytest-*.txt
- name: Upload coverage artifact
uses: actions/upload-artifact@v3.1.0
with:

1
.gitignore vendored
View File

@@ -67,6 +67,7 @@ htmlcov/
test-reports/
test-results.xml
test-output.xml
pytest-*.txt
# Translations
*.mo

View File

@@ -209,6 +209,8 @@ build.json @home-assistant/supervisor
/tests/components/coinbase/ @tombrien
/homeassistant/components/color_extractor/ @GenericStudent
/tests/components/color_extractor/ @GenericStudent
/homeassistant/components/comelit/ @chemelli74
/tests/components/comelit/ @chemelli74
/homeassistant/components/comfoconnect/ @michaelarnauts
/tests/components/comfoconnect/ @michaelarnauts
/homeassistant/components/command_line/ @gjohansson-ST
@@ -606,8 +608,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard
/tests/components/iotawatt/ @gtdiehl @jyavenard
/homeassistant/components/iperf3/ @rohankapoorcom
/homeassistant/components/ipma/ @dgomes @abmantis
/tests/components/ipma/ @dgomes @abmantis
/homeassistant/components/ipma/ @dgomes
/tests/components/ipma/ @dgomes
/homeassistant/components/ipp/ @ctalkington
/tests/components/ipp/ @ctalkington
/homeassistant/components/iqvia/ @bachya

View File

@@ -110,8 +110,7 @@ async def async_setup_hass(
runtime_config: RuntimeConfig,
) -> core.HomeAssistant | None:
"""Set up Home Assistant."""
hass = core.HomeAssistant()
hass.config.config_dir = runtime_config.config_dir
hass = core.HomeAssistant(runtime_config.config_dir)
async_enable_logging(
hass,
@@ -134,6 +133,7 @@ async def async_setup_hass(
_LOGGER.info("Config directory: %s", runtime_config.config_dir)
loader.async_setup(hass)
config_dict = None
basic_setup_success = False
@@ -177,14 +177,15 @@ async def async_setup_hass(
old_config = hass.config
old_logging = hass.data.get(DATA_LOGGING)
hass = core.HomeAssistant()
hass = core.HomeAssistant(old_config.config_dir)
if old_logging:
hass.data[DATA_LOGGING] = old_logging
hass.config.skip_pip = old_config.skip_pip
hass.config.skip_pip_packages = old_config.skip_pip_packages
hass.config.internal_url = old_config.internal_url
hass.config.external_url = old_config.external_url
hass.config.config_dir = old_config.config_dir
# Setup loader cache after the config dir has been set
loader.async_setup(hass)
if safe_mode:
_LOGGER.info("Starting in safe mode")

View File

@@ -1,6 +1,7 @@
"""The AccuWeather component."""
from __future__ import annotations
from asyncio import timeout
from datetime import timedelta
import logging
from typing import Any
@@ -8,7 +9,6 @@ from typing import Any
from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError
from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectorError
from async_timeout import timeout
from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM
from homeassistant.config_entries import ConfigEntry

View File

@@ -2,12 +2,12 @@
from __future__ import annotations
import asyncio
from asyncio import timeout
from typing import Any
from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError
from aiohttp import ClientError
from aiohttp.client_exceptions import ClientConnectorError
from async_timeout import timeout
import voluptuous as vol
from homeassistant import config_entries

View File

@@ -50,3 +50,8 @@ CONDITION_CLASSES: Final[dict[str, list[int]]] = {
ATTR_CONDITION_SUNNY: [1, 2, 5],
ATTR_CONDITION_WINDY: [32],
}
CONDITION_MAP = {
cond_code: cond_ha
for cond_ha, cond_codes in CONDITION_CLASSES.items()
for cond_code in cond_codes
}

View File

@@ -40,7 +40,7 @@ from .const import (
ATTR_SPEED,
ATTR_VALUE,
ATTRIBUTION,
CONDITION_CLASSES,
CONDITION_MAP,
DOMAIN,
)
@@ -80,14 +80,7 @@ class AccuWeatherEntity(
@property
def condition(self) -> str | None:
"""Return the current condition."""
try:
return [
k
for k, v in CONDITION_CLASSES.items()
if self.coordinator.data["WeatherIcon"] in v
][0]
except IndexError:
return None
return CONDITION_MAP.get(self.coordinator.data["WeatherIcon"])
@property
def cloud_coverage(self) -> float:
@@ -177,9 +170,7 @@ class AccuWeatherEntity(
],
ATTR_FORECAST_UV_INDEX: item["UVIndex"][ATTR_VALUE],
ATTR_FORECAST_WIND_BEARING: item["WindDay"][ATTR_DIRECTION]["Degrees"],
ATTR_FORECAST_CONDITION: [
k for k, v in CONDITION_CLASSES.items() if item["IconDay"] in v
][0],
ATTR_FORECAST_CONDITION: CONDITION_MAP.get(item["IconDay"]),
}
for item in self.coordinator.data[ATTR_FORECAST]
]

View File

@@ -2,11 +2,11 @@
from __future__ import annotations
import asyncio
from asyncio import timeout
from contextlib import suppress
from typing import Any
import aiopulse
import async_timeout
import voluptuous as vol
from homeassistant import config_entries
@@ -43,7 +43,7 @@ class AcmedaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
hubs: list[aiopulse.Hub] = []
with suppress(asyncio.TimeoutError):
async with async_timeout.timeout(5):
async with timeout(5):
async for hub in aiopulse.Hub.discover():
if hub.id not in already_configured:
hubs.append(hub)

View File

@@ -1,12 +1,12 @@
"""Support for Automation Device Specification (ADS)."""
import asyncio
from asyncio import timeout
from collections import namedtuple
import ctypes
import logging
import struct
import threading
import async_timeout
import pyads
import voluptuous as vol
@@ -301,7 +301,7 @@ class AdsEntity(Entity):
self._ads_hub.add_device_notification, ads_var, plctype, update
)
try:
async with async_timeout.timeout(10):
async with timeout(10):
await self._event.wait()
except asyncio.TimeoutError:
_LOGGER.debug("Variable %s: Timeout during first update", ads_var)

View File

@@ -1,4 +1,6 @@
"""Support for the AEMET OpenData service."""
from typing import cast
from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_NATIVE_PRECIPITATION,
@@ -8,7 +10,10 @@ from homeassistant.components.weather import (
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
DOMAIN as WEATHER_DOMAIN,
Forecast,
WeatherEntity,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -17,7 +22,8 @@ from homeassistant.const import (
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -79,10 +85,28 @@ async def async_setup_entry(
weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR]
entities = []
for mode in FORECAST_MODES:
name = f"{domain_data[ENTRY_NAME]} {mode}"
unique_id = f"{config_entry.unique_id} {mode}"
entities.append(AemetWeather(name, unique_id, weather_coordinator, mode))
entity_registry = er.async_get(hass)
# Add daily + hourly entity for legacy config entries, only add daily for new
# config entries. This can be removed in HA Core 2024.3
if entity_registry.async_get_entity_id(
WEATHER_DOMAIN,
DOMAIN,
f"{config_entry.unique_id} {FORECAST_MODE_HOURLY}",
):
for mode in FORECAST_MODES:
name = f"{domain_data[ENTRY_NAME]} {mode}"
unique_id = f"{config_entry.unique_id} {mode}"
entities.append(AemetWeather(name, unique_id, weather_coordinator, mode))
else:
entities.append(
AemetWeather(
domain_data[ENTRY_NAME],
config_entry.unique_id,
weather_coordinator,
FORECAST_MODE_DAILY,
)
)
async_add_entities(entities, False)
@@ -95,6 +119,9 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity):
_attr_native_pressure_unit = UnitOfPressure.HPA
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
_attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
)
def __init__(
self,
@@ -112,20 +139,44 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity):
self._attr_name = name
self._attr_unique_id = unique_id
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
super()._handle_coordinator_update()
assert self.platform.config_entry
self.platform.config_entry.async_create_task(
self.hass, self.async_update_listeners(("daily", "hourly"))
)
@property
def condition(self):
"""Return the current condition."""
return self.coordinator.data[ATTR_API_CONDITION]
@property
def forecast(self):
def _forecast(self, forecast_mode: str) -> list[Forecast]:
"""Return the forecast array."""
forecasts = self.coordinator.data[FORECAST_MODE_ATTR_API[self._forecast_mode]]
forecast_map = FORECAST_MAP[self._forecast_mode]
return [
{ha_key: forecast[api_key] for api_key, ha_key in forecast_map.items()}
for forecast in forecasts
]
forecasts = self.coordinator.data[FORECAST_MODE_ATTR_API[forecast_mode]]
forecast_map = FORECAST_MAP[forecast_mode]
return cast(
list[Forecast],
[
{ha_key: forecast[api_key] for api_key, ha_key in forecast_map.items()}
for forecast in forecasts
],
)
@property
def forecast(self) -> list[Forecast]:
"""Return the forecast array."""
return self._forecast(self._forecast_mode)
async def async_forecast_daily(self) -> list[Forecast]:
"""Return the daily forecast in native units."""
return self._forecast(FORECAST_MODE_DAILY)
async def async_forecast_hourly(self) -> list[Forecast]:
"""Return the hourly forecast in native units."""
return self._forecast(FORECAST_MODE_HOURLY)
@property
def humidity(self):

View File

@@ -1,6 +1,7 @@
"""Weather data coordinator for the AEMET OpenData service."""
from __future__ import annotations
from asyncio import timeout
from dataclasses import dataclass, field
from datetime import timedelta
import logging
@@ -41,7 +42,6 @@ from aemet_opendata.helpers import (
get_forecast_hour_value,
get_forecast_interval_value,
)
import async_timeout
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
@@ -139,7 +139,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
async def _async_update_data(self):
data = {}
async with async_timeout.timeout(120):
async with timeout(120):
weather_response = await self._get_aemet_weather()
data = self._convert_weather_response(weather_response)
return data

View File

@@ -1,6 +1,7 @@
"""The Airly integration."""
from __future__ import annotations
from asyncio import timeout
from datetime import timedelta
import logging
from math import ceil
@@ -9,7 +10,6 @@ from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectorError
from airly import Airly
from airly.exceptions import AirlyError
import async_timeout
from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM
from homeassistant.config_entries import ConfigEntry
@@ -167,7 +167,7 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator):
measurements = self.airly.create_measurements_session_point(
self.latitude, self.longitude
)
async with async_timeout.timeout(20):
async with timeout(20):
try:
await measurements.update()
except (AirlyError, ClientConnectorError) as error:

View File

@@ -1,13 +1,13 @@
"""Adds config flow for Airly."""
from __future__ import annotations
from asyncio import timeout
from http import HTTPStatus
from typing import Any
from aiohttp import ClientSession
from airly import Airly
from airly.exceptions import AirlyError
import async_timeout
import voluptuous as vol
from homeassistant import config_entries
@@ -105,7 +105,7 @@ async def test_location(
measurements = airly.create_measurements_session_point(
latitude=latitude, longitude=longitude
)
async with async_timeout.timeout(10):
async with timeout(10):
await measurements.update()
current = measurements.current

View File

@@ -1,13 +1,13 @@
"""The Airzone integration."""
from __future__ import annotations
from asyncio import timeout
from datetime import timedelta
import logging
from typing import Any
from aioairzone.exceptions import AirzoneError
from aioairzone.localapi import AirzoneLocalApi
import async_timeout
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -35,7 +35,7 @@ class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
async with async_timeout.timeout(AIOAIRZONE_DEVICE_TIMEOUT_SEC):
async with timeout(AIOAIRZONE_DEVICE_TIMEOUT_SEC):
try:
await self.airzone.update()
except AirzoneError as error:

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==0.6.5"]
"requirements": ["aioairzone==0.6.6"]
}

View File

@@ -1,13 +1,13 @@
"""The Airzone Cloud integration coordinator."""
from __future__ import annotations
from asyncio import timeout
from datetime import timedelta
import logging
from typing import Any
from aioairzone_cloud.cloudapi import AirzoneCloudApi
from aioairzone_cloud.exceptions import AirzoneCloudError
import async_timeout
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -35,7 +35,7 @@ class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
async with async_timeout.timeout(AIOAIRZONE_CLOUD_TIMEOUT_SEC):
async with timeout(AIOAIRZONE_CLOUD_TIMEOUT_SEC):
try:
await self.airzone.update()
except AirzoneCloudError as error:

View File

@@ -1,5 +1,6 @@
"""Support for Alexa skill auth."""
import asyncio
from asyncio import timeout
from datetime import datetime, timedelta
from http import HTTPStatus
import json
@@ -7,7 +8,6 @@ import logging
from typing import Any
import aiohttp
import async_timeout
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import HomeAssistant, callback
@@ -113,7 +113,7 @@ class Auth:
async def _async_request_new_token(self, lwa_params: dict[str, str]) -> str | None:
try:
session = aiohttp_client.async_get_clientsession(self.hass)
async with async_timeout.timeout(10):
async with timeout(10):
response = await session.post(
LWA_TOKEN_URI,
headers=LWA_HEADERS,

View File

@@ -474,7 +474,24 @@ async def async_api_unlock(
context: ha.Context,
) -> AlexaResponse:
"""Process an unlock request."""
if config.locale not in {"de-DE", "en-US", "ja-JP"}:
if config.locale not in {
"ar-SA",
"de-DE",
"en-AU",
"en-CA",
"en-GB",
"en-IN",
"en-US",
"es-ES",
"es-MX",
"es-US",
"fr-CA",
"fr-FR",
"hi-IN",
"it-IT",
"ja-JP",
"pt-BR",
}:
msg = (
"The unlock directive is not supported for the following locales:"
f" {config.locale}"

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from asyncio import timeout
from http import HTTPStatus
import json
import logging
@@ -10,7 +11,6 @@ from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
import aiohttp
import async_timeout
from homeassistant.components import event
from homeassistant.const import MATCH_ALL, STATE_ON
@@ -364,7 +364,7 @@ async def async_send_changereport_message(
assert config.endpoint is not None
try:
async with async_timeout.timeout(DEFAULT_TIMEOUT):
async with timeout(DEFAULT_TIMEOUT):
response = await session.post(
config.endpoint,
headers=headers,
@@ -517,7 +517,7 @@ async def async_send_doorbell_event_message(
assert config.endpoint is not None
try:
async with async_timeout.timeout(DEFAULT_TIMEOUT):
async with timeout(DEFAULT_TIMEOUT):
response = await session.post(
config.endpoint,
headers=headers,

View File

@@ -2,13 +2,13 @@
from __future__ import annotations
import asyncio
from asyncio import timeout
from dataclasses import asdict as dataclass_asdict, dataclass
from datetime import datetime
from typing import Any
import uuid
import aiohttp
import async_timeout
from homeassistant.components import hassio
from homeassistant.components.api import ATTR_INSTALLATION_TYPE
@@ -313,7 +313,7 @@ class Analytics:
)
try:
async with async_timeout.timeout(30):
async with timeout(30):
response = await self.session.post(self.endpoint, json=payload)
if response.status == 200:
LOGGER.info(

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from asyncio import timeout
import logging
from androidtvremote2 import (
@@ -10,7 +11,6 @@ from androidtvremote2 import (
ConnectionClosed,
InvalidAuth,
)
import async_timeout
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform
@@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
api.add_is_available_updated_callback(is_available_updated)
try:
async with async_timeout.timeout(5.0):
async with timeout(5.0):
await api.async_connect()
except InvalidAuth as exc:
# The Android TV is hard reset or the certificate and key files were deleted.

View File

@@ -1,9 +1,9 @@
"""Support for Anova Coordinators."""
from asyncio import timeout
from datetime import timedelta
import logging
from anova_wifi import AnovaOffline, AnovaPrecisionCooker, APCUpdate
import async_timeout
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
@@ -47,7 +47,7 @@ class AnovaCoordinator(DataUpdateCoordinator[APCUpdate]):
async def _async_update_data(self) -> APCUpdate:
try:
async with async_timeout.timeout(5):
async with timeout(5):
return await self.anova_device.update()
except AnovaOffline as err:
raise UpdateFailed(err) from err

View File

@@ -1,17 +1,17 @@
"""Rest API for Home Assistant."""
import asyncio
from asyncio import timeout
from functools import lru_cache
from http import HTTPStatus
import logging
from aiohttp import web
from aiohttp.web_exceptions import HTTPBadRequest
import async_timeout
import voluptuous as vol
from homeassistant.auth.permissions.const import POLICY_READ
from homeassistant.bootstrap import DATA_LOGGING
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http import HomeAssistantView, require_admin
from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
MATCH_ALL,
@@ -110,10 +110,9 @@ class APIEventStream(HomeAssistantView):
url = URL_API_STREAM
name = "api:stream"
@require_admin
async def get(self, request):
"""Provide a streaming interface for the event bus."""
if not request["hass_user"].is_admin:
raise Unauthorized()
hass = request.app["hass"]
stop_obj = object()
to_write = asyncio.Queue()
@@ -149,7 +148,7 @@ class APIEventStream(HomeAssistantView):
while True:
try:
async with async_timeout.timeout(STREAM_PING_INTERVAL):
async with timeout(STREAM_PING_INTERVAL):
payload = await to_write.get()
if payload is stop_obj:
@@ -278,10 +277,9 @@ class APIEventView(HomeAssistantView):
url = "/api/events/{event_type}"
name = "api:event"
@require_admin
async def post(self, request, event_type):
"""Fire events."""
if not request["hass_user"].is_admin:
raise Unauthorized()
body = await request.text()
try:
event_data = json_loads(body) if body else None
@@ -385,10 +383,9 @@ class APITemplateView(HomeAssistantView):
url = URL_API_TEMPLATE
name = "api:template"
@require_admin
async def post(self, request):
"""Render a template."""
if not request["hass_user"].is_admin:
raise Unauthorized()
try:
data = await request.json()
tpl = _cached_template(data["template"], request.app["hass"])
@@ -405,10 +402,9 @@ class APIErrorLog(HomeAssistantView):
url = URL_API_ERROR_LOG
name = "api:error_log"
@require_admin
async def get(self, request):
"""Retrieve API error log."""
if not request["hass_user"].is_admin:
raise Unauthorized()
return web.FileResponse(request.app["hass"].data[DATA_LOGGING])

View File

@@ -1,11 +1,11 @@
"""Arcam component."""
import asyncio
from asyncio import timeout
import logging
from typing import Any
from arcam.fmj import ConnectionFailed
from arcam.fmj.client import Client
import async_timeout
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
@@ -66,7 +66,7 @@ async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> N
while True:
try:
async with async_timeout.timeout(interval):
async with timeout(interval):
await client.start()
_LOGGER.debug("Client connected %s", client.host)

View File

@@ -1,10 +1,11 @@
"""Arcam media player."""
from __future__ import annotations
import functools
import logging
from typing import Any
from arcam.fmj import SourceCodes
from arcam.fmj import ConnectionFailed, SourceCodes
from arcam.fmj.state import State
from homeassistant.components.media_player import (
@@ -19,6 +20,7 @@ from homeassistant.components.media_player.errors import BrowseError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -57,6 +59,21 @@ async def async_setup_entry(
)
def convert_exception(func):
"""Return decorator to convert a connection error into a home assistant error."""
@functools.wraps(func)
async def _convert_exception(*args, **kwargs):
try:
return await func(*args, **kwargs)
except ConnectionFailed as exception:
raise HomeAssistantError(
f"Connection failed to device during {func}"
) from exception
return _convert_exception
class ArcamFmj(MediaPlayerEntity):
"""Representation of a media device."""
@@ -105,7 +122,10 @@ class ArcamFmj(MediaPlayerEntity):
async def async_added_to_hass(self) -> None:
"""Once registered, add listener for events."""
await self._state.start()
await self._state.update()
try:
await self._state.update()
except ConnectionFailed as connection:
_LOGGER.debug("Connection lost during addition: %s", connection)
@callback
def _data(host: str) -> None:
@@ -137,13 +157,18 @@ class ArcamFmj(MediaPlayerEntity):
async def async_update(self) -> None:
"""Force update of state."""
_LOGGER.debug("Update state %s", self.name)
await self._state.update()
try:
await self._state.update()
except ConnectionFailed as connection:
_LOGGER.debug("Connection lost during update: %s", connection)
@convert_exception
async def async_mute_volume(self, mute: bool) -> None:
"""Send mute command."""
await self._state.set_mute(mute)
self.async_write_ha_state()
@convert_exception
async def async_select_source(self, source: str) -> None:
"""Select a specific source."""
try:
@@ -155,31 +180,37 @@ class ArcamFmj(MediaPlayerEntity):
await self._state.set_source(value)
self.async_write_ha_state()
@convert_exception
async def async_select_sound_mode(self, sound_mode: str) -> None:
"""Select a specific source."""
try:
await self._state.set_decode_mode(sound_mode)
except (KeyError, ValueError):
_LOGGER.error("Unsupported sound_mode %s", sound_mode)
return
except (KeyError, ValueError) as exception:
raise HomeAssistantError(
f"Unsupported sound_mode {sound_mode}"
) from exception
self.async_write_ha_state()
@convert_exception
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
await self._state.set_volume(round(volume * 99.0))
self.async_write_ha_state()
@convert_exception
async def async_volume_up(self) -> None:
"""Turn volume up for media player."""
await self._state.inc_volume()
self.async_write_ha_state()
@convert_exception
async def async_volume_down(self) -> None:
"""Turn volume up for media player."""
await self._state.dec_volume()
self.async_write_ha_state()
@convert_exception
async def async_turn_on(self) -> None:
"""Turn the media player on."""
if self._state.get_power() is not None:
@@ -189,6 +220,7 @@ class ArcamFmj(MediaPlayerEntity):
_LOGGER.debug("Firing event to turn on device")
self.hass.bus.async_fire(EVENT_TURN_ON, {ATTR_ENTITY_ID: self.entity_id})
@convert_exception
async def async_turn_off(self) -> None:
"""Turn the media player off."""
await self._state.set_power(False)
@@ -230,6 +262,7 @@ class ArcamFmj(MediaPlayerEntity):
return root
@convert_exception
async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:

View File

@@ -37,19 +37,18 @@ class AsekoBinarySensorEntityDescription(
UNIT_BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = (
AsekoBinarySensorEntityDescription(
key="water_flow",
name="Water Flow",
translation_key="water_flow",
icon="mdi:waves-arrow-right",
value_fn=lambda unit: unit.water_flow,
),
AsekoBinarySensorEntityDescription(
key="has_alarm",
name="Alarm",
translation_key="alarm",
value_fn=lambda unit: unit.has_alarm,
device_class=BinarySensorDeviceClass.SAFETY,
),
AsekoBinarySensorEntityDescription(
key="has_error",
name="Error",
value_fn=lambda unit: unit.has_error,
device_class=BinarySensorDeviceClass.PROBLEM,
),

View File

@@ -11,6 +11,8 @@ from .coordinator import AsekoDataUpdateCoordinator
class AsekoEntity(CoordinatorEntity[AsekoDataUpdateCoordinator]):
"""Representation of an aseko entity."""
_attr_has_entity_name = True
def __init__(self, unit: Unit, coordinator: AsekoDataUpdateCoordinator) -> None:
"""Initialize the aseko entity."""
super().__init__(coordinator)

View File

@@ -45,13 +45,16 @@ class VariableSensorEntity(AsekoEntity, SensorEntity):
super().__init__(unit, coordinator)
self._variable = variable
variable_name = {
"Air temp.": "Air Temperature",
"Cl free": "Free Chlorine",
"Water temp.": "Water Temperature",
}.get(self._variable.name, self._variable.name)
translation_key = {
"Air temp.": "air_temperature",
"Cl free": "free_chlorine",
"Water temp.": "water_temperature",
}.get(self._variable.name)
if translation_key is not None:
self._attr_translation_key = translation_key
else:
self._attr_name = self._variable.name
self._attr_name = f"{self._device_name} {variable_name}"
self._attr_unique_id = f"{self._unit.serial_number}{self._variable.type}"
self._attr_native_unit_of_measurement = self._variable.unit

View File

@@ -16,5 +16,26 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
}
},
"entity": {
"binary_sensor": {
"water_flow": {
"name": "Water flow"
},
"alarm": {
"name": "Alarm"
}
},
"sensor": {
"air_temperature": {
"name": "Air temperature"
},
"free_chlorine": {
"name": "Free chlorine"
},
"water_temperature": {
"name": "Water temperature"
}
}
}
}

View File

@@ -254,6 +254,8 @@ class PipelineEventType(StrEnum):
WAKE_WORD_START = "wake_word-start"
WAKE_WORD_END = "wake_word-end"
STT_START = "stt-start"
STT_VAD_START = "stt-vad-start"
STT_VAD_END = "stt-vad-end"
STT_END = "stt-end"
INTENT_START = "intent-start"
INTENT_END = "intent-end"
@@ -612,11 +614,31 @@ class PipelineRun:
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
result = await self.stt_provider.async_process_audio_stream(

View File

@@ -7,7 +7,6 @@ from collections.abc import AsyncGenerator, Callable
import logging
from typing import Any
import async_timeout
import voluptuous as vol
from homeassistant.components import conversation, stt, tts, websocket_api
@@ -207,7 +206,7 @@ async def websocket_run(
try:
# Task contains a timeout
async with async_timeout.timeout(timeout):
async with asyncio.timeout(timeout):
await run_task
except asyncio.TimeoutError:
pipeline_input.run.process_event(

View File

@@ -1,8 +1,8 @@
"""The ATAG Integration."""
from asyncio import timeout
from datetime import timedelta
import logging
import async_timeout
from pyatag import AtagException, AtagOne
from homeassistant.config_entries import ConfigEntry
@@ -27,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def _async_update_data():
"""Update data via library."""
async with async_timeout.timeout(20):
async with timeout(20):
try:
await atag.update()
except AtagException as err:

View File

@@ -109,10 +109,6 @@ def _native_datetime() -> datetime:
class AugustBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes August binary_sensor entity."""
# AugustBinarySensor does not support UNDEFINED or None,
# restrict the type to str.
name: str = ""
@dataclass
class AugustDoorbellRequiredKeysMixin:
@@ -128,34 +124,28 @@ class AugustDoorbellBinarySensorEntityDescription(
):
"""Describes August binary_sensor entity."""
# AugustDoorbellBinarySensor does not support UNDEFINED or None,
# restrict the type to str.
name: str = ""
SENSOR_TYPE_DOOR = AugustBinarySensorEntityDescription(
key="door_open",
name="Open",
key="open",
device_class=BinarySensorDeviceClass.DOOR,
)
SENSOR_TYPES_VIDEO_DOORBELL = (
AugustDoorbellBinarySensorEntityDescription(
key="doorbell_motion",
name="Motion",
key="motion",
device_class=BinarySensorDeviceClass.MOTION,
value_fn=_retrieve_motion_state,
is_time_based=True,
),
AugustDoorbellBinarySensorEntityDescription(
key="doorbell_image_capture",
name="Image Capture",
key="image capture",
translation_key="image_capture",
icon="mdi:file-image",
value_fn=_retrieve_image_capture_state,
is_time_based=True,
),
AugustDoorbellBinarySensorEntityDescription(
key="doorbell_online",
name="Online",
key="online",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_retrieve_online_state,
@@ -166,8 +156,7 @@ SENSOR_TYPES_VIDEO_DOORBELL = (
SENSOR_TYPES_DOORBELL: tuple[AugustDoorbellBinarySensorEntityDescription, ...] = (
AugustDoorbellBinarySensorEntityDescription(
key="doorbell_ding",
name="Ding",
key="ding",
device_class=BinarySensorDeviceClass.OCCUPANCY,
value_fn=_retrieve_ding_state,
is_time_based=True,
@@ -236,8 +225,7 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity):
self.entity_description = description
self._data = data
self._device = device
self._attr_name = f"{device.device_name} {description.name}"
self._attr_unique_id = f"{self._device_id}_{description.name.lower()}"
self._attr_unique_id = f"{self._device_id}_{description.key}"
@callback
def _update_from_data(self):
@@ -284,8 +272,7 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity):
self.entity_description = description
self._check_for_off_update_listener = None
self._data = data
self._attr_name = f"{device.device_name} {description.name}"
self._attr_unique_id = f"{self._device_id}_{description.name.lower()}"
self._attr_unique_id = f"{self._device_id}_{description.key}"
@callback
def _update_from_data(self):

View File

@@ -24,10 +24,11 @@ async def async_setup_entry(
class AugustWakeLockButton(AugustEntityMixin, ButtonEntity):
"""Representation of an August lock wake button."""
_attr_translation_key = "wake"
def __init__(self, data: AugustData, device: Lock) -> None:
"""Initialize the lock wake button."""
super().__init__(data, device)
self._attr_name = f"{device.device_name} Wake"
self._attr_unique_id = f"{self._device_id}_wake"
async def async_press(self) -> None:

View File

@@ -33,16 +33,17 @@ async def async_setup_entry(
class AugustCamera(AugustEntityMixin, Camera):
"""An implementation of a August security camera."""
"""An implementation of an August security camera."""
_attr_translation_key = "camera"
def __init__(self, data, device, session, timeout):
"""Initialize a August security camera."""
"""Initialize an August security camera."""
super().__init__(data, device)
self._timeout = timeout
self._session = session
self._image_url = None
self._image_content = None
self._attr_name = f"{device.device_name} Camera"
self._attr_unique_id = f"{self._device_id:s}_camera"
@property

View File

@@ -19,6 +19,7 @@ class AugustEntityMixin(Entity):
"""Base implementation for August device."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(self, data: AugustData, device: Doorbell | Lock) -> None:
"""Initialize an August device."""

View File

@@ -37,11 +37,12 @@ async def async_setup_entry(
class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity):
"""Representation of an August lock."""
_attr_name = None
def __init__(self, data, device):
"""Initialize the lock."""
super().__init__(data, device)
self._lock_status = None
self._attr_name = device.device_name
self._attr_unique_id = f"{self._device_id:s}_lock"
self._update_from_data()

View File

@@ -75,7 +75,6 @@ class AugustSensorEntityDescription(
SENSOR_TYPE_DEVICE_BATTERY = AugustSensorEntityDescription[LockDetail](
key="device_battery",
name="Battery",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=_retrieve_device_battery_state,
@@ -83,7 +82,6 @@ SENSOR_TYPE_DEVICE_BATTERY = AugustSensorEntityDescription[LockDetail](
SENSOR_TYPE_KEYPAD_BATTERY = AugustSensorEntityDescription[KeypadDetail](
key="linked_keypad_battery",
name="Battery",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=_retrieve_linked_keypad_battery_state,
@@ -176,6 +174,8 @@ async def _async_migrate_old_unique_ids(hass, devices):
class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity):
"""Representation of an August lock operation sensor."""
_attr_translation_key = "operator"
def __init__(self, data, device):
"""Initialize the sensor."""
super().__init__(data, device)
@@ -188,11 +188,6 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity):
self._entity_picture = None
self._update_from_data()
@property
def name(self):
"""Return the name of the sensor."""
return f"{self._device.device_name} Operator"
@callback
def _update_from_data(self):
"""Get the latest state of the sensor and update activity."""
@@ -278,7 +273,6 @@ class AugustBatterySensor(AugustEntityMixin, SensorEntity, Generic[_T]):
super().__init__(data, device)
self.entity_description = description
self._old_device = old_device
self._attr_name = f"{device.device_name} {description.name}"
self._attr_unique_id = f"{self._device_id}_{description.key}"
self._update_from_data()

View File

@@ -37,5 +37,27 @@
"title": "Reauthenticate an August account"
}
}
},
"entity": {
"binary_sensor": {
"image_capture": {
"name": "Image capture"
}
},
"button": {
"wake": {
"name": "Wake"
}
},
"camera": {
"camera": {
"name": "[%key:component::camera::title%]"
}
},
"sensor": {
"operator": {
"name": "Operator"
}
}
}
}

View File

@@ -13,9 +13,12 @@ async def async_setup_entry(
) -> None:
"""Set up the binary_sensor platform."""
coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR]
name = f"{coordinator.name} Aurora Visibility Alert"
entity = AuroraSensor(coordinator=coordinator, name=name, icon="mdi:hazard-lights")
entity = AuroraSensor(
coordinator=coordinator,
translation_key="visibility_alert",
icon="mdi:hazard-lights",
)
async_add_entries([entity])

View File

@@ -19,14 +19,14 @@ class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]):
def __init__(
self,
coordinator: AuroraDataUpdateCoordinator,
name: str,
translation_key: str,
icon: str,
) -> None:
"""Initialize the Aurora Entity."""
super().__init__(coordinator=coordinator)
self._attr_name = name
self._attr_translation_key = translation_key
self._attr_unique_id = f"{coordinator.latitude}_{coordinator.longitude}"
self._attr_icon = icon

View File

@@ -17,7 +17,7 @@ async def async_setup_entry(
entity = AuroraSensor(
coordinator=coordinator,
name=f"{coordinator.name} Aurora Visibility %",
translation_key="visibility",
icon="mdi:gauge",
)

View File

@@ -25,5 +25,17 @@
}
}
}
},
"entity": {
"binary_sensor": {
"visibility_alert": {
"name": "Visibility alert"
}
},
"sensor": {
"visibility": {
"name": "Visibility"
}
}
}
}

View File

@@ -1,12 +1,11 @@
"""The awair component."""
from __future__ import annotations
from asyncio import gather
from asyncio import gather, timeout
from dataclasses import dataclass
from datetime import timedelta
from aiohttp import ClientSession
from async_timeout import timeout
from python_awair import Awair, AwairLocal
from python_awair.air_data import AirData
from python_awair.devices import AwairBaseDevice, AwairLocalDevice

View File

@@ -1,10 +1,10 @@
"""Axis network device abstraction."""
import asyncio
from asyncio import timeout
from types import MappingProxyType
from typing import Any
import async_timeout
import axis
from axis.configuration import Configuration
from axis.errors import Unauthorized
@@ -253,7 +253,7 @@ async def get_axis_device(
)
try:
async with async_timeout.timeout(30):
async with timeout(30):
await device.vapix.initialize()
return device

View File

@@ -2,10 +2,10 @@
from __future__ import annotations
import asyncio
from asyncio import timeout
from aiobafi6 import Device, Service
from aiobafi6.discovery import PORT
import async_timeout
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, Platform
@@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
run_future = device.async_run()
try:
async with async_timeout.timeout(RUN_TIMEOUT):
async with timeout(RUN_TIMEOUT):
await device.async_wait_available()
except asyncio.TimeoutError as ex:
run_future.cancel()

View File

@@ -2,12 +2,12 @@
from __future__ import annotations
import asyncio
from asyncio import timeout
import logging
from typing import Any
from aiobafi6 import Device, Service
from aiobafi6.discovery import PORT
import async_timeout
import voluptuous as vol
from homeassistant import config_entries
@@ -27,7 +27,7 @@ async def async_try_connect(ip_address: str) -> Device:
device = Device(Service(ip_addresses=[ip_address], port=PORT))
run_future = device.async_run()
try:
async with async_timeout.timeout(RUN_TIMEOUT):
async with timeout(RUN_TIMEOUT):
await device.async_wait_available()
except asyncio.TimeoutError as ex:
raise CannotConnect from ex

View File

@@ -1,9 +1,9 @@
"""Websocket API for blueprint."""
from __future__ import annotations
import asyncio
from typing import Any, cast
import async_timeout
import voluptuous as vol
from homeassistant.components import websocket_api
@@ -72,7 +72,7 @@ async def ws_import_blueprint(
msg: dict[str, Any],
) -> None:
"""Import a blueprint."""
async with async_timeout.timeout(10):
async with asyncio.timeout(10):
imported_blueprint = await importer.fetch_blueprint_from_url(hass, msg["url"])
if imported_blueprint is None:

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
import asyncio
from asyncio import CancelledError
from asyncio import CancelledError, timeout
from datetime import timedelta
from http import HTTPStatus
import logging
@@ -12,7 +12,6 @@ from urllib import parse
import aiohttp
from aiohttp.client_exceptions import ClientError
from aiohttp.hdrs import CONNECTION, KEEP_ALIVE
import async_timeout
import voluptuous as vol
import xmltodict
@@ -355,7 +354,7 @@ class BluesoundPlayer(MediaPlayerEntity):
try:
websession = async_get_clientsession(self._hass)
async with async_timeout.timeout(10):
async with timeout(10):
response = await websession.get(url)
if response.status == HTTPStatus.OK:
@@ -396,7 +395,7 @@ class BluesoundPlayer(MediaPlayerEntity):
_LOGGER.debug("Calling URL: %s", url)
try:
async with async_timeout.timeout(125):
async with timeout(125):
response = await self._polling_session.get(
url, headers={CONNECTION: KEEP_ALIVE}
)

View File

@@ -4,11 +4,11 @@ These APIs are the only documented way to interact with the bluetooth integratio
"""
from __future__ import annotations
import asyncio
from asyncio import Future
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, cast
import async_timeout
from home_assistant_bluetooth import BluetoothServiceInfoBleak
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
@@ -152,7 +152,7 @@ async def async_process_advertisements(
)
try:
async with async_timeout.timeout(timeout):
async with asyncio.timeout(timeout):
return await done
finally:
unload()

View File

@@ -19,6 +19,6 @@
"bluetooth-adapters==0.16.0",
"bluetooth-auto-recovery==1.2.1",
"bluetooth-data-tools==1.8.0",
"dbus-fast==1.91.2"
"dbus-fast==1.91.4"
]
}

View File

@@ -8,7 +8,6 @@ import logging
import platform
from typing import Any
import async_timeout
import bleak
from bleak import BleakError
from bleak.assigned_numbers import AdvertisementDataType
@@ -220,7 +219,7 @@ class HaScanner(BaseHaScanner):
START_ATTEMPTS,
)
try:
async with async_timeout.timeout(START_TIMEOUT):
async with asyncio.timeout(START_TIMEOUT):
await self.scanner.start() # type: ignore[no-untyped-call]
except InvalidMessageError as ex:
_LOGGER.debug(

View File

@@ -25,7 +25,9 @@ async def async_setup_entry(
entities: list[BinarySensorEntity] = []
session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION]
for binary_sensor in session.device_helper.shutter_contacts:
for binary_sensor in (
session.device_helper.shutter_contacts + session.device_helper.shutter_contacts2
):
entities.append(
ShutterContactSensor(
device=binary_sensor,
@@ -37,6 +39,7 @@ async def async_setup_entry(
for binary_sensor in (
session.device_helper.motion_detectors
+ session.device_helper.shutter_contacts
+ session.device_helper.shutter_contacts2
+ session.device_helper.smoke_detectors
+ session.device_helper.thermostats
+ session.device_helper.twinguards

View File

@@ -1,10 +1,10 @@
"""The Brother component."""
from __future__ import annotations
from asyncio import timeout
from datetime import timedelta
import logging
import async_timeout
from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError
from homeassistant.config_entries import ConfigEntry
@@ -79,7 +79,7 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]):
async def _async_update_data(self) -> BrotherSensors:
"""Update data via library."""
try:
async with async_timeout.timeout(20):
async with timeout(20):
data = await self.brother.async_update()
except (ConnectionError, SnmpError, UnsupportedModelError) as error:
raise UpdateFailed(error) from error

View File

@@ -1,10 +1,10 @@
"""The brunt component."""
from __future__ import annotations
from asyncio import timeout
import logging
from aiohttp.client_exceptions import ClientResponseError, ServerDisconnectedError
import async_timeout
from brunt import BruntClientAsync, Thing
from homeassistant.config_entries import ConfigEntry
@@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Error 401 is the API response for things that are not part of the account, could happen when a device is deleted from the account.
"""
try:
async with async_timeout.timeout(10):
async with timeout(10):
things = await bapi.async_get_things(force=True)
return {thing.serial: thing for thing in things}
except ServerDisconnectedError as err:

View File

@@ -14,10 +14,10 @@ CONF_TIMEFRAME = "timeframe"
SUPPORTED_COUNTRY_CODES = ["NL", "BE"]
DEFAULT_COUNTRY = "NL"
"""Schedule next call after (minutes)."""
SCHEDULE_OK = 10
"""When an error occurred, new call after (minutes)."""
"""Schedule next call after (minutes)."""
SCHEDULE_NOK = 2
"""When an error occurred, new call after (minutes)."""
STATE_CONDITIONS = ["clear", "cloudy", "fog", "rainy", "snowy", "lightning"]

View File

@@ -714,17 +714,18 @@ async def async_setup_entry(
timeframe,
)
# create weather entities:
entities = [
BrSensor(config.get(CONF_NAME, "Buienradar"), coordinates, description)
for description in SENSOR_TYPES
]
async_add_entities(entities)
# create weather data:
data = BrData(hass, coordinates, timeframe, entities)
# schedule the first update in 1 minute from now:
await data.schedule_update(1)
hass.data[DOMAIN][entry.entry_id][Platform.SENSOR] = data
await data.async_update()
async_add_entities(entities)
class BrSensor(SensorEntity):
@@ -753,9 +754,9 @@ class BrSensor(SensorEntity):
self._timeframe = None
@callback
def data_updated(self, data):
def data_updated(self, data: BrData):
"""Update data."""
if self.hass and self._load_data(data):
if self._load_data(data.data) and self.hass:
self.async_write_ha_state()
@callback

View File

@@ -1,11 +1,11 @@
"""Shared utilities for different supported platforms."""
import asyncio
from asyncio import timeout
from datetime import datetime, timedelta
from http import HTTPStatus
import logging
import aiohttp
import async_timeout
from buienradar.buienradar import parse_data
from buienradar.constants import (
ATTRIBUTION,
@@ -27,7 +27,7 @@ from buienradar.constants import (
from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import CALLBACK_TYPE
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util import dt as dt_util
@@ -75,9 +75,10 @@ class BrData:
# Update all devices
for dev in self.devices:
dev.data_updated(self.data)
dev.data_updated(self)
async def schedule_update(self, minute=1):
@callback
def async_schedule_update(self, minute=1):
"""Schedule an update after minute minutes."""
_LOGGER.debug("Scheduling next update in %s minutes", minute)
nxt = dt_util.utcnow() + timedelta(minutes=minute)
@@ -92,7 +93,7 @@ class BrData:
resp = None
try:
websession = async_get_clientsession(self.hass)
async with async_timeout.timeout(10):
async with timeout(10):
resp = await websession.get(url)
result[STATUS_CODE] = resp.status
@@ -108,9 +109,9 @@ class BrData:
return result
finally:
if resp is not None:
await resp.release()
resp.release()
async def async_update(self, *_):
async def _async_update(self):
"""Update the data from buienradar."""
content = await self.get_data(JSON_FEED_URL)
@@ -123,9 +124,7 @@ class BrData:
content.get(MESSAGE),
content.get(STATUS_CODE),
)
# schedule new call
await self.schedule_update(SCHEDULE_NOK)
return
return None
self.load_error_count = 0
# rounding coordinates prevents unnecessary redirects/calls
@@ -143,9 +142,7 @@ class BrData:
raincontent.get(MESSAGE),
raincontent.get(STATUS_CODE),
)
# schedule new call
await self.schedule_update(SCHEDULE_NOK)
return
return None
self.rain_error_count = 0
result = parse_data(
@@ -164,12 +161,21 @@ class BrData:
"Unable to parse data from Buienradar. (Msg: %s)",
result.get(MESSAGE),
)
await self.schedule_update(SCHEDULE_NOK)
return None
return result[DATA]
async def async_update(self, *_):
"""Update the data from buienradar and schedule the next update."""
data = await self._async_update()
if data is None:
self.async_schedule_update(SCHEDULE_NOK)
return
self.data = result.get(DATA)
self.data = data
await self.update_devices()
await self.schedule_update(SCHEDULE_OK)
self.async_schedule_update(SCHEDULE_OK)
@property
def attribution(self):

View File

@@ -34,7 +34,9 @@ from homeassistant.components.weather import (
ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
Forecast,
WeatherEntity,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -48,7 +50,7 @@ from homeassistant.const import (
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
# Reuse data and API logic from the sensor implementation
@@ -82,6 +84,11 @@ CONDITION_CLASSES = {
ATTR_CONDITION_WINDY_VARIANT: (),
ATTR_CONDITION_EXCEPTIONAL: (),
}
CONDITION_MAP = {
cond_code: cond_ha
for cond_ha, cond_codes in CONDITION_CLASSES.items()
for cond_code in cond_codes
}
async def async_setup_entry(
@@ -99,24 +106,16 @@ async def async_setup_entry(
coordinates = {CONF_LATITUDE: float(latitude), CONF_LONGITUDE: float(longitude)}
# create weather data:
data = BrData(hass, coordinates, DEFAULT_TIMEFRAME, None)
hass.data[DOMAIN][entry.entry_id][Platform.WEATHER] = data
# create weather device:
# create weather entity:
_LOGGER.debug("Initializing buienradar weather: coordinates %s", coordinates)
entities = [BrWeather(config, coordinates)]
# create condition helper
if DATA_CONDITION not in hass.data[DOMAIN]:
cond_keys = [str(chr(x)) for x in range(97, 123)]
hass.data[DOMAIN][DATA_CONDITION] = dict.fromkeys(cond_keys)
for cond, condlst in CONDITION_CLASSES.items():
for condi in condlst:
hass.data[DOMAIN][DATA_CONDITION][condi] = cond
# create weather data:
data = BrData(hass, coordinates, DEFAULT_TIMEFRAME, entities)
hass.data[DOMAIN][entry.entry_id][Platform.WEATHER] = data
await data.async_update()
async_add_entities([BrWeather(data, config, coordinates)])
# schedule the first update in 1 minute from now:
await data.schedule_update(1)
async_add_entities(entities)
class BrWeather(WeatherEntity):
@@ -127,81 +126,62 @@ class BrWeather(WeatherEntity):
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_visibility_unit = UnitOfLength.METERS
_attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND
_attr_should_poll = False
_attr_supported_features = WeatherEntityFeature.FORECAST_DAILY
def __init__(self, data, config, coordinates):
def __init__(self, config, coordinates):
"""Initialize the platform with a data instance and station name."""
self._stationname = config.get(CONF_NAME, "Buienradar")
self._attr_name = (
self._stationname or f"BR {data.stationname or '(unknown station)'}"
)
self._data = data
self._attr_name = self._stationname or f"BR {'(unknown station)'}"
self._attr_unique_id = "{:2.6f}{:2.6f}".format(
coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE]
)
@property
def attribution(self):
"""Return the attribution."""
return self._data.attribution
@callback
def data_updated(self, data: BrData) -> None:
"""Update data."""
self._attr_attribution = data.attribution
self._attr_condition = self._calc_condition(data)
self._attr_forecast = self._calc_forecast(data)
self._attr_humidity = data.humidity
self._attr_name = (
self._stationname or f"BR {data.stationname or '(unknown station)'}"
)
self._attr_native_pressure = data.pressure
self._attr_native_temperature = data.temperature
self._attr_native_visibility = data.visibility
self._attr_native_wind_speed = data.wind_speed
self._attr_wind_bearing = data.wind_bearing
@property
def condition(self):
if not self.hass:
return
self.async_write_ha_state()
assert self.platform.config_entry
self.platform.config_entry.async_create_task(
self.hass, self.async_update_listeners(("daily",))
)
def _calc_condition(self, data: BrData):
"""Return the current condition."""
if (
self._data
and self._data.condition
and (ccode := self._data.condition.get(CONDCODE))
and (conditions := self.hass.data[DOMAIN].get(DATA_CONDITION))
):
return conditions.get(ccode)
if data.condition and (ccode := data.condition.get(CONDCODE)):
return CONDITION_MAP.get(ccode)
return None
@property
def native_temperature(self):
"""Return the current temperature."""
return self._data.temperature
@property
def native_pressure(self):
"""Return the current pressure."""
return self._data.pressure
@property
def humidity(self):
"""Return the name of the sensor."""
return self._data.humidity
@property
def native_visibility(self):
"""Return the current visibility in m."""
return self._data.visibility
@property
def native_wind_speed(self):
"""Return the current windspeed in m/s."""
return self._data.wind_speed
@property
def wind_bearing(self):
"""Return the current wind bearing (degrees)."""
return self._data.wind_bearing
@property
def forecast(self):
def _calc_forecast(self, data: BrData):
"""Return the forecast array."""
fcdata_out = []
cond = self.hass.data[DOMAIN][DATA_CONDITION]
if not self._data.forecast:
if not data.forecast:
return None
for data_in in self._data.forecast:
for data_in in data.forecast:
# remap keys from external library to
# keys understood by the weather component:
condcode = data_in.get(CONDITION, []).get(CONDCODE)
condcode = data_in.get(CONDITION, {}).get(CONDCODE)
data_out = {
ATTR_FORECAST_TIME: data_in.get(DATETIME).isoformat(),
ATTR_FORECAST_CONDITION: cond[condcode],
ATTR_FORECAST_CONDITION: CONDITION_MAP.get(condcode),
ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP),
ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP),
ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN),
@@ -212,3 +192,7 @@ class BrWeather(WeatherEntity):
fcdata_out.append(data_out)
return fcdata_out
async def async_forecast_daily(self) -> list[Forecast] | None:
"""Return the daily forecast in native units."""
return self._attr_forecast

View File

@@ -87,6 +87,7 @@ def setup_platform(
calendars = client.principal().calendars()
calendar_devices = []
device_id: str | None
for calendar in list(calendars):
# If a calendar name was given in the configuration,
# ignore all the others

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/caldav",
"iot_class": "cloud_polling",
"loggers": ["caldav", "vobject"],
"requirements": ["caldav==1.2.0"]
"requirements": ["caldav==1.3.6"]
}

View File

@@ -15,7 +15,6 @@ from random import SystemRandom
from typing import Any, Final, cast, final
from aiohttp import hdrs, web
import async_timeout
import attr
import voluptuous as vol
@@ -168,7 +167,7 @@ async def _async_get_image(
are handled.
"""
with suppress(asyncio.CancelledError, asyncio.TimeoutError):
async with async_timeout.timeout(timeout):
async with asyncio.timeout(timeout):
if image_bytes := await camera.async_camera_image(
width=width, height=height
):
@@ -525,7 +524,7 @@ class Camera(Entity):
self._create_stream_lock = asyncio.Lock()
async with self._create_stream_lock:
if not self.stream:
async with async_timeout.timeout(CAMERA_STREAM_SOURCE_TIMEOUT):
async with asyncio.timeout(CAMERA_STREAM_SOURCE_TIMEOUT):
source = await self.stream_source()
if not source:
return None

View File

@@ -1,11 +1,11 @@
"""Provides the Canary DataUpdateCoordinator."""
from __future__ import annotations
import asyncio
from collections.abc import ValuesView
from datetime import timedelta
import logging
from async_timeout import timeout
from canary.api import Api
from canary.model import Location, Reading
from requests.exceptions import ConnectTimeout, HTTPError
@@ -58,7 +58,7 @@ class CanaryDataUpdateCoordinator(DataUpdateCoordinator[CanaryData]):
"""Fetch data from Canary."""
try:
async with timeout(15):
async with asyncio.timeout(15):
return await self.hass.async_add_executor_job(self._update_data)
except (ConnectTimeout, HTTPError) as error:
raise UpdateFailed(f"Invalid response from API: {error}") from error

View File

@@ -77,6 +77,7 @@ class CertExpiryEntity(CoordinatorEntity[CertExpiryDataUpdateCoordinator]):
"""Defines a base Cert Expiry entity."""
_attr_icon = "mdi:certificate"
_attr_has_entity_name = True
@property
def extra_state_attributes(self):
@@ -91,6 +92,7 @@ class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity):
"""Implementation of the Cert Expiry timestamp sensor."""
_attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_translation_key = "certificate_expiry"
def __init__(
self,
@@ -98,7 +100,6 @@ class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity):
) -> None:
"""Initialize a Cert Expiry timestamp sensor."""
super().__init__(coordinator)
self._attr_name = f"Cert Expiry Timestamp ({coordinator.name})"
self._attr_unique_id = f"{coordinator.host}:{coordinator.port}-timestamp"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{coordinator.host}:{coordinator.port}")},

View File

@@ -20,5 +20,12 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"import_failed": "Import from config failed"
}
},
"entity": {
"sensor": {
"certificate_expiry": {
"name": "Cert expiry"
}
}
}
}

View File

@@ -6,7 +6,6 @@ from datetime import timedelta
import logging
import aiohttp
import async_timeout
import voluptuous as vol
from homeassistant.components.sensor import (
@@ -140,7 +139,7 @@ async def async_citybikes_request(hass, uri, schema):
try:
session = async_get_clientsession(hass)
async with async_timeout.timeout(REQUEST_TIMEOUT):
async with asyncio.timeout(REQUEST_TIMEOUT):
req = await session.get(DEFAULT_ENDPOINT.format(uri=uri))
json_response = await req.json()

View File

@@ -10,7 +10,6 @@ import logging
from typing import TYPE_CHECKING, Any
import aiohttp
import async_timeout
from hass_nabucasa import Cloud, cloud_api
from yarl import URL
@@ -501,7 +500,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
)
try:
async with async_timeout.timeout(10):
async with asyncio.timeout(10):
await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED)
return True

View File

@@ -54,7 +54,6 @@ class CloudClient(Interface):
@property
def base_path(self) -> Path:
"""Return path to base dir."""
assert self._hass.config.config_dir is not None
return Path(self._hass.config.config_dir)
@property

View File

@@ -10,7 +10,6 @@ from typing import Any, Concatenate, ParamSpec, TypeVar
import aiohttp
from aiohttp import web
import async_timeout
import attr
from hass_nabucasa import Cloud, auth, thingtalk
from hass_nabucasa.const import STATE_DISCONNECTED
@@ -252,7 +251,7 @@ class CloudLogoutView(HomeAssistantView):
hass = request.app["hass"]
cloud = hass.data[DOMAIN]
async with async_timeout.timeout(REQUEST_TIMEOUT):
async with asyncio.timeout(REQUEST_TIMEOUT):
await cloud.logout()
return self.json_message("ok")
@@ -292,7 +291,7 @@ class CloudRegisterView(HomeAssistantView):
if location_info.zip_code is not None:
client_metadata["NC_ZIP_CODE"] = location_info.zip_code
async with async_timeout.timeout(REQUEST_TIMEOUT):
async with asyncio.timeout(REQUEST_TIMEOUT):
await cloud.auth.async_register(
data["email"],
data["password"],
@@ -316,7 +315,7 @@ class CloudResendConfirmView(HomeAssistantView):
hass = request.app["hass"]
cloud = hass.data[DOMAIN]
async with async_timeout.timeout(REQUEST_TIMEOUT):
async with asyncio.timeout(REQUEST_TIMEOUT):
await cloud.auth.async_resend_email_confirm(data["email"])
return self.json_message("ok")
@@ -336,7 +335,7 @@ class CloudForgotPasswordView(HomeAssistantView):
hass = request.app["hass"]
cloud = hass.data[DOMAIN]
async with async_timeout.timeout(REQUEST_TIMEOUT):
async with asyncio.timeout(REQUEST_TIMEOUT):
await cloud.auth.async_forgot_password(data["email"])
return self.json_message("ok")
@@ -439,7 +438,7 @@ async def websocket_update_prefs(
if changes.get(PREF_ALEXA_REPORT_STATE):
alexa_config = await cloud.client.get_alexa_config()
try:
async with async_timeout.timeout(10):
async with asyncio.timeout(10):
await alexa_config.async_get_access_token()
except asyncio.TimeoutError:
connection.send_error(
@@ -779,7 +778,7 @@ async def alexa_sync(
cloud = hass.data[DOMAIN]
alexa_config = await cloud.client.get_alexa_config()
async with async_timeout.timeout(10):
async with asyncio.timeout(10):
try:
success = await alexa_config.async_sync_entities()
except alexa_errors.NoTokenAvailable:
@@ -808,7 +807,7 @@ async def thingtalk_convert(
"""Convert a query."""
cloud = hass.data[DOMAIN]
async with async_timeout.timeout(10):
async with asyncio.timeout(10):
try:
connection.send_result(
msg["id"], await thingtalk.async_convert(cloud, msg["query"])

View File

@@ -6,7 +6,6 @@ import logging
from typing import Any
from aiohttp.client_exceptions import ClientError
import async_timeout
from hass_nabucasa import Cloud, cloud_api
from .client import CloudClient
@@ -18,7 +17,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_subscription_info(cloud: Cloud[CloudClient]) -> dict[str, Any] | None:
"""Fetch the subscription info."""
try:
async with async_timeout.timeout(REQUEST_TIMEOUT):
async with asyncio.timeout(REQUEST_TIMEOUT):
return await cloud_api.async_subscription_info(cloud)
except asyncio.TimeoutError:
_LOGGER.error(
@@ -39,7 +38,7 @@ async def async_migrate_paypal_agreement(
) -> dict[str, Any] | None:
"""Migrate a paypal agreement from legacy."""
try:
async with async_timeout.timeout(REQUEST_TIMEOUT):
async with asyncio.timeout(REQUEST_TIMEOUT):
return await cloud_api.async_migrate_paypal_agreement(cloud)
except asyncio.TimeoutError:
_LOGGER.error(

View File

@@ -4,7 +4,6 @@ import io
import logging
import aiohttp
import async_timeout
from colorthief import ColorThief
from PIL import UnidentifiedImageError
import voluptuous as vol
@@ -120,7 +119,7 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
try:
session = aiohttp_client.async_get_clientsession(hass)
async with async_timeout.timeout(10):
async with asyncio.timeout(10):
response = await session.get(url)
except (asyncio.TimeoutError, aiohttp.ClientError) as err:

View File

@@ -7,7 +7,6 @@ import json
import logging
import aiohttp
import async_timeout
import voluptuous as vol
from homeassistant.components.sensor import (
@@ -112,7 +111,7 @@ class ComedHourlyPricingSensor(SensorEntity):
else:
url_string += "?type=currenthouraverage"
async with async_timeout.timeout(60):
async with asyncio.timeout(60):
response = await self.websession.get(url_string)
# The API responds with MIME type 'text/html'
text = await response.text()

View File

@@ -0,0 +1,34 @@
"""Comelit integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PIN, Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import ComelitSerialBridge
PLATFORMS = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Comelit platform."""
coordinator = ComelitSerialBridge(hass, entry.data[CONF_HOST], entry.data[CONF_PIN])
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
coordinator: ComelitSerialBridge = hass.data[DOMAIN][entry.entry_id]
await coordinator.api.logout()
await coordinator.api.close()
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@@ -0,0 +1,145 @@
"""Config flow for Comelit integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from aiocomelit import ComeliteSerialBridgeAPi, exceptions as aiocomelit_exceptions
import voluptuous as vol
from homeassistant import core, exceptions
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.const import CONF_HOST, CONF_PIN
from homeassistant.data_entry_flow import FlowResult
from .const import _LOGGER, DOMAIN
DEFAULT_HOST = "192.168.1.252"
DEFAULT_PIN = "111111"
def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema:
"""Return user form schema."""
user_input = user_input or {}
return vol.Schema(
{
vol.Optional(CONF_HOST, default=DEFAULT_HOST): str,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): str,
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): str})
async def validate_input(
hass: core.HomeAssistant, data: dict[str, Any]
) -> dict[str, str]:
"""Validate the user input allows us to connect."""
api = ComeliteSerialBridgeAPi(data[CONF_HOST], data[CONF_PIN])
try:
await api.login()
except aiocomelit_exceptions.CannotConnect as err:
raise CannotConnect from err
except aiocomelit_exceptions.CannotAuthenticate as err:
raise InvalidAuth from err
finally:
await api.logout()
await api.close()
return {"title": data[CONF_HOST]}
class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Comelit."""
VERSION = 1
_reauth_entry: ConfigEntry | None
_reauth_host: str
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
if user_input is None:
return self.async_show_form(
step_id="user", data_schema=user_form_schema(user_input)
)
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
errors = {}
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user", data_schema=user_form_schema(user_input), errors=errors
)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Handle reauth flow."""
self._reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
self._reauth_host = entry_data[CONF_HOST]
self.context["title_placeholders"] = {"host": self._reauth_host}
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle reauth confirm."""
assert self._reauth_entry
errors = {}
if user_input is not None:
try:
await validate_input(
self.hass, {CONF_HOST: self._reauth_host} | user_input
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self.hass.config_entries.async_update_entry(
self._reauth_entry,
data={
CONF_HOST: self._reauth_host,
CONF_PIN: user_input[CONF_PIN],
},
)
self.hass.async_create_task(
self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
)
return self.async_abort(reason="reauth_successful")
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={CONF_HOST: self._reauth_entry.data[CONF_HOST]},
data_schema=STEP_REAUTH_DATA_SCHEMA,
errors=errors,
)
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""

View File

@@ -0,0 +1,6 @@
"""Comelit constants."""
import logging
_LOGGER = logging.getLogger(__package__)
DOMAIN = "comelit"

View File

@@ -0,0 +1,50 @@
"""Support for Comelit."""
import asyncio
from datetime import timedelta
from typing import Any
from aiocomelit import ComeliteSerialBridgeAPi
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, DOMAIN
class ComelitSerialBridge(DataUpdateCoordinator):
"""Queries Comelit Serial Bridge."""
def __init__(self, hass: HomeAssistant, host: str, pin: int) -> None:
"""Initialize the scanner."""
self._host = host
self._pin = pin
self.api = ComeliteSerialBridgeAPi(host, pin)
super().__init__(
hass=hass,
logger=_LOGGER,
name=f"{DOMAIN}-{host}-coordinator",
update_interval=timedelta(seconds=5),
)
async def _async_update_data(self) -> dict[str, Any]:
"""Update router data."""
_LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host)
try:
logged = await self.api.login()
except (asyncio.exceptions.TimeoutError, aiohttp.ClientConnectorError) as err:
_LOGGER.warning("Connection error for %s", self._host)
raise UpdateFailed(f"Error fetching data: {repr(err)}") from err
if not logged:
raise ConfigEntryAuthFailed
devices_data = await self.api.get_all_devices()
alarm_data = await self.api.get_alarm_config()
await self.api.logout()
return devices_data | alarm_data

View File

@@ -0,0 +1,78 @@
"""Support for lights."""
from __future__ import annotations
from typing import Any
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import LIGHT, LIGHT_OFF, LIGHT_ON
from homeassistant.components.light import LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ComelitSerialBridge
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Comelit lights."""
coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id]
# Use config_entry.entry_id as base for unique_id because no serial number or mac is available
async_add_entities(
ComelitLightEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[LIGHT].values()
)
class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity):
"""Light device."""
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
coordinator: ComelitSerialBridge,
device: ComelitSerialBridgeObject,
config_entry_unique_id: str | None,
) -> None:
"""Init light entity."""
self._api = coordinator.api
self._device = device
super().__init__(coordinator)
self._attr_unique_id = f"{config_entry_unique_id}-{device.index}"
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, self._attr_unique_id),
},
manufacturer="Comelit",
model="Serial Bridge",
name=device.name,
)
async def _light_set_state(self, state: int) -> None:
"""Set desired light state."""
await self.coordinator.api.light_switch(self._device.index, state)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
await self._light_set_state(LIGHT_ON)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self._light_set_state(LIGHT_OFF)
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
return self.coordinator.data[LIGHT][self._device.index].status == LIGHT_ON

View File

@@ -0,0 +1,10 @@
{
"domain": "comelit",
"name": "Comelit SimpleHome",
"codeowners": ["@chemelli74"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/comelit",
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"requirements": ["aiocomelit==0.0.5"]
}

View File

@@ -0,0 +1,31 @@
{
"config": {
"flow_title": "{host}",
"step": {
"reauth_confirm": {
"description": "Please enter the correct PIN for VEDO system: {host}",
"data": {
"pin": "[%key:common::config_flow::data::pin%]"
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"pin": "[%key:common::config_flow::data::pin%]"
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
}
}

View File

@@ -16,13 +16,12 @@ from homeassistant.components.sensor import (
PLATFORM_SCHEMA,
STATE_CLASSES_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.components.sensor.helpers import async_parse_date_datetime
from homeassistant.const import (
CONF_COMMAND,
CONF_DEVICE_CLASS,
CONF_ICON,
CONF_NAME,
CONF_SCAN_INTERVAL,
CONF_UNIQUE_ID,
@@ -36,7 +35,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template
from homeassistant.helpers.template_entity import ManualTriggerEntity
from homeassistant.helpers.template_entity import (
CONF_AVAILABILITY,
CONF_PICTURE,
ManualTriggerSensorEntity,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
@@ -47,6 +50,16 @@ CONF_JSON_ATTRIBUTES = "json_attributes"
DEFAULT_NAME = "Command Sensor"
TRIGGER_ENTITY_OPTIONS = (
CONF_AVAILABILITY,
CONF_DEVICE_CLASS,
CONF_ICON,
CONF_PICTURE,
CONF_UNIQUE_ID,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
)
SCAN_INTERVAL = timedelta(seconds=60)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@@ -87,30 +100,25 @@ async def async_setup_platform(
name: str = sensor_config[CONF_NAME]
command: str = sensor_config[CONF_COMMAND]
unit: str | None = sensor_config.get(CONF_UNIT_OF_MEASUREMENT)
value_template: Template | None = sensor_config.get(CONF_VALUE_TEMPLATE)
command_timeout: int = sensor_config[CONF_COMMAND_TIMEOUT]
unique_id: str | None = sensor_config.get(CONF_UNIQUE_ID)
if value_template is not None:
value_template.hass = hass
json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES)
scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
state_class: SensorStateClass | None = sensor_config.get(CONF_STATE_CLASS)
data = CommandSensorData(hass, command, command_timeout)
trigger_entity_config = {
CONF_UNIQUE_ID: unique_id,
CONF_NAME: Template(name, hass),
CONF_DEVICE_CLASS: sensor_config.get(CONF_DEVICE_CLASS),
}
trigger_entity_config = {CONF_NAME: Template(name, hass)}
for key in TRIGGER_ENTITY_OPTIONS:
if key not in sensor_config:
continue
trigger_entity_config[key] = sensor_config[key]
async_add_entities(
[
CommandSensor(
data,
trigger_entity_config,
unit,
state_class,
value_template,
json_attributes,
scan_interval,
@@ -119,7 +127,7 @@ async def async_setup_platform(
)
class CommandSensor(ManualTriggerEntity, SensorEntity):
class CommandSensor(ManualTriggerSensorEntity):
"""Representation of a sensor that is using shell commands."""
_attr_should_poll = False
@@ -128,8 +136,6 @@ class CommandSensor(ManualTriggerEntity, SensorEntity):
self,
data: CommandSensorData,
config: ConfigType,
unit_of_measurement: str | None,
state_class: SensorStateClass | None,
value_template: Template | None,
json_attributes: list[str] | None,
scan_interval: timedelta,
@@ -141,8 +147,6 @@ class CommandSensor(ManualTriggerEntity, SensorEntity):
self._json_attributes = json_attributes
self._attr_native_value = None
self._value_template = value_template
self._attr_native_unit_of_measurement = unit_of_measurement
self._attr_state_class = state_class
self._scan_interval = scan_interval
self._process_updates: asyncio.Lock | None = None

View File

@@ -11,7 +11,7 @@ import voluptuous as vol
from homeassistant import config_entries, data_entry_flow
from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES, POLICY_EDIT
from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http import HomeAssistantView, require_admin
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import DependencyError, Unauthorized
import homeassistant.helpers.config_validation as cv
@@ -138,12 +138,11 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView):
"""Not implemented."""
raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"])
# pylint: disable=arguments-differ
@require_admin(
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add")
)
async def post(self, request):
"""Handle a POST request."""
if not request["hass_user"].is_admin:
raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add")
# pylint: disable=no-value-for-parameter
try:
return await super().post(request)
@@ -164,19 +163,18 @@ class ConfigManagerFlowResourceView(FlowManagerResourceView):
url = "/api/config/config_entries/flow/{flow_id}"
name = "api:config:config_entries:flow:resource"
async def get(self, request, flow_id):
@require_admin(
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add")
)
async def get(self, request, /, flow_id):
"""Get the current state of a data_entry_flow."""
if not request["hass_user"].is_admin:
raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add")
return await super().get(request, flow_id)
# pylint: disable=arguments-differ
@require_admin(
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add")
)
async def post(self, request, flow_id):
"""Handle a POST request."""
if not request["hass_user"].is_admin:
raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add")
# pylint: disable=no-value-for-parameter
return await super().post(request, flow_id)
@@ -206,15 +204,14 @@ class OptionManagerFlowIndexView(FlowManagerIndexView):
url = "/api/config/config_entries/options/flow"
name = "api:config:config_entries:option:flow"
# pylint: disable=arguments-differ
@require_admin(
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
async def post(self, request):
"""Handle a POST request.
handler in request is entry_id.
"""
if not request["hass_user"].is_admin:
raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
# pylint: disable=no-value-for-parameter
return await super().post(request)
@@ -225,19 +222,18 @@ class OptionManagerFlowResourceView(FlowManagerResourceView):
url = "/api/config/config_entries/options/flow/{flow_id}"
name = "api:config:config_entries:options:flow:resource"
async def get(self, request, flow_id):
@require_admin(
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
async def get(self, request, /, flow_id):
"""Get the current state of a data_entry_flow."""
if not request["hass_user"].is_admin:
raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
return await super().get(request, flow_id)
# pylint: disable=arguments-differ
@require_admin(
error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
)
async def post(self, request, flow_id):
"""Handle a POST request."""
if not request["hass_user"].is_admin:
raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
# pylint: disable=no-value-for-parameter
return await super().post(request, flow_id)

View File

@@ -4,7 +4,6 @@ from datetime import timedelta
import logging
from aiohttp import ClientConnectionError
from async_timeout import timeout
from pydaikin.daikin_base import Appliance
from homeassistant.config_entries import ConfigEntry
@@ -74,7 +73,7 @@ async def daikin_api_setup(hass: HomeAssistant, host, key, uuid, password):
session = async_get_clientsession(hass)
try:
async with timeout(TIMEOUT):
async with asyncio.timeout(TIMEOUT):
device = await Appliance.factory(
host, session, key=key, uuid=uuid, password=password
)

View File

@@ -4,7 +4,6 @@ import logging
from uuid import uuid4
from aiohttp import ClientError, web_exceptions
from async_timeout import timeout
from pydaikin.daikin_base import Appliance, DaikinException
from pydaikin.discovery import Discovery
import voluptuous as vol
@@ -70,7 +69,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
password = None
try:
async with timeout(TIMEOUT):
async with asyncio.timeout(TIMEOUT):
device = await Appliance.factory(
host,
async_get_clientsession(self.hass),

View File

@@ -9,7 +9,6 @@ from pprint import pformat
from typing import Any, cast
from urllib.parse import urlparse
import async_timeout
from pydeconz.errors import LinkButtonNotPressed, RequestError, ResponseError
from pydeconz.gateway import DeconzSession
from pydeconz.utils import (
@@ -101,7 +100,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
session = aiohttp_client.async_get_clientsession(self.hass)
try:
async with async_timeout.timeout(10):
async with asyncio.timeout(10):
self.bridges = await deconz_discovery(session)
except (asyncio.TimeoutError, ResponseError):
@@ -159,7 +158,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
deconz_session = DeconzSession(session, self.host, self.port)
try:
async with async_timeout.timeout(10):
async with asyncio.timeout(10):
api_key = await deconz_session.get_api_key()
except LinkButtonNotPressed:
@@ -180,7 +179,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
session = aiohttp_client.async_get_clientsession(self.hass)
try:
async with async_timeout.timeout(10):
async with asyncio.timeout(10):
self.bridge_id = await deconz_get_bridge_id(
session, self.host, self.port, self.api_key
)

View File

@@ -7,7 +7,6 @@ from collections.abc import Callable
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, cast
import async_timeout
from pydeconz import DeconzSession, errors
from pydeconz.interfaces import sensors
from pydeconz.interfaces.api_handlers import APIHandler, GroupedAPIHandler
@@ -353,7 +352,7 @@ async def get_deconz_session(
config[CONF_API_KEY],
)
try:
async with async_timeout.timeout(10):
async with asyncio.timeout(10):
await deconz_session.refresh_state()
return deconz_session

View File

@@ -46,6 +46,11 @@ CONDITION_CLASSES: dict[str, list[str]] = {
ATTR_CONDITION_WINDY_VARIANT: [],
ATTR_CONDITION_EXCEPTIONAL: [],
}
CONDITION_MAP = {
cond_code: cond_ha
for cond_ha, cond_codes in CONDITION_CLASSES.items()
for cond_code in cond_codes
}
WEATHER_UPDATE_INTERVAL = timedelta(minutes=30)
@@ -237,9 +242,7 @@ class DemoWeather(WeatherEntity):
@property
def condition(self) -> str:
"""Return the weather condition."""
return [
k for k, v in CONDITION_CLASSES.items() if self._condition.lower() in v
][0]
return CONDITION_MAP[self._condition.lower()]
async def async_forecast_daily(self) -> list[Forecast]:
"""Return the daily forecast."""

View File

@@ -1,10 +1,10 @@
"""The devolo Home Network integration."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import async_timeout
from devolo_plc_api import Device
from devolo_plc_api.device_api import (
ConnectedStationInfo,
@@ -70,7 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Fetch data from API endpoint."""
assert device.plcnet
try:
async with async_timeout.timeout(10):
async with asyncio.timeout(10):
return await device.plcnet.async_get_network_overview()
except DeviceUnavailable as err:
raise UpdateFailed(err) from err
@@ -79,7 +79,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Fetch data from API endpoint."""
assert device.device
try:
async with async_timeout.timeout(10):
async with asyncio.timeout(10):
return await device.device.async_get_wifi_guest_access()
except DeviceUnavailable as err:
raise UpdateFailed(err) from err
@@ -90,7 +90,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Fetch data from API endpoint."""
assert device.device
try:
async with async_timeout.timeout(10):
async with asyncio.timeout(10):
return await device.device.async_get_led_setting()
except DeviceUnavailable as err:
raise UpdateFailed(err) from err
@@ -99,7 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Fetch data from API endpoint."""
assert device.device
try:
async with async_timeout.timeout(10):
async with asyncio.timeout(10):
return await device.device.async_get_wifi_connected_station()
except DeviceUnavailable as err:
raise UpdateFailed(err) from err
@@ -108,7 +108,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Fetch data from API endpoint."""
assert device.device
try:
async with async_timeout.timeout(30):
async with asyncio.timeout(30):
return await device.device.async_get_wifi_neighbor_access_points()
except DeviceUnavailable as err:
raise UpdateFailed(err) from err

View File

@@ -6,7 +6,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import COORDINATOR, DOMAIN, GLUCOSE_TREND_ICON, GLUCOSE_VALUE_ICON, MG_DL
@@ -29,18 +32,33 @@ async def async_setup_entry(
)
class DexcomGlucoseValueSensor(CoordinatorEntity, SensorEntity):
class DexcomSensorEntity(CoordinatorEntity, SensorEntity):
"""Base Dexcom sensor entity."""
def __init__(
self, coordinator: DataUpdateCoordinator, username: str, key: str
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{username}-{key}"
class DexcomGlucoseValueSensor(DexcomSensorEntity):
"""Representation of a Dexcom glucose value sensor."""
_attr_icon = GLUCOSE_VALUE_ICON
def __init__(self, coordinator, username, unit_of_measurement):
def __init__(
self,
coordinator: DataUpdateCoordinator,
username: str,
unit_of_measurement: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
super().__init__(coordinator, username, "value")
self._attr_native_unit_of_measurement = unit_of_measurement
self._key = "mg_dl" if unit_of_measurement == MG_DL else "mmol_l"
self._attr_name = f"{DOMAIN}_{username}_glucose_value"
self._attr_unique_id = f"{username}-value"
@property
def native_value(self):
@@ -50,14 +68,13 @@ class DexcomGlucoseValueSensor(CoordinatorEntity, SensorEntity):
return None
class DexcomGlucoseTrendSensor(CoordinatorEntity, SensorEntity):
class DexcomGlucoseTrendSensor(DexcomSensorEntity):
"""Representation of a Dexcom glucose trend sensor."""
def __init__(self, coordinator, username):
def __init__(self, coordinator: DataUpdateCoordinator, username: str) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
super().__init__(coordinator, username, "trend")
self._attr_name = f"{DOMAIN}_{username}_glucose_trend"
self._attr_unique_id = f"{username}-trend"
@property
def icon(self):

View File

@@ -1,7 +1,9 @@
"""Discovergy sensor entity."""
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime
from pydiscovergy.models import Meter
from pydiscovergy.models import Meter, Reading
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -11,6 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
EntityCategory,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
@@ -19,7 +22,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import DiscovergyData, DiscovergyUpdateCoordinator
@@ -32,6 +34,9 @@ PARALLEL_UPDATES = 1
class DiscovergyMixin:
"""Mixin for alternative keys."""
value_fn: Callable[[Reading, str, int], datetime | float | None] = field(
default=lambda reading, key, scale: float(reading.values[key] / scale)
)
alternative_keys: list[str] = field(default_factory=lambda: [])
scale: int = field(default_factory=lambda: 1000)
@@ -144,6 +149,17 @@ ELECTRICITY_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = (
),
)
ADDITIONAL_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = (
DiscovergySensorEntityDescription(
key="last_transmitted",
translation_key="last_transmitted",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda reading, key, scale: reading.time_with_timezone,
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
@@ -160,18 +176,22 @@ async def async_setup_entry(
elif meter.measurement_type == "GAS":
sensors = GAS_SENSORS
coordinator: DiscovergyUpdateCoordinator = data.coordinators[meter.meter_id]
if sensors is not None:
for description in sensors:
# check if this meter has this data, then add this sensor
for key in {description.key, *description.alternative_keys}:
coordinator: DiscovergyUpdateCoordinator = data.coordinators[
meter.meter_id
]
if key in coordinator.data.values:
entities.append(
DiscovergySensor(key, description, meter, coordinator)
)
for description in ADDITIONAL_SENSORS:
entities.append(
DiscovergySensor(description.key, description, meter, coordinator)
)
async_add_entities(entities, False)
@@ -204,8 +224,8 @@ class DiscovergySensor(CoordinatorEntity[DiscovergyUpdateCoordinator], SensorEnt
)
@property
def native_value(self) -> StateType:
def native_value(self) -> datetime | float | None:
"""Return the sensor state."""
return float(
self.coordinator.data.values[self.data_key] / self.entity_description.scale
return self.entity_description.value_fn(
self.coordinator.data, self.data_key, self.entity_description.scale
)

View File

@@ -60,6 +60,9 @@
},
"phase_3_power": {
"name": "Phase 3 power"
},
"last_transmitted": {
"name": "Last transmitted"
}
}
}

View File

@@ -14,35 +14,24 @@ from homeassistant.components import persistent_notification
from homeassistant.components.http import HomeAssistantView
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.network import get_url
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util, slugify
from .const import (
CONF_EVENTS,
DOMAIN,
DOOR_STATION,
DOOR_STATION_EVENT_ENTITY_IDS,
DOOR_STATION_INFO,
PLATFORMS,
UNDO_UPDATE_LISTENER,
)
from .util import get_doorstation_by_token
from .const import API_URL, CONF_EVENTS, DOMAIN, PLATFORMS
from .device import ConfiguredDoorBird
from .models import DoorBirdData
from .util import get_door_station_by_token
_LOGGER = logging.getLogger(__name__)
API_URL = f"/api/{DOMAIN}"
CONF_CUSTOM_URL = "hass_url_override"
RESET_DEVICE_FAVORITES = "doorbird_reset_favorites"
@@ -66,26 +55,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the DoorBird component."""
hass.data.setdefault(DOMAIN, {})
# Provide an endpoint for the doorstations to call to trigger events
# Provide an endpoint for the door stations to call to trigger events
hass.http.register_view(DoorBirdRequestView)
def _reset_device_favorites_handler(event):
def _reset_device_favorites_handler(event: Event) -> None:
"""Handle clearing favorites on device."""
if (token := event.data.get("token")) is None:
return
doorstation = get_doorstation_by_token(hass, token)
door_station = get_door_station_by_token(hass, token)
if doorstation is None:
if door_station is None:
_LOGGER.error("Device not found for provided token")
return
# Clear webhooks
favorites = doorstation.device.favorites()
for favorite_type in favorites:
for favorite_id in favorites[favorite_type]:
doorstation.device.delete_favorite(favorite_type, favorite_id)
favorites: dict[str, list[str]] = door_station.device.favorites()
for favorite_type, favorite_ids in favorites.items():
for favorite_id in favorite_ids:
door_station.device.delete_favorite(favorite_type, favorite_id)
hass.bus.async_listen(RESET_DEVICE_FAVORITES, _reset_device_favorites_handler)
@@ -97,17 +85,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_async_import_options_from_data_if_missing(hass, entry)
doorstation_config = entry.data
doorstation_options = entry.options
door_station_config = entry.data
config_entry_id = entry.entry_id
device_ip = doorstation_config[CONF_HOST]
username = doorstation_config[CONF_USERNAME]
password = doorstation_config[CONF_PASSWORD]
device_ip = door_station_config[CONF_HOST]
username = door_station_config[CONF_USERNAME]
password = door_station_config[CONF_PASSWORD]
device = DoorBird(device_ip, username, password)
try:
status, info = await hass.async_add_executor_job(_init_doorbird_device, device)
status, info = await hass.async_add_executor_job(_init_door_bird_device, device)
except requests.exceptions.HTTPError as err:
if err.response.status_code == HTTPStatus.UNAUTHORIZED:
_LOGGER.error(
@@ -128,50 +115,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
raise ConfigEntryNotReady
token = doorstation_config.get(CONF_TOKEN, config_entry_id)
custom_url = doorstation_config.get(CONF_CUSTOM_URL)
name = doorstation_config.get(CONF_NAME)
events = doorstation_options.get(CONF_EVENTS, [])
doorstation = ConfiguredDoorBird(device, name, custom_url, token)
doorstation.update_events(events)
token: str = door_station_config.get(CONF_TOKEN, config_entry_id)
custom_url: str | None = door_station_config.get(CONF_CUSTOM_URL)
name: str | None = door_station_config.get(CONF_NAME)
events = entry.options.get(CONF_EVENTS, [])
event_entity_ids: dict[str, str] = {}
door_station = ConfiguredDoorBird(device, name, custom_url, token, event_entity_ids)
door_bird_data = DoorBirdData(door_station, info, event_entity_ids)
door_station.update_events(events)
# Subscribe to doorbell or motion events
if not await _async_register_events(hass, doorstation):
if not await _async_register_events(hass, door_station):
raise ConfigEntryNotReady
undo_listener = entry.add_update_listener(_update_listener)
hass.data[DOMAIN][config_entry_id] = {
DOOR_STATION: doorstation,
DOOR_STATION_INFO: info,
UNDO_UPDATE_LISTENER: undo_listener,
}
entry.async_on_unload(entry.add_update_listener(_update_listener))
hass.data[DOMAIN][config_entry_id] = door_bird_data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
def _init_doorbird_device(device):
def _init_door_bird_device(device: DoorBird) -> tuple[tuple[bool, int], dict[str, Any]]:
"""Verify we can connect to the device and return the status."""
return device.ready(), device.info()
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
data: dict[str, DoorBirdData] = hass.data[DOMAIN]
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
data.pop(entry.entry_id)
return unload_ok
async def _async_register_events(
hass: HomeAssistant, doorstation: ConfiguredDoorBird
hass: HomeAssistant, door_station: ConfiguredDoorBird
) -> bool:
try:
await hass.async_add_executor_job(doorstation.register_events, hass)
await hass.async_add_executor_job(door_station.register_events, hass)
except requests.exceptions.HTTPError:
persistent_notification.async_create(
hass,
@@ -192,10 +172,11 @@ async def _async_register_events(
async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
config_entry_id = entry.entry_id
doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION]
doorstation.update_events(entry.options[CONF_EVENTS])
data: DoorBirdData = hass.data[DOMAIN][config_entry_id]
door_station = data.door_station
door_station.update_events(entry.options[CONF_EVENTS])
# Subscribe to doorbell or motion events
await _async_register_events(hass, doorstation)
await _async_register_events(hass, door_station)
@callback
@@ -211,122 +192,6 @@ def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: Confi
hass.config_entries.async_update_entry(entry, options=options)
class ConfiguredDoorBird:
"""Attach additional information to pass along with configured device."""
def __init__(self, device, name, custom_url, token):
"""Initialize configured device."""
self._name = name
self._device = device
self._custom_url = custom_url
self.events = None
self.doorstation_events = None
self._token = token
def update_events(self, events):
"""Update the doorbird events."""
self.events = events
self.doorstation_events = [self._get_event_name(event) for event in self.events]
@property
def name(self):
"""Get custom device name."""
return self._name
@property
def device(self):
"""Get the configured device."""
return self._device
@property
def custom_url(self):
"""Get custom url for device."""
return self._custom_url
@property
def token(self):
"""Get token for device."""
return self._token
def register_events(self, hass: HomeAssistant) -> None:
"""Register events on device."""
# Get the URL of this server
hass_url = get_url(hass, prefer_external=False)
# Override url if another is specified in the configuration
if self.custom_url is not None:
hass_url = self.custom_url
if not self.doorstation_events:
# User may not have permission to get the favorites
return
favorites = self.device.favorites()
for event in self.doorstation_events:
if self._register_event(hass_url, event, favs=favorites):
_LOGGER.info(
"Successfully registered URL for %s on %s", event, self.name
)
@property
def slug(self):
"""Get device slug."""
return slugify(self._name)
def _get_event_name(self, event):
return f"{self.slug}_{event}"
def _register_event(
self, hass_url: str, event: str, favs: dict[str, Any] | None = None
) -> bool:
"""Add a schedule entry in the device for a sensor."""
url = f"{hass_url}{API_URL}/{event}?token={self._token}"
# Register HA URL as webhook if not already, then get the ID
if self.webhook_is_registered(url, favs=favs):
return True
self.device.change_favorite("http", f"Home Assistant ({event})", url)
if not self.webhook_is_registered(url):
_LOGGER.warning(
'Unable to set favorite URL "%s". Event "%s" will not fire',
url,
event,
)
return False
return True
def webhook_is_registered(self, url, favs=None) -> bool:
"""Return whether the given URL is registered as a device favorite."""
return self.get_webhook_id(url, favs) is not None
def get_webhook_id(self, url, favs=None) -> str | None:
"""Return the device favorite ID for the given URL.
The favorite must exist or there will be problems.
"""
favs = favs if favs else self.device.favorites()
if "http" not in favs:
return None
for fav_id in favs["http"]:
if favs["http"][fav_id]["value"] == url:
return fav_id
return None
def get_event_data(self):
"""Get data to pass along with HA event."""
return {
"timestamp": dt_util.utcnow().isoformat(),
"live_video_url": self._device.live_video_url,
"live_image_url": self._device.live_image_url,
"rtsp_live_video_url": self._device.rtsp_live_video_url,
"html5_viewer_url": self._device.html5_viewer_url,
}
class DoorBirdRequestView(HomeAssistantView):
"""Provide a page for the device to call."""
@@ -335,21 +200,17 @@ class DoorBirdRequestView(HomeAssistantView):
name = API_URL[1:].replace("/", ":")
extra_urls = [API_URL + "/{event}"]
async def get(self, request, event):
async def get(self, request: web.Request, event: str) -> web.Response:
"""Respond to requests from the device."""
hass = request.app["hass"]
token = request.query.get("token")
device = get_doorstation_by_token(hass, token)
if device is None:
hass: HomeAssistant = request.app["hass"]
token: str | None = request.query.get("token")
if token is None or (device := get_door_station_by_token(hass, token)) is None:
return web.Response(
status=HTTPStatus.UNAUTHORIZED, text="Invalid token provided."
)
if device:
event_data = device.get_event_data()
event_data = device.get_event_data(event)
else:
event_data = {}
@@ -359,10 +220,6 @@ class DoorBirdRequestView(HomeAssistantView):
message = f"HTTP Favorites cleared for {device.slug}"
return web.Response(text=message)
event_data[ATTR_ENTITY_ID] = hass.data[DOMAIN][
DOOR_STATION_EVENT_ENTITY_IDS
].get(event)
hass.bus.async_fire(f"{DOMAIN}_{event}", event_data)
return web.Response(text="OK")

View File

@@ -10,8 +10,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, DOOR_STATION, DOOR_STATION_INFO
from .const import DOMAIN
from .entity import DoorBirdEntity
from .models import DoorBirdData
IR_RELAY = "__ir_light__"
@@ -49,20 +50,14 @@ async def async_setup_entry(
) -> None:
"""Set up the DoorBird button platform."""
config_entry_id = config_entry.entry_id
data = hass.data[DOMAIN][config_entry_id]
doorstation = data[DOOR_STATION]
doorstation_info = data[DOOR_STATION_INFO]
relays = doorstation_info["RELAYS"]
door_bird_data: DoorBirdData = hass.data[DOMAIN][config_entry_id]
relays = door_bird_data.door_station_info["RELAYS"]
entities = [
DoorBirdButton(doorstation, doorstation_info, relay, RELAY_ENTITY_DESCRIPTION)
DoorBirdButton(door_bird_data, relay, RELAY_ENTITY_DESCRIPTION)
for relay in relays
]
entities.append(
DoorBirdButton(doorstation, doorstation_info, IR_RELAY, IR_ENTITY_DESCRIPTION)
)
entities.append(DoorBirdButton(door_bird_data, IR_RELAY, IR_ENTITY_DESCRIPTION))
async_add_entities(entities)
@@ -74,16 +69,14 @@ class DoorBirdButton(DoorBirdEntity, ButtonEntity):
def __init__(
self,
doorstation: DoorBird,
doorstation_info,
door_bird_data: DoorBirdData,
relay: str,
entity_description: DoorbirdButtonEntityDescription,
) -> None:
"""Initialize a relay in a DoorBird device."""
super().__init__(doorstation, doorstation_info)
super().__init__(door_bird_data)
self._relay = relay
self.entity_description = entity_description
if self._relay == IR_RELAY:
self._attr_name = "IR"
else:
@@ -92,4 +85,4 @@ class DoorBirdButton(DoorBirdEntity, ButtonEntity):
def press(self) -> None:
"""Power the relay."""
self.entity_description.press_action(self._doorstation.device, self._relay)
self.entity_description.press_action(self._door_station.device, self._relay)

View File

@@ -6,7 +6,6 @@ import datetime
import logging
import aiohttp
import async_timeout
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.config_entries import ConfigEntry
@@ -15,13 +14,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.dt as dt_util
from .const import (
DOMAIN,
DOOR_STATION,
DOOR_STATION_EVENT_ENTITY_IDS,
DOOR_STATION_INFO,
)
from .const import DOMAIN
from .entity import DoorBirdEntity
from .models import DoorBirdData
_LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=2)
_LAST_MOTION_INTERVAL = datetime.timedelta(seconds=30)
@@ -37,39 +32,31 @@ async def async_setup_entry(
) -> None:
"""Set up the DoorBird camera platform."""
config_entry_id = config_entry.entry_id
config_data = hass.data[DOMAIN][config_entry_id]
doorstation = config_data[DOOR_STATION]
doorstation_info = config_data[DOOR_STATION_INFO]
device = doorstation.device
door_bird_data: DoorBirdData = hass.data[DOMAIN][config_entry_id]
device = door_bird_data.door_station.device
async_add_entities(
[
DoorBirdCamera(
doorstation,
doorstation_info,
door_bird_data,
device.live_image_url,
"live",
"live",
doorstation.doorstation_events,
_LIVE_INTERVAL,
device.rtsp_live_video_url,
),
DoorBirdCamera(
doorstation,
doorstation_info,
door_bird_data,
device.history_image_url(1, "doorbell"),
"last_ring",
"last_ring",
[],
_LAST_VISITOR_INTERVAL,
),
DoorBirdCamera(
doorstation,
doorstation_info,
door_bird_data,
device.history_image_url(1, "motionsensor"),
"last_motion",
"last_motion",
[],
_LAST_MOTION_INTERVAL,
),
]
@@ -81,17 +68,15 @@ class DoorBirdCamera(DoorBirdEntity, Camera):
def __init__(
self,
doorstation,
doorstation_info,
url,
camera_id,
translation_key,
doorstation_events,
interval,
stream_url=None,
door_bird_data: DoorBirdData,
url: str,
camera_id: str,
translation_key: str,
interval: datetime.timedelta,
stream_url: str | None = None,
) -> None:
"""Initialize the camera on a DoorBird device."""
super().__init__(doorstation, doorstation_info)
super().__init__(door_bird_data)
self._url = url
self._stream_url = stream_url
self._attr_translation_key = translation_key
@@ -101,7 +86,6 @@ class DoorBirdCamera(DoorBirdEntity, Camera):
self._interval = interval
self._last_update = datetime.datetime.min
self._attr_unique_id = f"{self._mac_addr}_{camera_id}"
self._doorstation_events = doorstation_events
async def stream_source(self):
"""Return the stream source."""
@@ -118,7 +102,7 @@ class DoorBirdCamera(DoorBirdEntity, Camera):
try:
websession = async_get_clientsession(self.hass)
async with async_timeout.timeout(_TIMEOUT):
async with asyncio.timeout(_TIMEOUT):
response = await websession.get(self._url)
self._last_image = await response.read()
@@ -134,19 +118,15 @@ class DoorBirdCamera(DoorBirdEntity, Camera):
return self._last_image
async def async_added_to_hass(self) -> None:
"""Add callback after being added to hass.
Registers entity_id map for the logbook
"""
event_to_entity_id = self.hass.data[DOMAIN].setdefault(
DOOR_STATION_EVENT_ENTITY_IDS, {}
)
for event in self._doorstation_events:
"""Subscribe to events."""
await super().async_added_to_hass()
event_to_entity_id = self._door_bird_data.event_entity_ids
for event in self._door_station.events:
event_to_entity_id[event] = self.entity_id
async def will_remove_from_hass(self):
"""Unregister entity_id map for the logbook."""
event_to_entity_id = self.hass.data[DOMAIN][DOOR_STATION_EVENT_ENTITY_IDS]
for event in self._doorstation_events:
if event in event_to_entity_id:
del event_to_entity_id[event]
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from events."""
event_to_entity_id = self._door_bird_data.event_entity_ids
for event in self._door_station.events:
del event_to_entity_id[event]
await super().async_will_remove_from_hass()

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from http import HTTPStatus
from ipaddress import ip_address
import logging
from typing import Any
from doorbirdpy import DoorBird
import requests
@@ -12,12 +13,12 @@ import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.components import zeroconf
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.util.network import is_ipv4_address, is_link_local
from .const import CONF_EVENTS, DOMAIN, DOORBIRD_OUI
from .util import get_mac_address_from_doorstation_info
from .util import get_mac_address_from_door_station_info
_LOGGER = logging.getLogger(__name__)
@@ -33,7 +34,7 @@ def _schema_with_defaults(host=None, name=None):
)
def _check_device(device):
def _check_device(device: DoorBird) -> tuple[tuple[bool, int], dict[str, Any]]:
"""Verify we can connect to the device and return the status."""
return device.ready(), device.info()
@@ -53,13 +54,13 @@ async def validate_input(hass: core.HomeAssistant, data):
if not status[0]:
raise CannotConnect
mac_addr = get_mac_address_from_doorstation_info(info)
mac_addr = get_mac_address_from_door_station_info(info)
# Return info that you want to store in the config entry.
return {"title": data[CONF_HOST], "mac_addr": mac_addr}
async def async_verify_supported_device(hass, host):
async def async_verify_supported_device(hass: HomeAssistant, host: str) -> bool:
"""Verify the doorbell state endpoint returns a 401."""
device = DoorBird(host, "", "")
try:

View File

@@ -19,3 +19,5 @@ DOORBIRD_INFO_KEY_PRIMARY_MAC_ADDR = "PRIMARY_MAC_ADDR"
DOORBIRD_INFO_KEY_WIFI_MAC_ADDR = "WIFI_MAC_ADDR"
UNDO_UPDATE_LISTENER = "undo_update_listener"
API_URL = f"/api/{DOMAIN}"

View File

@@ -0,0 +1,147 @@
"""Support for DoorBird devices."""
from __future__ import annotations
import logging
from typing import Any
from doorbirdpy import DoorBird
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers.network import get_url
from homeassistant.util import dt as dt_util, slugify
from .const import API_URL
_LOGGER = logging.getLogger(__name__)
class ConfiguredDoorBird:
"""Attach additional information to pass along with configured device."""
def __init__(
self,
device: DoorBird,
name: str | None,
custom_url: str | None,
token: str,
event_entity_ids: dict[str, str],
) -> None:
"""Initialize configured device."""
self._name = name
self._device = device
self._custom_url = custom_url
self._token = token
self._event_entity_ids = event_entity_ids
self.events: list[str] = []
self.door_station_events: list[str] = []
def update_events(self, events: list[str]) -> None:
"""Update the doorbird events."""
self.events = events
self.door_station_events = [
self._get_event_name(event) for event in self.events
]
@property
def name(self) -> str | None:
"""Get custom device name."""
return self._name
@property
def device(self) -> DoorBird:
"""Get the configured device."""
return self._device
@property
def custom_url(self) -> str | None:
"""Get custom url for device."""
return self._custom_url
@property
def token(self) -> str:
"""Get token for device."""
return self._token
def register_events(self, hass: HomeAssistant) -> None:
"""Register events on device."""
# Get the URL of this server
hass_url = get_url(hass, prefer_external=False)
# Override url if another is specified in the configuration
if self.custom_url is not None:
hass_url = self.custom_url
if not self.door_station_events:
# User may not have permission to get the favorites
return
favorites = self.device.favorites()
for event in self.door_station_events:
if self._register_event(hass_url, event, favs=favorites):
_LOGGER.info(
"Successfully registered URL for %s on %s", event, self.name
)
@property
def slug(self) -> str:
"""Get device slug."""
return slugify(self._name)
def _get_event_name(self, event: str) -> str:
return f"{self.slug}_{event}"
def _register_event(
self, hass_url: str, event: str, favs: dict[str, Any] | None = None
) -> bool:
"""Add a schedule entry in the device for a sensor."""
url = f"{hass_url}{API_URL}/{event}?token={self._token}"
# Register HA URL as webhook if not already, then get the ID
if self.webhook_is_registered(url, favs=favs):
return True
self.device.change_favorite("http", f"Home Assistant ({event})", url)
if not self.webhook_is_registered(url):
_LOGGER.warning(
'Unable to set favorite URL "%s". Event "%s" will not fire',
url,
event,
)
return False
return True
def webhook_is_registered(
self, url: str, favs: dict[str, Any] | None = None
) -> bool:
"""Return whether the given URL is registered as a device favorite."""
return self.get_webhook_id(url, favs) is not None
def get_webhook_id(
self, url: str, favs: dict[str, Any] | None = None
) -> str | None:
"""Return the device favorite ID for the given URL.
The favorite must exist or there will be problems.
"""
favs = favs if favs else self.device.favorites()
if "http" not in favs:
return None
for fav_id in favs["http"]:
if favs["http"][fav_id]["value"] == url:
return fav_id
return None
def get_event_data(self, event: str) -> dict[str, str | None]:
"""Get data to pass along with HA event."""
return {
"timestamp": dt_util.utcnow().isoformat(),
"live_video_url": self._device.live_video_url,
"live_image_url": self._device.live_image_url,
"rtsp_live_video_url": self._device.rtsp_live_video_url,
"html5_viewer_url": self._device.html5_viewer_url,
ATTR_ENTITY_ID: self._event_entity_ids.get(event),
}

View File

@@ -1,5 +1,6 @@
"""The DoorBird integration base entity."""
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -10,7 +11,8 @@ from .const import (
DOORBIRD_INFO_KEY_FIRMWARE,
MANUFACTURER,
)
from .util import get_mac_address_from_doorstation_info
from .models import DoorBirdData
from .util import get_mac_address_from_door_station_info
class DoorBirdEntity(Entity):
@@ -18,19 +20,20 @@ class DoorBirdEntity(Entity):
_attr_has_entity_name = True
def __init__(self, doorstation, doorstation_info):
def __init__(self, door_bird_data: DoorBirdData) -> None:
"""Initialize the entity."""
super().__init__()
self._doorstation = doorstation
self._mac_addr = get_mac_address_from_doorstation_info(doorstation_info)
firmware = doorstation_info[DOORBIRD_INFO_KEY_FIRMWARE]
firmware_build = doorstation_info[DOORBIRD_INFO_KEY_BUILD_NUMBER]
self._door_bird_data = door_bird_data
self._door_station = door_bird_data.door_station
door_station_info = door_bird_data.door_station_info
self._mac_addr = get_mac_address_from_door_station_info(door_station_info)
firmware = door_station_info[DOORBIRD_INFO_KEY_FIRMWARE]
firmware_build = door_station_info[DOORBIRD_INFO_KEY_BUILD_NUMBER]
self._attr_device_info = DeviceInfo(
configuration_url="https://webadmin.doorbird.com/",
connections={(dr.CONNECTION_NETWORK_MAC, self._mac_addr)},
manufacturer=MANUFACTURER,
model=doorstation_info[DOORBIRD_INFO_KEY_DEVICE_TYPE],
name=self._doorstation.name,
model=door_station_info[DOORBIRD_INFO_KEY_DEVICE_TYPE],
name=self._door_station.name,
sw_version=f"{firmware} {firmware_build}",
)

View File

@@ -1,43 +1,35 @@
"""Describe logbook events."""
from __future__ import annotations
from typing import Any
from homeassistant.components.logbook import (
LOGBOOK_ENTRY_ENTITY_ID,
LOGBOOK_ENTRY_MESSAGE,
LOGBOOK_ENTRY_NAME,
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import callback
from homeassistant.core import Event, HomeAssistant, callback
from .const import DOMAIN, DOOR_STATION, DOOR_STATION_EVENT_ENTITY_IDS
from .const import DOMAIN
from .models import DoorBirdData
@callback
def async_describe_events(hass, async_describe_event):
def async_describe_events(hass: HomeAssistant, async_describe_event):
"""Describe logbook events."""
@callback
def async_describe_logbook_event(event):
def async_describe_logbook_event(event: Event):
"""Describe a logbook event."""
doorbird_event = event.event_type.split("_", 1)[1]
return {
LOGBOOK_ENTRY_NAME: "Doorbird",
LOGBOOK_ENTRY_MESSAGE: f"Event {event.event_type} was fired",
LOGBOOK_ENTRY_ENTITY_ID: hass.data[DOMAIN][
DOOR_STATION_EVENT_ENTITY_IDS
].get(doorbird_event, event.data.get(ATTR_ENTITY_ID)),
# Database entries before Jun 25th 2020 will not have an entity ID
LOGBOOK_ENTRY_ENTITY_ID: event.data.get(ATTR_ENTITY_ID),
}
domain_data: dict[str, Any] = hass.data[DOMAIN]
domain_data: dict[str, DoorBirdData] = hass.data[DOMAIN]
for data in domain_data.values():
if DOOR_STATION not in data:
# We need to skip door_station_event_entity_ids
continue
for event in data[DOOR_STATION].doorstation_events:
for event in data.door_station.door_station_events:
async_describe_event(
DOMAIN, f"{DOMAIN}_{event}", async_describe_logbook_event
)

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