mirror of
https://github.com/home-assistant/core.git
synced 2025-08-09 23:55:07 +02:00
Merge branch 'dev' into feature/starlink-device-tracker
This commit is contained in:
@@ -863,7 +863,6 @@ omit =
|
||||
homeassistant/components/openhome/const.py
|
||||
homeassistant/components/openhome/media_player.py
|
||||
homeassistant/components/opensensemap/air_quality.py
|
||||
homeassistant/components/opensky/sensor.py
|
||||
homeassistant/components/opentherm_gw/__init__.py
|
||||
homeassistant/components/opentherm_gw/binary_sensor.py
|
||||
homeassistant/components/opentherm_gw/climate.py
|
||||
|
@@ -297,8 +297,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/dunehd/ @bieniu
|
||||
/homeassistant/components/duotecno/ @cereal2nd
|
||||
/tests/components/duotecno/ @cereal2nd
|
||||
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo
|
||||
/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo
|
||||
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
|
||||
/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
|
||||
/homeassistant/components/dynalite/ @ziv1234
|
||||
/tests/components/dynalite/ @ziv1234
|
||||
/homeassistant/components/eafm/ @Jc2k
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyatv", "srptools"],
|
||||
"requirements": ["pyatv==0.13.3"],
|
||||
"requirements": ["pyatv==0.13.4"],
|
||||
"zeroconf": [
|
||||
"_mediaremotetv._tcp.local.",
|
||||
"_companion-link._tcp.local.",
|
||||
|
@@ -38,7 +38,7 @@ from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.issue_registry import async_delete_issue
|
||||
from homeassistant.loader import async_get_bluetooth
|
||||
|
||||
from . import models
|
||||
from . import models, passive_update_processor
|
||||
from .api import (
|
||||
_get_manager,
|
||||
async_address_present,
|
||||
@@ -125,6 +125,7 @@ async def _async_get_adapter_from_address(
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the bluetooth integration."""
|
||||
await passive_update_processor.async_setup(hass)
|
||||
integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass))
|
||||
integration_matcher.async_setup()
|
||||
bluetooth_adapters = get_adapters()
|
||||
|
@@ -2,12 +2,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from datetime import timedelta
|
||||
from functools import cache
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
||||
from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast
|
||||
|
||||
from homeassistant.const import ATTR_IDENTIFIERS, ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import (
|
||||
ATTR_IDENTIFIERS,
|
||||
ATTR_NAME,
|
||||
CONF_ENTITY_CATEGORY,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import (
|
||||
DeviceInfo,
|
||||
Entity,
|
||||
EntityDescription,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
|
||||
from .const import DOMAIN
|
||||
from .update_coordinator import BasePassiveBluetoothCoordinator
|
||||
@@ -23,6 +42,12 @@ if TYPE_CHECKING:
|
||||
BluetoothServiceInfoBleak,
|
||||
)
|
||||
|
||||
STORAGE_KEY = "bluetooth.passive_update_processor"
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_SAVE_INTERVAL = timedelta(minutes=15)
|
||||
PASSIVE_UPDATE_PROCESSOR = "passive_update_processor"
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
@dataclasses.dataclass(slots=True, frozen=True)
|
||||
class PassiveBluetoothEntityKey:
|
||||
@@ -36,8 +61,67 @@ class PassiveBluetoothEntityKey:
|
||||
key: str
|
||||
device_id: str | None
|
||||
|
||||
def to_string(self) -> str:
|
||||
"""Convert the key to a string which can be used as JSON key."""
|
||||
return f"{self.key}___{self.device_id or ''}"
|
||||
|
||||
_T = TypeVar("_T")
|
||||
@classmethod
|
||||
def from_string(cls, key: str) -> PassiveBluetoothEntityKey:
|
||||
"""Convert a string (from JSON) to a key."""
|
||||
key, device_id = key.split("___")
|
||||
return cls(key, device_id or None)
|
||||
|
||||
|
||||
@dataclasses.dataclass(slots=True, frozen=False)
|
||||
class PassiveBluetoothProcessorData:
|
||||
"""Data for the passive bluetooth processor."""
|
||||
|
||||
coordinators: set[PassiveBluetoothProcessorCoordinator]
|
||||
all_restore_data: dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]]
|
||||
|
||||
|
||||
class RestoredPassiveBluetoothDataUpdate(TypedDict):
|
||||
"""Restored PassiveBluetoothDataUpdate."""
|
||||
|
||||
devices: dict[str, DeviceInfo]
|
||||
entity_descriptions: dict[str, dict[str, Any]]
|
||||
entity_names: dict[str, str | None]
|
||||
entity_data: dict[str, Any]
|
||||
|
||||
|
||||
# Fields do not change so we can cache the result
|
||||
# of calling fields() on the dataclass
|
||||
cached_fields = cache(dataclasses.fields)
|
||||
|
||||
|
||||
def deserialize_entity_description(
|
||||
descriptions_class: type[EntityDescription], data: dict[str, Any]
|
||||
) -> EntityDescription:
|
||||
"""Deserialize an entity description."""
|
||||
result: dict[str, Any] = {}
|
||||
for field in cached_fields(descriptions_class): # type: ignore[arg-type]
|
||||
field_name = field.name
|
||||
# It would be nice if field.type returned the actual
|
||||
# type instead of a str so we could avoid writing this
|
||||
# out, but it doesn't. If we end up using this in more
|
||||
# places we can add a `as_dict` and a `from_dict`
|
||||
# method to these classes
|
||||
if field_name == CONF_ENTITY_CATEGORY:
|
||||
value = try_parse_enum(EntityCategory, data.get(field_name))
|
||||
else:
|
||||
value = data.get(field_name)
|
||||
result[field_name] = value
|
||||
return descriptions_class(**result)
|
||||
|
||||
|
||||
def serialize_entity_description(description: EntityDescription) -> dict[str, Any]:
|
||||
"""Serialize an entity description."""
|
||||
as_dict = dataclasses.asdict(description)
|
||||
return {
|
||||
field.name: as_dict[field.name]
|
||||
for field in cached_fields(type(description)) # type: ignore[arg-type]
|
||||
if field.default != as_dict.get(field.name)
|
||||
}
|
||||
|
||||
|
||||
@dataclasses.dataclass(slots=True, frozen=True)
|
||||
@@ -62,6 +146,114 @@ class PassiveBluetoothDataUpdate(Generic[_T]):
|
||||
self.entity_data.update(new_data.entity_data)
|
||||
self.entity_names.update(new_data.entity_names)
|
||||
|
||||
def async_get_restore_data(self) -> RestoredPassiveBluetoothDataUpdate:
|
||||
"""Serialize restore data to storage."""
|
||||
return {
|
||||
"devices": {
|
||||
key or "": device_info for key, device_info in self.devices.items()
|
||||
},
|
||||
"entity_descriptions": {
|
||||
key.to_string(): serialize_entity_description(description)
|
||||
for key, description in self.entity_descriptions.items()
|
||||
},
|
||||
"entity_names": {
|
||||
key.to_string(): name for key, name in self.entity_names.items()
|
||||
},
|
||||
"entity_data": {
|
||||
key.to_string(): data for key, data in self.entity_data.items()
|
||||
},
|
||||
}
|
||||
|
||||
@callback
|
||||
def async_set_restore_data(
|
||||
self,
|
||||
restore_data: RestoredPassiveBluetoothDataUpdate,
|
||||
entity_description_class: type[EntityDescription],
|
||||
) -> None:
|
||||
"""Set the restored data from storage."""
|
||||
self.devices.update(
|
||||
{
|
||||
key or None: device_info
|
||||
for key, device_info in restore_data["devices"].items()
|
||||
}
|
||||
)
|
||||
self.entity_descriptions.update(
|
||||
{
|
||||
PassiveBluetoothEntityKey.from_string(
|
||||
key
|
||||
): deserialize_entity_description(entity_description_class, description)
|
||||
for key, description in restore_data["entity_descriptions"].items()
|
||||
if description
|
||||
}
|
||||
)
|
||||
self.entity_names.update(
|
||||
{
|
||||
PassiveBluetoothEntityKey.from_string(key): name
|
||||
for key, name in restore_data["entity_names"].items()
|
||||
}
|
||||
)
|
||||
self.entity_data.update(
|
||||
{
|
||||
PassiveBluetoothEntityKey.from_string(key): cast(_T, data)
|
||||
for key, data in restore_data["entity_data"].items()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def async_register_coordinator_for_restore(
|
||||
hass: HomeAssistant, coordinator: PassiveBluetoothProcessorCoordinator
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Register a coordinator to have its processors data restored."""
|
||||
data: PassiveBluetoothProcessorData = hass.data[PASSIVE_UPDATE_PROCESSOR]
|
||||
coordinators = data.coordinators
|
||||
coordinators.add(coordinator)
|
||||
if restore_key := coordinator.restore_key:
|
||||
coordinator.restore_data = data.all_restore_data.setdefault(restore_key, {})
|
||||
|
||||
@callback
|
||||
def _unregister_coordinator_for_restore() -> None:
|
||||
"""Unregister a coordinator."""
|
||||
coordinators.remove(coordinator)
|
||||
|
||||
return _unregister_coordinator_for_restore
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant) -> None:
|
||||
"""Set up the passive update processor coordinators."""
|
||||
storage: Store[dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]]] = Store(
|
||||
hass, STORAGE_VERSION, STORAGE_KEY
|
||||
)
|
||||
coordinators: set[PassiveBluetoothProcessorCoordinator] = set()
|
||||
all_restore_data: dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]] = (
|
||||
await storage.async_load() or {}
|
||||
)
|
||||
hass.data[PASSIVE_UPDATE_PROCESSOR] = PassiveBluetoothProcessorData(
|
||||
coordinators, all_restore_data
|
||||
)
|
||||
|
||||
async def _async_save_processor_data(_: Any) -> None:
|
||||
"""Save the processor data."""
|
||||
await storage.async_save(
|
||||
{
|
||||
coordinator.restore_key: coordinator.async_get_restore_data()
|
||||
for coordinator in coordinators
|
||||
if coordinator.restore_key
|
||||
}
|
||||
)
|
||||
|
||||
cancel_interval = async_track_time_interval(
|
||||
hass, _async_save_processor_data, STORAGE_SAVE_INTERVAL
|
||||
)
|
||||
|
||||
async def _async_save_processor_data_at_stop(_event: Event) -> None:
|
||||
"""Save the processor data at shutdown."""
|
||||
cancel_interval()
|
||||
await _async_save_processor_data(None)
|
||||
|
||||
hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, _async_save_processor_data_at_stop
|
||||
)
|
||||
|
||||
|
||||
class PassiveBluetoothProcessorCoordinator(
|
||||
Generic[_T], BasePassiveBluetoothCoordinator
|
||||
@@ -90,23 +282,49 @@ class PassiveBluetoothProcessorCoordinator(
|
||||
self._processors: list[PassiveBluetoothDataProcessor] = []
|
||||
self._update_method = update_method
|
||||
self.last_update_success = True
|
||||
self.restore_data: dict[str, RestoredPassiveBluetoothDataUpdate] = {}
|
||||
self.restore_key = None
|
||||
if config_entry := config_entries.current_entry.get():
|
||||
self.restore_key = config_entry.entry_id
|
||||
self._on_stop.append(async_register_coordinator_for_restore(self.hass, self))
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the device is available."""
|
||||
return super().available and self.last_update_success
|
||||
|
||||
@callback
|
||||
def async_get_restore_data(
|
||||
self,
|
||||
) -> dict[str, RestoredPassiveBluetoothDataUpdate]:
|
||||
"""Generate the restore data."""
|
||||
return {
|
||||
processor.restore_key: processor.data.async_get_restore_data()
|
||||
for processor in self._processors
|
||||
if processor.restore_key
|
||||
}
|
||||
|
||||
@callback
|
||||
def async_register_processor(
|
||||
self,
|
||||
processor: PassiveBluetoothDataProcessor,
|
||||
entity_description_class: type[EntityDescription] | None = None,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a processor that subscribes to updates."""
|
||||
processor.async_register_coordinator(self)
|
||||
|
||||
# entity_description_class will become mandatory
|
||||
# in the future, but is optional for now to allow
|
||||
# for a transition period.
|
||||
processor.async_register_coordinator(self, entity_description_class)
|
||||
|
||||
@callback
|
||||
def remove_processor() -> None:
|
||||
"""Remove a processor."""
|
||||
# Save the data before removing the processor
|
||||
# so if they reload its still there
|
||||
if restore_key := processor.restore_key:
|
||||
self.restore_data[restore_key] = processor.data.async_get_restore_data()
|
||||
|
||||
self._processors.remove(processor)
|
||||
|
||||
self._processors.append(processor)
|
||||
@@ -182,12 +400,18 @@ class PassiveBluetoothDataProcessor(Generic[_T]):
|
||||
entity_data: dict[PassiveBluetoothEntityKey, _T]
|
||||
entity_descriptions: dict[PassiveBluetoothEntityKey, EntityDescription]
|
||||
devices: dict[str | None, DeviceInfo]
|
||||
restore_key: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
update_method: Callable[[_T], PassiveBluetoothDataUpdate[_T]],
|
||||
restore_key: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
try:
|
||||
self.restore_key = restore_key or async_get_current_platform().domain
|
||||
except RuntimeError:
|
||||
self.restore_key = None
|
||||
self._listeners: list[
|
||||
Callable[[PassiveBluetoothDataUpdate[_T] | None], None]
|
||||
] = []
|
||||
@@ -202,15 +426,29 @@ class PassiveBluetoothDataProcessor(Generic[_T]):
|
||||
def async_register_coordinator(
|
||||
self,
|
||||
coordinator: PassiveBluetoothProcessorCoordinator,
|
||||
entity_description_class: type[EntityDescription] | None,
|
||||
) -> None:
|
||||
"""Register a coordinator."""
|
||||
self.coordinator = coordinator
|
||||
self.data = PassiveBluetoothDataUpdate()
|
||||
data = self.data
|
||||
# These attributes to access the data in
|
||||
# self.data are for backwards compatibility.
|
||||
self.entity_names = data.entity_names
|
||||
self.entity_data = data.entity_data
|
||||
self.entity_descriptions = data.entity_descriptions
|
||||
self.devices = data.devices
|
||||
if (
|
||||
entity_description_class
|
||||
and (restore_key := self.restore_key)
|
||||
and (restore_data := coordinator.restore_data)
|
||||
and (restored_processor_data := restore_data.get(restore_key))
|
||||
):
|
||||
data.async_set_restore_data(
|
||||
restored_processor_data,
|
||||
entity_description_class,
|
||||
)
|
||||
self.async_update_listeners(data)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "dwd_weather_warnings",
|
||||
"name": "Deutscher Wetterdienst (DWD) Weather Warnings",
|
||||
"codeowners": ["@runningman84", "@stephan192", "@Hummel95", "@andarotajo"],
|
||||
"codeowners": ["@runningman84", "@stephan192", "@andarotajo"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings",
|
||||
"iot_class": "cloud_polling",
|
||||
|
@@ -1,6 +1,8 @@
|
||||
"""The enphase_envoy component."""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -13,13 +15,19 @@ from pyenphase import (
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import CONF_TOKEN, INVALID_AUTH_ERRORS
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1)
|
||||
STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds()
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -36,6 +44,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.username = entry_data[CONF_USERNAME]
|
||||
self.password = entry_data[CONF_PASSWORD]
|
||||
self._setup_complete = False
|
||||
self._cancel_token_refresh: CALLBACK_TYPE | None = None
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
@@ -44,39 +53,92 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
always_update=False,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_refresh_token_if_needed(self, now: datetime.datetime) -> None:
|
||||
"""Proactively refresh token if its stale in case cloud services goes down."""
|
||||
assert isinstance(self.envoy.auth, EnvoyTokenAuth)
|
||||
expire_time = self.envoy.auth.expire_timestamp
|
||||
remain = expire_time - now.timestamp()
|
||||
fresh = remain > STALE_TOKEN_THRESHOLD
|
||||
name = self.name
|
||||
_LOGGER.debug("%s: %s seconds remaining on token fresh=%s", name, remain, fresh)
|
||||
if not fresh:
|
||||
self.hass.async_create_background_task(
|
||||
self._async_try_refresh_token(), "{name} token refresh"
|
||||
)
|
||||
|
||||
async def _async_try_refresh_token(self) -> None:
|
||||
"""Try to refresh token."""
|
||||
assert isinstance(self.envoy.auth, EnvoyTokenAuth)
|
||||
_LOGGER.debug("%s: Trying to refresh token", self.name)
|
||||
try:
|
||||
await self.envoy.auth.refresh()
|
||||
except EnvoyError as err:
|
||||
# If we can't refresh the token, we try again later
|
||||
# If the token actually ends up expiring, we'll
|
||||
# re-authenticate with username/password and get a new token
|
||||
# or log an error if that fails
|
||||
_LOGGER.debug("%s: Error refreshing token: %s", err, self.name)
|
||||
return
|
||||
self._async_update_saved_token()
|
||||
|
||||
@callback
|
||||
def _async_mark_setup_complete(self) -> None:
|
||||
"""Mark setup as complete and setup token refresh if needed."""
|
||||
self._setup_complete = True
|
||||
if self._cancel_token_refresh:
|
||||
self._cancel_token_refresh()
|
||||
self._cancel_token_refresh = None
|
||||
if not isinstance(self.envoy.auth, EnvoyTokenAuth):
|
||||
return
|
||||
self._cancel_token_refresh = async_track_time_interval(
|
||||
self.hass,
|
||||
self._async_refresh_token_if_needed,
|
||||
TOKEN_REFRESH_CHECK_INTERVAL,
|
||||
cancel_on_shutdown=True,
|
||||
)
|
||||
|
||||
async def _async_setup_and_authenticate(self) -> None:
|
||||
"""Set up and authenticate with the envoy."""
|
||||
envoy = self.envoy
|
||||
await envoy.setup()
|
||||
assert envoy.serial_number is not None
|
||||
self.envoy_serial_number = envoy.serial_number
|
||||
|
||||
if token := self.entry.data.get(CONF_TOKEN):
|
||||
try:
|
||||
await envoy.authenticate(token=token)
|
||||
except INVALID_AUTH_ERRORS:
|
||||
# token likely expired or firmware changed
|
||||
# so we fall through to authenticate with username/password
|
||||
pass
|
||||
else:
|
||||
self._setup_complete = True
|
||||
with contextlib.suppress(*INVALID_AUTH_ERRORS):
|
||||
# Always set the username and password
|
||||
# so we can refresh the token if needed
|
||||
await envoy.authenticate(
|
||||
username=self.username, password=self.password, token=token
|
||||
)
|
||||
# The token is valid, but we still want
|
||||
# to refresh it if it's stale right away
|
||||
self._async_refresh_token_if_needed(dt_util.utcnow())
|
||||
return
|
||||
# token likely expired or firmware changed
|
||||
# so we fall through to authenticate with
|
||||
# username/password
|
||||
await self.envoy.authenticate(username=self.username, password=self.password)
|
||||
# Password auth succeeded, so we can update the token
|
||||
# if we are using EnvoyTokenAuth
|
||||
self._async_update_saved_token()
|
||||
|
||||
await envoy.authenticate(username=self.username, password=self.password)
|
||||
assert envoy.auth is not None
|
||||
|
||||
if isinstance(envoy.auth, EnvoyTokenAuth):
|
||||
# update token in config entry so we can
|
||||
# startup without hitting the Cloud API
|
||||
# as long as the token is valid
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.entry,
|
||||
data={
|
||||
**self.entry.data,
|
||||
CONF_TOKEN: envoy.auth.token,
|
||||
},
|
||||
)
|
||||
self._setup_complete = True
|
||||
def _async_update_saved_token(self) -> None:
|
||||
"""Update saved token in config entry."""
|
||||
envoy = self.envoy
|
||||
if not isinstance(envoy.auth, EnvoyTokenAuth):
|
||||
return
|
||||
# update token in config entry so we can
|
||||
# startup without hitting the Cloud API
|
||||
# as long as the token is valid
|
||||
_LOGGER.debug("%s: Updating token in config entry from auth", self.name)
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.entry,
|
||||
data={
|
||||
**self.entry.data,
|
||||
CONF_TOKEN: envoy.auth.token,
|
||||
},
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch all device and sensor data from api."""
|
||||
@@ -85,6 +147,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
if not self._setup_complete:
|
||||
await self._async_setup_and_authenticate()
|
||||
self._async_mark_setup_complete()
|
||||
return (await envoy.update()).raw
|
||||
except INVALID_AUTH_ERRORS as err:
|
||||
if self._setup_complete and tries == 0:
|
||||
|
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"requirements": ["pyenphase==0.9.0"],
|
||||
"requirements": ["pyenphase==0.14.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
@@ -233,7 +233,7 @@ class EnvoyEntity(CoordinatorEntity[EnphaseUpdateCoordinator], SensorEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, envoy_serial_num)},
|
||||
manufacturer="Enphase",
|
||||
model="Envoy",
|
||||
model=coordinator.envoy.part_number or "Envoy",
|
||||
name=envoy_name,
|
||||
sw_version=str(coordinator.envoy.firmware),
|
||||
)
|
||||
|
@@ -262,14 +262,13 @@ def async_fire_triggers(conn: HKDevice, events: dict[tuple[int, int], dict[str,
|
||||
if not trigger_sources:
|
||||
return
|
||||
for (aid, iid), ev in events.items():
|
||||
# If the value is None, we received the event via polling
|
||||
# and we don't want to trigger on that
|
||||
if ev["value"] is None:
|
||||
continue
|
||||
if aid in conn.devices:
|
||||
device_id = conn.devices[aid]
|
||||
if source := trigger_sources.get(device_id):
|
||||
source.fire(iid, ev)
|
||||
# If the value is None, we received the event via polling
|
||||
# and we don't want to trigger on that
|
||||
if ev.get("value") is not None:
|
||||
source.fire(iid, ev)
|
||||
|
||||
|
||||
async def async_get_triggers(
|
||||
|
@@ -5,6 +5,7 @@ from typing import Any
|
||||
|
||||
from aiohomekit.model import Accessory
|
||||
from aiohomekit.model.characteristics import (
|
||||
EVENT_CHARACTERISTICS,
|
||||
Characteristic,
|
||||
CharacteristicPermissions,
|
||||
CharacteristicsTypes,
|
||||
@@ -111,7 +112,10 @@ class HomeKitEntity(Entity):
|
||||
def _setup_characteristic(self, char: Characteristic) -> None:
|
||||
"""Configure an entity based on a HomeKit characteristics metadata."""
|
||||
# Build up a list of (aid, iid) tuples to poll on update()
|
||||
if CharacteristicPermissions.paired_read in char.perms:
|
||||
if (
|
||||
CharacteristicPermissions.paired_read in char.perms
|
||||
and char.type not in EVENT_CHARACTERISTICS
|
||||
):
|
||||
self.pollable_characteristics.append((self._aid, char.iid))
|
||||
|
||||
# Build up a list of (aid, iid) tuples to subscribe to
|
||||
|
@@ -14,6 +14,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohomekit", "commentjson"],
|
||||
"requirements": ["aiohomekit==2.6.13"],
|
||||
"requirements": ["aiohomekit==2.6.14"],
|
||||
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
|
||||
}
|
||||
|
@@ -53,6 +53,7 @@ from .const import ( # noqa: F401
|
||||
)
|
||||
from .cors import setup_cors
|
||||
from .forwarded import async_setup_forwarded
|
||||
from .headers import setup_headers
|
||||
from .request_context import current_request, setup_request_context
|
||||
from .security_filter import setup_security_filter
|
||||
from .static import CACHE_HEADERS, CachingStaticResource
|
||||
@@ -69,6 +70,7 @@ CONF_SSL_PEER_CERTIFICATE: Final = "ssl_peer_certificate"
|
||||
CONF_SSL_KEY: Final = "ssl_key"
|
||||
CONF_CORS_ORIGINS: Final = "cors_allowed_origins"
|
||||
CONF_USE_X_FORWARDED_FOR: Final = "use_x_forwarded_for"
|
||||
CONF_USE_X_FRAME_OPTIONS: Final = "use_x_frame_options"
|
||||
CONF_TRUSTED_PROXIES: Final = "trusted_proxies"
|
||||
CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold"
|
||||
CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled"
|
||||
@@ -118,6 +120,7 @@ HTTP_SCHEMA: Final = vol.All(
|
||||
vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): vol.In(
|
||||
[SSL_INTERMEDIATE, SSL_MODERN]
|
||||
),
|
||||
vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean,
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -136,6 +139,7 @@ class ConfData(TypedDict, total=False):
|
||||
ssl_key: str
|
||||
cors_allowed_origins: list[str]
|
||||
use_x_forwarded_for: bool
|
||||
use_x_frame_options: bool
|
||||
trusted_proxies: list[IPv4Network | IPv6Network]
|
||||
login_attempts_threshold: int
|
||||
ip_ban_enabled: bool
|
||||
@@ -180,6 +184,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
ssl_key = conf.get(CONF_SSL_KEY)
|
||||
cors_origins = conf[CONF_CORS_ORIGINS]
|
||||
use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False)
|
||||
use_x_frame_options = conf[CONF_USE_X_FRAME_OPTIONS]
|
||||
trusted_proxies = conf.get(CONF_TRUSTED_PROXIES) or []
|
||||
is_ban_enabled = conf[CONF_IP_BAN_ENABLED]
|
||||
login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD]
|
||||
@@ -200,6 +205,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
use_x_forwarded_for=use_x_forwarded_for,
|
||||
login_threshold=login_threshold,
|
||||
is_ban_enabled=is_ban_enabled,
|
||||
use_x_frame_options=use_x_frame_options,
|
||||
)
|
||||
|
||||
async def stop_server(event: Event) -> None:
|
||||
@@ -331,6 +337,7 @@ class HomeAssistantHTTP:
|
||||
use_x_forwarded_for: bool,
|
||||
login_threshold: int,
|
||||
is_ban_enabled: bool,
|
||||
use_x_frame_options: bool,
|
||||
) -> None:
|
||||
"""Initialize the server."""
|
||||
self.app[KEY_HASS] = self.hass
|
||||
@@ -348,6 +355,7 @@ class HomeAssistantHTTP:
|
||||
|
||||
await async_setup_auth(self.hass, self.app)
|
||||
|
||||
setup_headers(self.app, use_x_frame_options)
|
||||
setup_cors(self.app, cors_origins)
|
||||
|
||||
if self.ssl_certificate:
|
||||
|
32
homeassistant/components/http/headers.py
Normal file
32
homeassistant/components/http/headers.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Middleware that helps with the control of headers in our responses."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from aiohttp.web import Application, Request, StreamResponse, middleware
|
||||
|
||||
from homeassistant.core import callback
|
||||
|
||||
|
||||
@callback
|
||||
def setup_headers(app: Application, use_x_frame_options: bool) -> None:
|
||||
"""Create headers middleware for the app."""
|
||||
|
||||
@middleware
|
||||
async def headers_middleware(
|
||||
request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
|
||||
) -> StreamResponse:
|
||||
"""Process request and add headers to the responses."""
|
||||
response = await handler(request)
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
|
||||
# Set an empty server header, to prevent aiohttp of setting one.
|
||||
response.headers["Server"] = ""
|
||||
|
||||
if use_x_frame_options:
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
|
||||
return response
|
||||
|
||||
app.middlewares.append(headers_middleware)
|
@@ -82,6 +82,7 @@ from .const import ( # noqa: F401
|
||||
CONF_MIN_TEMP,
|
||||
CONF_MIN_VALUE,
|
||||
CONF_MSG_WAIT,
|
||||
CONF_NAN_VALUE,
|
||||
CONF_PARITY,
|
||||
CONF_PRECISION,
|
||||
CONF_RETRIES,
|
||||
@@ -123,6 +124,7 @@ from .modbus import ModbusHub, async_modbus_setup
|
||||
from .validators import (
|
||||
duplicate_entity_validator,
|
||||
duplicate_modbus_validator,
|
||||
nan_validator,
|
||||
number_validator,
|
||||
scan_interval_validator,
|
||||
struct_validator,
|
||||
@@ -298,6 +300,7 @@ SENSOR_SCHEMA = vol.All(
|
||||
vol.Optional(CONF_SLAVE_COUNT, default=0): cv.positive_int,
|
||||
vol.Optional(CONF_MIN_VALUE): number_validator,
|
||||
vol.Optional(CONF_MAX_VALUE): number_validator,
|
||||
vol.Optional(CONF_NAN_VALUE): nan_validator,
|
||||
vol.Optional(CONF_ZERO_SUPPRESS): number_validator,
|
||||
}
|
||||
),
|
||||
|
@@ -22,6 +22,7 @@ from homeassistant.const import (
|
||||
CONF_STRUCTURE,
|
||||
CONF_UNIQUE_ID,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -46,6 +47,7 @@ from .const import (
|
||||
CONF_LAZY_ERROR,
|
||||
CONF_MAX_VALUE,
|
||||
CONF_MIN_VALUE,
|
||||
CONF_NAN_VALUE,
|
||||
CONF_PRECISION,
|
||||
CONF_SCALE,
|
||||
CONF_STATE_OFF,
|
||||
@@ -101,6 +103,7 @@ class BasePlatform(Entity):
|
||||
|
||||
self._min_value = get_optional_numeric_config(CONF_MIN_VALUE)
|
||||
self._max_value = get_optional_numeric_config(CONF_MAX_VALUE)
|
||||
self._nan_value = entry.get(CONF_NAN_VALUE, None)
|
||||
self._zero_suppress = get_optional_numeric_config(CONF_ZERO_SUPPRESS)
|
||||
|
||||
@abstractmethod
|
||||
@@ -173,8 +176,10 @@ class BaseStructPlatform(BasePlatform, RestoreEntity):
|
||||
registers.reverse()
|
||||
return registers
|
||||
|
||||
def __process_raw_value(self, entry: float | int) -> float | int:
|
||||
"""Process value from sensor with scaling, offset, min/max etc."""
|
||||
def __process_raw_value(self, entry: float | int | str) -> float | int | str:
|
||||
"""Process value from sensor with NaN handling, scaling, offset, min/max etc."""
|
||||
if self._nan_value and entry in (self._nan_value, -self._nan_value):
|
||||
return STATE_UNAVAILABLE
|
||||
val: float | int = self._scale * entry + self._offset
|
||||
if self._min_value is not None and val < self._min_value:
|
||||
return self._min_value
|
||||
@@ -213,6 +218,9 @@ class BaseStructPlatform(BasePlatform, RestoreEntity):
|
||||
# the conversion only when it's absolutely necessary.
|
||||
if isinstance(v_temp, int) and self._precision == 0:
|
||||
v_result.append(str(v_temp))
|
||||
elif v_temp != v_temp: # noqa: PLR0124
|
||||
# NaN float detection replace with None
|
||||
v_result.append("nan") # pragma: no cover
|
||||
else:
|
||||
v_result.append(f"{float(v_temp):.{self._precision}f}")
|
||||
return ",".join(map(str, v_result))
|
||||
@@ -223,8 +231,16 @@ class BaseStructPlatform(BasePlatform, RestoreEntity):
|
||||
# We could convert int to float, and the code would still work; however
|
||||
# we lose some precision, and unit tests will fail. Therefore, we do
|
||||
# the conversion only when it's absolutely necessary.
|
||||
|
||||
# NaN float detection replace with None
|
||||
if val_result != val_result: # noqa: PLR0124
|
||||
return None # pragma: no cover
|
||||
if isinstance(val_result, int) and self._precision == 0:
|
||||
return str(val_result)
|
||||
if isinstance(val_result, str):
|
||||
if val_result == "nan":
|
||||
val_result = STATE_UNAVAILABLE # pragma: no cover
|
||||
return val_result
|
||||
return f"{float(val_result):.{self._precision}f}"
|
||||
|
||||
|
||||
|
@@ -30,6 +30,7 @@ CONF_MAX_VALUE = "max_value"
|
||||
CONF_MIN_TEMP = "min_temp"
|
||||
CONF_MIN_VALUE = "min_value"
|
||||
CONF_MSG_WAIT = "message_wait_milliseconds"
|
||||
CONF_NAN_VALUE = "nan_value"
|
||||
CONF_PARITY = "parity"
|
||||
CONF_REGISTER = "register"
|
||||
CONF_REGISTER_TYPE = "register_type"
|
||||
|
@@ -139,6 +139,20 @@ def number_validator(value: Any) -> int | float:
|
||||
raise vol.Invalid(f"invalid number {value}") from err
|
||||
|
||||
|
||||
def nan_validator(value: Any) -> int:
|
||||
"""Convert nan string to number (can be hex string or int)."""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
return int(value, 16)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise vol.Invalid(f"invalid number {value}") from err
|
||||
|
||||
|
||||
def scan_interval_validator(config: dict) -> dict:
|
||||
"""Control scan_interval."""
|
||||
for hub in config:
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/roborock",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["roborock"],
|
||||
"requirements": ["python-roborock==0.31.1"]
|
||||
"requirements": ["python-roborock==0.32.2"]
|
||||
}
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/schlage",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["pyschlage==2023.8.0"]
|
||||
"requirements": ["pyschlage==2023.8.1"]
|
||||
}
|
||||
|
@@ -6,6 +6,7 @@ from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
@@ -22,6 +23,7 @@ _SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
]
|
||||
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pytrafikverket"],
|
||||
"requirements": ["pytrafikverket==0.3.3"]
|
||||
"requirements": ["pytrafikverket==0.3.5"]
|
||||
}
|
||||
|
@@ -6,6 +6,12 @@ from datetime import date, datetime, time, timedelta
|
||||
import logging
|
||||
|
||||
from pytrafikverket import TrafikverketTrain
|
||||
from pytrafikverket.exceptions import (
|
||||
InvalidAuthentication,
|
||||
MultipleTrainAnnouncementFound,
|
||||
NoTrainAnnouncementFound,
|
||||
UnknownError,
|
||||
)
|
||||
from pytrafikverket.trafikverket_train import StationInfo, TrainStop
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -119,9 +125,13 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]):
|
||||
state = await self._train_api.async_get_next_train_stop(
|
||||
self.from_station, self.to_station, when
|
||||
)
|
||||
except ValueError as error:
|
||||
if "Invalid authentication" in error.args[0]:
|
||||
raise ConfigEntryAuthFailed from error
|
||||
except InvalidAuthentication as error:
|
||||
raise ConfigEntryAuthFailed from error
|
||||
except (
|
||||
NoTrainAnnouncementFound,
|
||||
MultipleTrainAnnouncementFound,
|
||||
UnknownError,
|
||||
) as error:
|
||||
raise UpdateFailed(
|
||||
f"Train departure {when} encountered a problem: {error}"
|
||||
) from error
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/trafikverket_train",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pytrafikverket"],
|
||||
"requirements": ["pytrafikverket==0.3.3"]
|
||||
"requirements": ["pytrafikverket==0.3.5"]
|
||||
}
|
||||
|
@@ -3,9 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from pytrafikverket.trafikverket_train import StationInfo
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -13,37 +11,23 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, CONF_WEEKDAY
|
||||
from homeassistant.const import CONF_NAME, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONF_TIME, DOMAIN
|
||||
from .const import ATTRIBUTION, DOMAIN
|
||||
from .coordinator import TrainData, TVDataUpdateCoordinator
|
||||
|
||||
ATTR_DEPARTURE_STATE = "departure_state"
|
||||
ATTR_CANCELED = "canceled"
|
||||
ATTR_DELAY_TIME = "number_of_minutes_delayed"
|
||||
ATTR_PLANNED_TIME = "planned_time"
|
||||
ATTR_ESTIMATED_TIME = "estimated_time"
|
||||
ATTR_ACTUAL_TIME = "actual_time"
|
||||
ATTR_OTHER_INFORMATION = "other_information"
|
||||
ATTR_DEVIATIONS = "deviations"
|
||||
|
||||
ICON = "mdi:train"
|
||||
SCAN_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrafikverketRequiredKeysMixin:
|
||||
"""Mixin for required keys."""
|
||||
|
||||
value_fn: Callable[[TrainData], StateType | datetime]
|
||||
extra_fn: Callable[[TrainData], dict[str, StateType | datetime]]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -60,16 +44,64 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = (
|
||||
icon="mdi:clock",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda data: data.departure_time,
|
||||
extra_fn=lambda data: {
|
||||
ATTR_DEPARTURE_STATE: data.departure_state,
|
||||
ATTR_CANCELED: data.cancelled,
|
||||
ATTR_DELAY_TIME: data.delayed_time,
|
||||
ATTR_PLANNED_TIME: data.planned_time,
|
||||
ATTR_ESTIMATED_TIME: data.estimated_time,
|
||||
ATTR_ACTUAL_TIME: data.actual_time,
|
||||
ATTR_OTHER_INFORMATION: data.other_info,
|
||||
ATTR_DEVIATIONS: data.deviation,
|
||||
},
|
||||
),
|
||||
TrafikverketSensorEntityDescription(
|
||||
key="departure_state",
|
||||
translation_key="departure_state",
|
||||
icon="mdi:clock",
|
||||
value_fn=lambda data: data.departure_state,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["on_time", "delayed", "canceled"],
|
||||
),
|
||||
TrafikverketSensorEntityDescription(
|
||||
key="cancelled",
|
||||
translation_key="cancelled",
|
||||
icon="mdi:alert",
|
||||
value_fn=lambda data: data.cancelled,
|
||||
),
|
||||
TrafikverketSensorEntityDescription(
|
||||
key="delayed_time",
|
||||
translation_key="delayed_time",
|
||||
icon="mdi:clock",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
value_fn=lambda data: data.delayed_time,
|
||||
),
|
||||
TrafikverketSensorEntityDescription(
|
||||
key="planned_time",
|
||||
translation_key="planned_time",
|
||||
icon="mdi:clock",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda data: data.planned_time,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
TrafikverketSensorEntityDescription(
|
||||
key="estimated_time",
|
||||
translation_key="estimated_time",
|
||||
icon="mdi:clock",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda data: data.estimated_time,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
TrafikverketSensorEntityDescription(
|
||||
key="actual_time",
|
||||
translation_key="actual_time",
|
||||
icon="mdi:clock",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda data: data.actual_time,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
TrafikverketSensorEntityDescription(
|
||||
key="other_info",
|
||||
translation_key="other_info",
|
||||
icon="mdi:information-variant",
|
||||
value_fn=lambda data: data.other_info,
|
||||
),
|
||||
TrafikverketSensorEntityDescription(
|
||||
key="deviation",
|
||||
translation_key="deviation",
|
||||
icon="mdi:alert",
|
||||
value_fn=lambda data: data.deviation,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -81,71 +113,48 @@ async def async_setup_entry(
|
||||
|
||||
coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
to_station = coordinator.to_station
|
||||
from_station = coordinator.from_station
|
||||
get_time: str | None = entry.data.get(CONF_TIME)
|
||||
train_time = dt_util.parse_time(get_time) if get_time else None
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
TrainSensor(
|
||||
coordinator,
|
||||
entry.data[CONF_NAME],
|
||||
from_station,
|
||||
to_station,
|
||||
entry.data[CONF_WEEKDAY],
|
||||
train_time,
|
||||
entry.entry_id,
|
||||
description,
|
||||
)
|
||||
TrainSensor(coordinator, entry.data[CONF_NAME], entry.entry_id, description)
|
||||
for description in SENSOR_TYPES
|
||||
],
|
||||
True,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TrainSensor(CoordinatorEntity[TVDataUpdateCoordinator], SensorEntity):
|
||||
"""Contains data about a train depature."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: TrafikverketSensorEntityDescription
|
||||
_attr_attribution = ATTRIBUTION
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: TVDataUpdateCoordinator,
|
||||
name: str,
|
||||
from_station: StationInfo,
|
||||
to_station: StationInfo,
|
||||
weekday: list,
|
||||
departuretime: time | None,
|
||||
entry_id: str,
|
||||
entity_description: TrafikverketSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{entry_id}-{entity_description.key}"
|
||||
self.entity_description = entity_description
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, entry_id)},
|
||||
manufacturer="Trafikverket",
|
||||
model="v2.0",
|
||||
name=name,
|
||||
configuration_url="https://api.trafikinfo.trafikverket.se/",
|
||||
)
|
||||
self._attr_unique_id = f"{entry_id}-{entity_description.key}"
|
||||
self._update_attr()
|
||||
|
||||
@callback
|
||||
def _update_attr(self) -> None:
|
||||
"""Update _attr."""
|
||||
self._attr_native_value = self.entity_description.value_fn(
|
||||
self.coordinator.data
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
self._update_attr()
|
||||
return super()._handle_coordinator_update()
|
||||
|
||||
@callback
|
||||
def _update_attr(self) -> None:
|
||||
"""Retrieve latest states."""
|
||||
self._attr_native_value = self.entity_description.value_fn(
|
||||
self.coordinator.data
|
||||
)
|
||||
self._attr_extra_state_attributes = self.entity_description.extra_fn(
|
||||
self.coordinator.data
|
||||
)
|
||||
|
@@ -45,38 +45,36 @@
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"departure_time": {
|
||||
"name": "Departure time",
|
||||
"state_attributes": {
|
||||
"departure_state": {
|
||||
"name": "Departure state",
|
||||
"state": {
|
||||
"on_time": "On time",
|
||||
"delayed": "Delayed",
|
||||
"canceled": "Cancelled"
|
||||
}
|
||||
},
|
||||
"canceled": {
|
||||
"name": "Cancelled"
|
||||
},
|
||||
"number_of_minutes_delayed": {
|
||||
"name": "Minutes delayed"
|
||||
},
|
||||
"planned_time": {
|
||||
"name": "Planned time"
|
||||
},
|
||||
"estimated_time": {
|
||||
"name": "Estimated time"
|
||||
},
|
||||
"actual_time": {
|
||||
"name": "Actual time"
|
||||
},
|
||||
"other_information": {
|
||||
"name": "Other information"
|
||||
},
|
||||
"deviations": {
|
||||
"name": "Deviations"
|
||||
}
|
||||
"name": "Departure time"
|
||||
},
|
||||
"departure_state": {
|
||||
"name": "Departure state",
|
||||
"state": {
|
||||
"on_time": "On time",
|
||||
"delayed": "Delayed",
|
||||
"canceled": "Cancelled"
|
||||
}
|
||||
},
|
||||
"cancelled": {
|
||||
"name": "Cancelled"
|
||||
},
|
||||
"delayed_time": {
|
||||
"name": "Delayed time"
|
||||
},
|
||||
"planned_time": {
|
||||
"name": "Planned time"
|
||||
},
|
||||
"estimated_time": {
|
||||
"name": "Estimated time"
|
||||
},
|
||||
"actual_time": {
|
||||
"name": "Actual time"
|
||||
},
|
||||
"other_info": {
|
||||
"name": "Other information"
|
||||
},
|
||||
"deviation": {
|
||||
"name": "Deviation"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pytrafikverket"],
|
||||
"requirements": ["pytrafikverket==0.3.3"]
|
||||
"requirements": ["pytrafikverket==0.3.5"]
|
||||
}
|
||||
|
@@ -18,10 +18,9 @@ async def async_setup_entry(
|
||||
"""Set up Velbus switch based on config_entry."""
|
||||
await hass.data[DOMAIN][entry.entry_id]["tsk"]
|
||||
cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"]
|
||||
entities = []
|
||||
for channel in cntrl.get_all("binary_sensor"):
|
||||
entities.append(VelbusBinarySensor(channel))
|
||||
async_add_entities(entities)
|
||||
async_add_entities(
|
||||
VelbusBinarySensor(channel) for channel in cntrl.get_all("binary_sensor")
|
||||
)
|
||||
|
||||
|
||||
class VelbusBinarySensor(VelbusEntity, BinarySensorEntity):
|
||||
|
@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import VelbusEntity
|
||||
from .entity import VelbusEntity, api_call
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -24,10 +24,7 @@ async def async_setup_entry(
|
||||
"""Set up Velbus switch based on config_entry."""
|
||||
await hass.data[DOMAIN][entry.entry_id]["tsk"]
|
||||
cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"]
|
||||
entities = []
|
||||
for channel in cntrl.get_all("button"):
|
||||
entities.append(VelbusButton(channel))
|
||||
async_add_entities(entities)
|
||||
async_add_entities(VelbusButton(channel) for channel in cntrl.get_all("button"))
|
||||
|
||||
|
||||
class VelbusButton(VelbusEntity, ButtonEntity):
|
||||
@@ -37,6 +34,7 @@ class VelbusButton(VelbusEntity, ButtonEntity):
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
@api_call
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self._channel.press()
|
||||
|
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, PRESET_MODES
|
||||
from .entity import VelbusEntity
|
||||
from .entity import VelbusEntity, api_call
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -27,10 +27,7 @@ async def async_setup_entry(
|
||||
"""Set up Velbus switch based on config_entry."""
|
||||
await hass.data[DOMAIN][entry.entry_id]["tsk"]
|
||||
cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"]
|
||||
entities = []
|
||||
for channel in cntrl.get_all("climate"):
|
||||
entities.append(VelbusClimate(channel))
|
||||
async_add_entities(entities)
|
||||
async_add_entities(VelbusClimate(channel) for channel in cntrl.get_all("climate"))
|
||||
|
||||
|
||||
class VelbusClimate(VelbusEntity, ClimateEntity):
|
||||
@@ -67,6 +64,7 @@ class VelbusClimate(VelbusEntity, ClimateEntity):
|
||||
"""Return the current temperature."""
|
||||
return self._channel.get_state()
|
||||
|
||||
@api_call
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperatures."""
|
||||
if (temp := kwargs.get(ATTR_TEMPERATURE)) is None:
|
||||
@@ -74,6 +72,7 @@ class VelbusClimate(VelbusEntity, ClimateEntity):
|
||||
await self._channel.set_temp(temp)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@api_call
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set the new preset mode."""
|
||||
await self._channel.set_preset(PRESET_MODES[preset_mode])
|
||||
|
@@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import VelbusEntity
|
||||
from .entity import VelbusEntity, api_call
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -81,18 +81,22 @@ class VelbusCover(VelbusEntity, CoverEntity):
|
||||
return 100 - pos
|
||||
return None
|
||||
|
||||
@api_call
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
await self._channel.open()
|
||||
|
||||
@api_call
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the cover."""
|
||||
await self._channel.close()
|
||||
|
||||
@api_call
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
await self._channel.stop()
|
||||
|
||||
@api_call
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
await self._channel.set_position(100 - kwargs[ATTR_POSITION])
|
||||
|
@@ -1,8 +1,13 @@
|
||||
"""Support for Velbus devices."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate, ParamSpec, TypeVar
|
||||
|
||||
from velbusaio.channels import Channel as VelbusChannel
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -35,3 +40,25 @@ class VelbusEntity(Entity):
|
||||
|
||||
async def _on_update(self) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
_T = TypeVar("_T", bound="VelbusEntity")
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
def api_call(
|
||||
func: Callable[Concatenate[_T, _P], Awaitable[None]]
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
|
||||
"""Catch command exceptions."""
|
||||
|
||||
@wraps(func)
|
||||
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
"""Wrap all command methods."""
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except OSError as exc:
|
||||
raise HomeAssistantError(
|
||||
f"Could not execute {func.__name__} service for {self.name}"
|
||||
) from exc
|
||||
|
||||
return cmd_wrapper
|
||||
|
@@ -26,7 +26,7 @@ from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import VelbusEntity
|
||||
from .entity import VelbusEntity, api_call
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -63,6 +63,7 @@ class VelbusLight(VelbusEntity, LightEntity):
|
||||
"""Return the brightness of the light."""
|
||||
return int((self._channel.get_dimmer_state() * 255) / 100)
|
||||
|
||||
@api_call
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Instruct the Velbus light to turn on."""
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
@@ -83,6 +84,7 @@ class VelbusLight(VelbusEntity, LightEntity):
|
||||
)
|
||||
await getattr(self._channel, attr)(*args)
|
||||
|
||||
@api_call
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the velbus light to turn off."""
|
||||
attr, *args = (
|
||||
@@ -113,6 +115,7 @@ class VelbusButtonLight(VelbusEntity, LightEntity):
|
||||
"""Return true if the light is on."""
|
||||
return self._channel.is_on()
|
||||
|
||||
@api_call
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Instruct the Velbus light to turn on."""
|
||||
if ATTR_FLASH in kwargs:
|
||||
@@ -126,6 +129,7 @@ class VelbusButtonLight(VelbusEntity, LightEntity):
|
||||
attr, *args = "set_led_state", "on"
|
||||
await getattr(self._channel, attr)(*args)
|
||||
|
||||
@api_call
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the velbus light to turn off."""
|
||||
attr, *args = "set_led_state", "off"
|
||||
|
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import VelbusEntity
|
||||
from .entity import VelbusEntity, api_call
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -37,6 +37,7 @@ class VelbusSelect(VelbusEntity, SelectEntity):
|
||||
self._attr_options = self._channel.get_options()
|
||||
self._attr_unique_id = f"{self._attr_unique_id}-program_select"
|
||||
|
||||
@api_call
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Update the program on the module."""
|
||||
await self._channel.set_selected_program(option)
|
||||
|
@@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import VelbusEntity
|
||||
from .entity import VelbusEntity, api_call
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -36,10 +36,12 @@ class VelbusSwitch(VelbusEntity, SwitchEntity):
|
||||
"""Return true if the switch is on."""
|
||||
return self._channel.is_on()
|
||||
|
||||
@api_call
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Instruct the switch to turn on."""
|
||||
await self._channel.turn_on()
|
||||
|
||||
@api_call
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the switch to turn off."""
|
||||
await self._channel.turn_off()
|
||||
|
@@ -1048,6 +1048,12 @@ class WeatherEntity(Entity):
|
||||
self, forecast_types: Iterable[Literal["daily", "hourly", "twice_daily"]] | None
|
||||
) -> None:
|
||||
"""Push updated forecast to all listeners."""
|
||||
if not hasattr(self, "_forecast_listeners"):
|
||||
# Required for entities initiated with `update_before_add`
|
||||
# as `self._forecast_listeners` has not yet been set.
|
||||
# `async_internal_added_to_hass()` will execute once entity has been added.
|
||||
return
|
||||
|
||||
if forecast_types is None:
|
||||
forecast_types = {"daily", "hourly", "twice_daily"}
|
||||
for forecast_type in forecast_types:
|
||||
|
@@ -145,16 +145,26 @@ async def async_handle_webhook(
|
||||
return Response(status=HTTPStatus.METHOD_NOT_ALLOWED)
|
||||
|
||||
if webhook["local_only"] in (True, None) and not isinstance(request, MockRequest):
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(request, Request)
|
||||
assert request.remote is not None
|
||||
try:
|
||||
remote = ip_address(request.remote)
|
||||
except ValueError:
|
||||
_LOGGER.debug("Unable to parse remote ip %s", request.remote)
|
||||
return Response(status=HTTPStatus.OK)
|
||||
if has_cloud := "cloud" in hass.config.components:
|
||||
from hass_nabucasa import remote # pylint: disable=import-outside-toplevel
|
||||
|
||||
if not network.is_local(remote):
|
||||
is_local = True
|
||||
if has_cloud and remote.is_cloud_request.get():
|
||||
is_local = False
|
||||
else:
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(request, Request)
|
||||
assert request.remote is not None
|
||||
|
||||
try:
|
||||
request_remote = ip_address(request.remote)
|
||||
except ValueError:
|
||||
_LOGGER.debug("Unable to parse remote ip %s", request.remote)
|
||||
return Response(status=HTTPStatus.OK)
|
||||
|
||||
is_local = network.is_local(request_remote)
|
||||
|
||||
if not is_local:
|
||||
_LOGGER.warning("Received remote request for local webhook %s", webhook_id)
|
||||
if webhook["local_only"]:
|
||||
return Response(status=HTTPStatus.OK)
|
||||
|
@@ -17,7 +17,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["async_upnp_client", "yeelight"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["yeelight==0.7.12", "async-upnp-client==0.34.1"],
|
||||
"requirements": ["yeelight==0.7.13", "async-upnp-client==0.34.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_miio._udp.local.",
|
||||
|
@@ -964,10 +964,9 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager):
|
||||
|
||||
Handler key is the domain of the component that we want to set up.
|
||||
"""
|
||||
await _load_integration(self.hass, handler_key, self._hass_config)
|
||||
if (handler := HANDLERS.get(handler_key)) is None:
|
||||
raise data_entry_flow.UnknownHandler
|
||||
|
||||
handler = await _async_get_flow_handler(
|
||||
self.hass, handler_key, self._hass_config
|
||||
)
|
||||
if not context or "source" not in context:
|
||||
raise KeyError("Context not set or doesn't have a source set")
|
||||
|
||||
@@ -1830,12 +1829,8 @@ class OptionsFlowManager(data_entry_flow.FlowManager):
|
||||
if entry is None:
|
||||
raise UnknownEntry(handler_key)
|
||||
|
||||
await _load_integration(self.hass, entry.domain, {})
|
||||
|
||||
if entry.domain not in HANDLERS:
|
||||
raise data_entry_flow.UnknownHandler
|
||||
|
||||
return HANDLERS[entry.domain].async_get_options_flow(entry)
|
||||
handler = await _async_get_flow_handler(self.hass, entry.domain, {})
|
||||
return handler.async_get_options_flow(entry)
|
||||
|
||||
async def async_finish_flow(
|
||||
self, flow: data_entry_flow.FlowHandler, result: data_entry_flow.FlowResult
|
||||
@@ -2021,9 +2016,15 @@ async def support_remove_from_device(hass: HomeAssistant, domain: str) -> bool:
|
||||
return hasattr(component, "async_remove_config_entry_device")
|
||||
|
||||
|
||||
async def _load_integration(
|
||||
async def _async_get_flow_handler(
|
||||
hass: HomeAssistant, domain: str, hass_config: ConfigType
|
||||
) -> None:
|
||||
) -> type[ConfigFlow]:
|
||||
"""Get a flow handler for specified domain."""
|
||||
|
||||
# First check if there is a handler registered for the domain
|
||||
if domain in hass.config.components and (handler := HANDLERS.get(domain)):
|
||||
return handler
|
||||
|
||||
try:
|
||||
integration = await loader.async_get_integration(hass, domain)
|
||||
except loader.IntegrationNotFound as err:
|
||||
@@ -2042,3 +2043,8 @@ async def _load_integration(
|
||||
err,
|
||||
)
|
||||
raise data_entry_flow.UnknownHandler
|
||||
|
||||
if handler := HANDLERS.get(domain):
|
||||
return handler
|
||||
|
||||
raise data_entry_flow.UnknownHandler
|
||||
|
@@ -30,7 +30,7 @@ janus==1.0.0
|
||||
Jinja2==3.1.2
|
||||
lru-dict==1.2.0
|
||||
mutagen==1.46.0
|
||||
orjson==3.9.2
|
||||
orjson==3.9.3
|
||||
packaging>=23.1
|
||||
paho-mqtt==1.6.1
|
||||
Pillow==10.0.0
|
||||
|
@@ -44,7 +44,7 @@ dependencies = [
|
||||
"cryptography==41.0.3",
|
||||
# pyOpenSSL 23.2.0 is required to work with cryptography 41+
|
||||
"pyOpenSSL==23.2.0",
|
||||
"orjson==3.9.2",
|
||||
"orjson==3.9.3",
|
||||
"packaging>=23.1",
|
||||
"pip>=21.3.1",
|
||||
"python-slugify==4.0.1",
|
||||
|
@@ -18,7 +18,7 @@ lru-dict==1.2.0
|
||||
PyJWT==2.8.0
|
||||
cryptography==41.0.3
|
||||
pyOpenSSL==23.2.0
|
||||
orjson==3.9.2
|
||||
orjson==3.9.3
|
||||
packaging>=23.1
|
||||
pip>=21.3.1
|
||||
python-slugify==4.0.1
|
||||
|
@@ -249,7 +249,7 @@ aioguardian==2022.07.0
|
||||
aioharmony==0.2.10
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==2.6.13
|
||||
aiohomekit==2.6.14
|
||||
|
||||
# homeassistant.components.emulated_hue
|
||||
# homeassistant.components.http
|
||||
@@ -1569,7 +1569,7 @@ pyatag==0.3.5.3
|
||||
pyatmo==7.5.0
|
||||
|
||||
# homeassistant.components.apple_tv
|
||||
pyatv==0.13.3
|
||||
pyatv==0.13.4
|
||||
|
||||
# homeassistant.components.aussie_broadband
|
||||
pyaussiebb==0.0.15
|
||||
@@ -1662,7 +1662,7 @@ pyedimax==0.2.1
|
||||
pyefergy==22.1.1
|
||||
|
||||
# homeassistant.components.enphase_envoy
|
||||
pyenphase==0.9.0
|
||||
pyenphase==0.14.1
|
||||
|
||||
# homeassistant.components.envisalink
|
||||
pyenvisalink==4.6
|
||||
@@ -1982,7 +1982,7 @@ pysabnzbd==1.1.1
|
||||
pysaj==0.0.16
|
||||
|
||||
# homeassistant.components.schlage
|
||||
pyschlage==2023.8.0
|
||||
pyschlage==2023.8.1
|
||||
|
||||
# homeassistant.components.sensibo
|
||||
pysensibo==1.0.33
|
||||
@@ -2153,7 +2153,7 @@ python-qbittorrent==0.4.3
|
||||
python-ripple-api==0.0.3
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==0.31.1
|
||||
python-roborock==0.32.2
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.33
|
||||
@@ -2191,7 +2191,7 @@ pytradfri[async]==9.0.1
|
||||
# homeassistant.components.trafikverket_ferry
|
||||
# homeassistant.components.trafikverket_train
|
||||
# homeassistant.components.trafikverket_weatherstation
|
||||
pytrafikverket==0.3.3
|
||||
pytrafikverket==0.3.5
|
||||
|
||||
# homeassistant.components.usb
|
||||
pyudev==0.23.2
|
||||
@@ -2728,7 +2728,7 @@ yalexs-ble==2.2.3
|
||||
yalexs==1.5.1
|
||||
|
||||
# homeassistant.components.yeelight
|
||||
yeelight==0.7.12
|
||||
yeelight==0.7.13
|
||||
|
||||
# homeassistant.components.yeelightsunflower
|
||||
yeelightsunflower==0.0.10
|
||||
|
@@ -227,7 +227,7 @@ aioguardian==2022.07.0
|
||||
aioharmony==0.2.10
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==2.6.13
|
||||
aiohomekit==2.6.14
|
||||
|
||||
# homeassistant.components.emulated_hue
|
||||
# homeassistant.components.http
|
||||
@@ -1175,7 +1175,7 @@ pyatag==0.3.5.3
|
||||
pyatmo==7.5.0
|
||||
|
||||
# homeassistant.components.apple_tv
|
||||
pyatv==0.13.3
|
||||
pyatv==0.13.4
|
||||
|
||||
# homeassistant.components.aussie_broadband
|
||||
pyaussiebb==0.0.15
|
||||
@@ -1229,7 +1229,7 @@ pyeconet==0.1.20
|
||||
pyefergy==22.1.1
|
||||
|
||||
# homeassistant.components.enphase_envoy
|
||||
pyenphase==0.9.0
|
||||
pyenphase==0.14.1
|
||||
|
||||
# homeassistant.components.everlights
|
||||
pyeverlights==0.1.0
|
||||
@@ -1474,7 +1474,7 @@ pyrympro==0.0.7
|
||||
pysabnzbd==1.1.1
|
||||
|
||||
# homeassistant.components.schlage
|
||||
pyschlage==2023.8.0
|
||||
pyschlage==2023.8.1
|
||||
|
||||
# homeassistant.components.sensibo
|
||||
pysensibo==1.0.33
|
||||
@@ -1582,7 +1582,7 @@ python-picnic-api==1.1.0
|
||||
python-qbittorrent==0.4.3
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==0.31.1
|
||||
python-roborock==0.32.2
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.33
|
||||
@@ -1611,7 +1611,7 @@ pytradfri[async]==9.0.1
|
||||
# homeassistant.components.trafikverket_ferry
|
||||
# homeassistant.components.trafikverket_train
|
||||
# homeassistant.components.trafikverket_weatherstation
|
||||
pytrafikverket==0.3.3
|
||||
pytrafikverket==0.3.5
|
||||
|
||||
# homeassistant.components.usb
|
||||
pyudev==0.23.2
|
||||
@@ -2010,7 +2010,7 @@ yalexs-ble==2.2.3
|
||||
yalexs==1.5.1
|
||||
|
||||
# homeassistant.components.yeelight
|
||||
yeelight==0.7.12
|
||||
yeelight==0.7.13
|
||||
|
||||
# homeassistant.components.yolink
|
||||
yolink-api==0.3.0
|
||||
|
@@ -1,15 +1,18 @@
|
||||
"""Tests for the Bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from home_assistant_bluetooth import BluetoothServiceInfo
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
@@ -22,13 +25,19 @@ from homeassistant.components.bluetooth import (
|
||||
)
|
||||
from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
STORAGE_KEY,
|
||||
PassiveBluetoothDataProcessor,
|
||||
PassiveBluetoothDataUpdate,
|
||||
PassiveBluetoothEntityKey,
|
||||
PassiveBluetoothProcessorCoordinator,
|
||||
PassiveBluetoothProcessorEntity,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription
|
||||
from homeassistant.components.sensor import (
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
SensorDeviceClass,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import current_entry
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import CoreState, HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
@@ -41,7 +50,12 @@ from . import (
|
||||
patch_all_discovered_devices,
|
||||
)
|
||||
|
||||
from tests.common import MockEntityPlatform, async_fire_time_changed
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
MockEntityPlatform,
|
||||
async_fire_time_changed,
|
||||
async_test_home_assistant,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -1092,6 +1106,18 @@ BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
|
||||
)
|
||||
|
||||
|
||||
DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
|
||||
devices={
|
||||
None: DeviceInfo(
|
||||
name="Test Device", model="Test Model", manufacturer="Test Manufacturer"
|
||||
),
|
||||
},
|
||||
entity_data={},
|
||||
entity_names={},
|
||||
entity_descriptions={},
|
||||
)
|
||||
|
||||
|
||||
async def test_integration_multiple_entity_platforms(
|
||||
hass: HomeAssistant,
|
||||
mock_bleak_scanner_start: MagicMock,
|
||||
@@ -1118,21 +1144,21 @@ async def test_integration_multiple_entity_platforms(
|
||||
binary_sensor_processor = PassiveBluetoothDataProcessor(
|
||||
lambda service_info: BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE
|
||||
)
|
||||
sesnor_processor = PassiveBluetoothDataProcessor(
|
||||
sensor_processor = PassiveBluetoothDataProcessor(
|
||||
lambda service_info: SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE
|
||||
)
|
||||
|
||||
coordinator.async_register_processor(binary_sensor_processor)
|
||||
coordinator.async_register_processor(sesnor_processor)
|
||||
coordinator.async_register_processor(sensor_processor)
|
||||
cancel_coordinator = coordinator.async_start()
|
||||
|
||||
binary_sensor_processor.async_add_listener(MagicMock())
|
||||
sesnor_processor.async_add_listener(MagicMock())
|
||||
sensor_processor.async_add_listener(MagicMock())
|
||||
|
||||
mock_add_sensor_entities = MagicMock()
|
||||
mock_add_binary_sensor_entities = MagicMock()
|
||||
|
||||
sesnor_processor.async_add_entities_listener(
|
||||
sensor_processor.async_add_entities_listener(
|
||||
PassiveBluetoothProcessorEntity,
|
||||
mock_add_sensor_entities,
|
||||
)
|
||||
@@ -1146,14 +1172,14 @@ async def test_integration_multiple_entity_platforms(
|
||||
assert len(mock_add_binary_sensor_entities.mock_calls) == 1
|
||||
assert len(mock_add_sensor_entities.mock_calls) == 1
|
||||
|
||||
binary_sesnor_entities = [
|
||||
binary_sensor_entities = [
|
||||
*mock_add_binary_sensor_entities.mock_calls[0][1][0],
|
||||
]
|
||||
sesnor_entities = [
|
||||
sensor_entities = [
|
||||
*mock_add_sensor_entities.mock_calls[0][1][0],
|
||||
]
|
||||
|
||||
sensor_entity_one: PassiveBluetoothProcessorEntity = sesnor_entities[0]
|
||||
sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0]
|
||||
sensor_entity_one.hass = hass
|
||||
assert sensor_entity_one.available is True
|
||||
assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure"
|
||||
@@ -1167,7 +1193,7 @@ async def test_integration_multiple_entity_platforms(
|
||||
key="pressure", device_id=None
|
||||
)
|
||||
|
||||
binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sesnor_entities[
|
||||
binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[
|
||||
0
|
||||
]
|
||||
binary_sensor_entity_one.hass = hass
|
||||
@@ -1242,3 +1268,281 @@ async def test_exception_from_coordinator_update_method(
|
||||
assert processor.available is True
|
||||
unregister_processor()
|
||||
cancel_coordinator()
|
||||
|
||||
|
||||
async def test_integration_multiple_entity_platforms_with_reload_and_restart(
|
||||
hass: HomeAssistant,
|
||||
mock_bleak_scanner_start: MagicMock,
|
||||
mock_bluetooth_adapters: None,
|
||||
hass_storage: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test integration of PassiveBluetoothProcessorCoordinator with multiple platforms with reload."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={})
|
||||
|
||||
@callback
|
||||
def _mock_update_method(
|
||||
service_info: BluetoothServiceInfo,
|
||||
) -> dict[str, str]:
|
||||
return {"test": "data"}
|
||||
|
||||
current_entry.set(entry)
|
||||
coordinator = PassiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
"aa:bb:cc:dd:ee:ff",
|
||||
BluetoothScanningMode.ACTIVE,
|
||||
_mock_update_method,
|
||||
)
|
||||
assert coordinator.available is False # no data yet
|
||||
|
||||
binary_sensor_processor = PassiveBluetoothDataProcessor(
|
||||
lambda service_info: BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE,
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
)
|
||||
sensor_processor = PassiveBluetoothDataProcessor(
|
||||
lambda service_info: SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE, SENSOR_DOMAIN
|
||||
)
|
||||
|
||||
unregister_binary_sensor_processor = coordinator.async_register_processor(
|
||||
binary_sensor_processor, BinarySensorEntityDescription
|
||||
)
|
||||
unregister_sensor_processor = coordinator.async_register_processor(
|
||||
sensor_processor, SensorEntityDescription
|
||||
)
|
||||
cancel_coordinator = coordinator.async_start()
|
||||
|
||||
binary_sensor_processor.async_add_listener(MagicMock())
|
||||
sensor_processor.async_add_listener(MagicMock())
|
||||
|
||||
mock_add_sensor_entities = MagicMock()
|
||||
mock_add_binary_sensor_entities = MagicMock()
|
||||
|
||||
sensor_processor.async_add_entities_listener(
|
||||
PassiveBluetoothProcessorEntity,
|
||||
mock_add_sensor_entities,
|
||||
)
|
||||
binary_sensor_processor.async_add_entities_listener(
|
||||
PassiveBluetoothProcessorEntity,
|
||||
mock_add_binary_sensor_entities,
|
||||
)
|
||||
|
||||
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO)
|
||||
# First call with just the remote sensor entities results in them being added
|
||||
assert len(mock_add_binary_sensor_entities.mock_calls) == 1
|
||||
assert len(mock_add_sensor_entities.mock_calls) == 1
|
||||
|
||||
binary_sensor_entities = [
|
||||
*mock_add_binary_sensor_entities.mock_calls[0][1][0],
|
||||
]
|
||||
sensor_entities = [
|
||||
*mock_add_sensor_entities.mock_calls[0][1][0],
|
||||
]
|
||||
|
||||
sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0]
|
||||
sensor_entity_one.hass = hass
|
||||
assert sensor_entity_one.available is True
|
||||
assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure"
|
||||
assert sensor_entity_one.device_info == {
|
||||
"identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")},
|
||||
"manufacturer": "Test Manufacturer",
|
||||
"model": "Test Model",
|
||||
"name": "Test Device",
|
||||
}
|
||||
assert sensor_entity_one.entity_key == PassiveBluetoothEntityKey(
|
||||
key="pressure", device_id=None
|
||||
)
|
||||
|
||||
binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[
|
||||
0
|
||||
]
|
||||
binary_sensor_entity_one.hass = hass
|
||||
assert binary_sensor_entity_one.available is True
|
||||
assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion"
|
||||
assert binary_sensor_entity_one.device_info == {
|
||||
"identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")},
|
||||
"manufacturer": "Test Manufacturer",
|
||||
"model": "Test Model",
|
||||
"name": "Test Device",
|
||||
}
|
||||
assert binary_sensor_entity_one.entity_key == PassiveBluetoothEntityKey(
|
||||
key="motion", device_id=None
|
||||
)
|
||||
cancel_coordinator()
|
||||
unregister_binary_sensor_processor()
|
||||
unregister_sensor_processor()
|
||||
|
||||
mock_add_sensor_entities = MagicMock()
|
||||
mock_add_binary_sensor_entities = MagicMock()
|
||||
|
||||
current_entry.set(entry)
|
||||
coordinator = PassiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
"aa:bb:cc:dd:ee:ff",
|
||||
BluetoothScanningMode.ACTIVE,
|
||||
_mock_update_method,
|
||||
)
|
||||
binary_sensor_processor = PassiveBluetoothDataProcessor(
|
||||
lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE,
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
)
|
||||
sensor_processor = PassiveBluetoothDataProcessor(
|
||||
lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE,
|
||||
SENSOR_DOMAIN,
|
||||
)
|
||||
|
||||
sensor_processor.async_add_entities_listener(
|
||||
PassiveBluetoothProcessorEntity,
|
||||
mock_add_sensor_entities,
|
||||
)
|
||||
binary_sensor_processor.async_add_entities_listener(
|
||||
PassiveBluetoothProcessorEntity,
|
||||
mock_add_binary_sensor_entities,
|
||||
)
|
||||
|
||||
unregister_binary_sensor_processor = coordinator.async_register_processor(
|
||||
binary_sensor_processor, BinarySensorEntityDescription
|
||||
)
|
||||
unregister_sensor_processor = coordinator.async_register_processor(
|
||||
sensor_processor, SensorEntityDescription
|
||||
)
|
||||
cancel_coordinator = coordinator.async_start()
|
||||
|
||||
assert len(mock_add_binary_sensor_entities.mock_calls) == 1
|
||||
assert len(mock_add_sensor_entities.mock_calls) == 1
|
||||
|
||||
binary_sensor_entities = [
|
||||
*mock_add_binary_sensor_entities.mock_calls[0][1][0],
|
||||
]
|
||||
sensor_entities = [
|
||||
*mock_add_sensor_entities.mock_calls[0][1][0],
|
||||
]
|
||||
|
||||
sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0]
|
||||
sensor_entity_one.hass = hass
|
||||
assert sensor_entity_one.available is True
|
||||
assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure"
|
||||
assert sensor_entity_one.device_info == {
|
||||
"identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")},
|
||||
"manufacturer": "Test Manufacturer",
|
||||
"model": "Test Model",
|
||||
"name": "Test Device",
|
||||
}
|
||||
assert sensor_entity_one.entity_key == PassiveBluetoothEntityKey(
|
||||
key="pressure", device_id=None
|
||||
)
|
||||
|
||||
binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[
|
||||
0
|
||||
]
|
||||
binary_sensor_entity_one.hass = hass
|
||||
assert binary_sensor_entity_one.available is True
|
||||
assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion"
|
||||
assert binary_sensor_entity_one.device_info == {
|
||||
"identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")},
|
||||
"manufacturer": "Test Manufacturer",
|
||||
"model": "Test Model",
|
||||
"name": "Test Device",
|
||||
}
|
||||
assert binary_sensor_entity_one.entity_key == PassiveBluetoothEntityKey(
|
||||
key="motion", device_id=None
|
||||
)
|
||||
|
||||
await hass.async_stop()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert SENSOR_DOMAIN in hass_storage[STORAGE_KEY]["data"][entry.entry_id]
|
||||
assert BINARY_SENSOR_DOMAIN in hass_storage[STORAGE_KEY]["data"][entry.entry_id]
|
||||
|
||||
# We don't normally cancel or unregister these at stop,
|
||||
# but since we are mocking a restart we need to cleanup
|
||||
cancel_coordinator()
|
||||
unregister_binary_sensor_processor()
|
||||
unregister_sensor_processor()
|
||||
|
||||
hass = await async_test_home_assistant(asyncio.get_running_loop())
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
current_entry.set(entry)
|
||||
coordinator = PassiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
"aa:bb:cc:dd:ee:ff",
|
||||
BluetoothScanningMode.ACTIVE,
|
||||
_mock_update_method,
|
||||
)
|
||||
assert coordinator.available is False # no data yet
|
||||
|
||||
mock_add_sensor_entities = MagicMock()
|
||||
mock_add_binary_sensor_entities = MagicMock()
|
||||
|
||||
binary_sensor_processor = PassiveBluetoothDataProcessor(
|
||||
lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE,
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
)
|
||||
sensor_processor = PassiveBluetoothDataProcessor(
|
||||
lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE,
|
||||
SENSOR_DOMAIN,
|
||||
)
|
||||
|
||||
sensor_processor.async_add_entities_listener(
|
||||
PassiveBluetoothProcessorEntity,
|
||||
mock_add_sensor_entities,
|
||||
)
|
||||
binary_sensor_processor.async_add_entities_listener(
|
||||
PassiveBluetoothProcessorEntity,
|
||||
mock_add_binary_sensor_entities,
|
||||
)
|
||||
|
||||
unregister_binary_sensor_processor = coordinator.async_register_processor(
|
||||
binary_sensor_processor, BinarySensorEntityDescription
|
||||
)
|
||||
unregister_sensor_processor = coordinator.async_register_processor(
|
||||
sensor_processor, SensorEntityDescription
|
||||
)
|
||||
cancel_coordinator = coordinator.async_start()
|
||||
|
||||
assert len(mock_add_binary_sensor_entities.mock_calls) == 1
|
||||
assert len(mock_add_sensor_entities.mock_calls) == 1
|
||||
|
||||
binary_sensor_entities = [
|
||||
*mock_add_binary_sensor_entities.mock_calls[0][1][0],
|
||||
]
|
||||
sensor_entities = [
|
||||
*mock_add_sensor_entities.mock_calls[0][1][0],
|
||||
]
|
||||
|
||||
sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0]
|
||||
sensor_entity_one.hass = hass
|
||||
assert sensor_entity_one.available is False # service data not injected
|
||||
assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure"
|
||||
assert sensor_entity_one.device_info == {
|
||||
"identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")},
|
||||
"manufacturer": "Test Manufacturer",
|
||||
"model": "Test Model",
|
||||
"name": "Test Device",
|
||||
}
|
||||
assert sensor_entity_one.entity_key == PassiveBluetoothEntityKey(
|
||||
key="pressure", device_id=None
|
||||
)
|
||||
|
||||
binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[
|
||||
0
|
||||
]
|
||||
binary_sensor_entity_one.hass = hass
|
||||
assert binary_sensor_entity_one.available is False # service data not injected
|
||||
assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion"
|
||||
assert binary_sensor_entity_one.device_info == {
|
||||
"identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")},
|
||||
"manufacturer": "Test Manufacturer",
|
||||
"model": "Test Model",
|
||||
"name": "Test Device",
|
||||
}
|
||||
assert binary_sensor_entity_one.entity_key == PassiveBluetoothEntityKey(
|
||||
key="motion", device_id=None
|
||||
)
|
||||
cancel_coordinator()
|
||||
unregister_binary_sensor_processor()
|
||||
unregister_sensor_processor()
|
||||
await hass.async_stop()
|
||||
|
44
tests/components/http/test_headers.py
Normal file
44
tests/components/http/test_headers.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Test headers middleware."""
|
||||
from http import HTTPStatus
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.components.http.headers import setup_headers
|
||||
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
async def mock_handler(request):
|
||||
"""Return OK."""
|
||||
return web.Response(text="OK")
|
||||
|
||||
|
||||
async def test_headers_added(aiohttp_client: ClientSessionGenerator) -> None:
|
||||
"""Test that headers are being added on each request."""
|
||||
app = web.Application()
|
||||
app.router.add_get("/", mock_handler)
|
||||
|
||||
setup_headers(app, use_x_frame_options=True)
|
||||
|
||||
mock_api_client = await aiohttp_client(app)
|
||||
resp = await mock_api_client.get("/")
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
assert resp.headers["Referrer-Policy"] == "no-referrer"
|
||||
assert resp.headers["Server"] == ""
|
||||
assert resp.headers["X-Content-Type-Options"] == "nosniff"
|
||||
assert resp.headers["X-Frame-Options"] == "SAMEORIGIN"
|
||||
|
||||
|
||||
async def test_allow_framing(aiohttp_client: ClientSessionGenerator) -> None:
|
||||
"""Test that we allow framing when disabled."""
|
||||
app = web.Application()
|
||||
app.router.add_get("/", mock_handler)
|
||||
|
||||
setup_headers(app, use_x_frame_options=False)
|
||||
|
||||
mock_api_client = await aiohttp_client(app)
|
||||
resp = await mock_api_client.get("/")
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
assert "X-Frame-Options" not in resp.headers
|
@@ -64,6 +64,7 @@ from homeassistant.components.modbus.const import (
|
||||
from homeassistant.components.modbus.validators import (
|
||||
duplicate_entity_validator,
|
||||
duplicate_modbus_validator,
|
||||
nan_validator,
|
||||
number_validator,
|
||||
struct_validator,
|
||||
)
|
||||
@@ -141,6 +142,23 @@ async def test_number_validator() -> None:
|
||||
pytest.fail("Number_validator not throwing exception")
|
||||
|
||||
|
||||
async def test_nan_validator() -> None:
|
||||
"""Test number validator."""
|
||||
|
||||
for value, value_type in (
|
||||
(15, int),
|
||||
("15", int),
|
||||
("abcdef", int),
|
||||
("0xabcdef", int),
|
||||
):
|
||||
assert isinstance(nan_validator(value), value_type)
|
||||
|
||||
with pytest.raises(vol.Invalid):
|
||||
nan_validator("x15")
|
||||
with pytest.raises(vol.Invalid):
|
||||
nan_validator("not a hex string")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"do_config",
|
||||
[
|
||||
|
@@ -9,6 +9,7 @@ from homeassistant.components.modbus.const import (
|
||||
CONF_LAZY_ERROR,
|
||||
CONF_MAX_VALUE,
|
||||
CONF_MIN_VALUE,
|
||||
CONF_NAN_VALUE,
|
||||
CONF_PRECISION,
|
||||
CONF_SCALE,
|
||||
CONF_SLAVE_COUNT,
|
||||
@@ -558,6 +559,15 @@ async def test_config_wrong_struct_sensor(
|
||||
False,
|
||||
str(int(0x02010404)),
|
||||
),
|
||||
(
|
||||
{
|
||||
CONF_DATA_TYPE: DataType.INT32,
|
||||
CONF_NAN_VALUE: "0x80000000",
|
||||
},
|
||||
[0x8000, 0x0000],
|
||||
False,
|
||||
STATE_UNAVAILABLE,
|
||||
),
|
||||
(
|
||||
{
|
||||
CONF_DATA_TYPE: DataType.INT32,
|
||||
|
@@ -1,5 +1,6 @@
|
||||
"""Configure tests for the OpenSky integration."""
|
||||
from collections.abc import Awaitable, Callable
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -10,7 +11,7 @@ from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, load_fixture
|
||||
|
||||
ComponentSetup = Callable[[MockConfigEntry], Awaitable[None]]
|
||||
|
||||
@@ -32,6 +33,23 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="config_entry_altitude")
|
||||
def mock_config_entry_altitude() -> MockConfigEntry:
|
||||
"""Create Opensky entry with altitude in Home Assistant."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="OpenSky",
|
||||
data={
|
||||
CONF_LATITUDE: 0.0,
|
||||
CONF_LONGITUDE: 0.0,
|
||||
},
|
||||
options={
|
||||
CONF_RADIUS: 10.0,
|
||||
CONF_ALTITUDE: 12500.0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="setup_integration")
|
||||
async def mock_setup_integration(
|
||||
hass: HomeAssistant,
|
||||
@@ -40,9 +58,10 @@ async def mock_setup_integration(
|
||||
|
||||
async def func(mock_config_entry: MockConfigEntry) -> None:
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
json_fixture = load_fixture("opensky/states.json")
|
||||
with patch(
|
||||
"python_opensky.OpenSky.get_states",
|
||||
return_value=StatesResponse(states=[], time=0),
|
||||
return_value=StatesResponse.parse_obj(json.loads(json_fixture)),
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
105
tests/components/opensky/fixtures/states.json
Normal file
105
tests/components/opensky/fixtures/states.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"time": 1691244533,
|
||||
"states": [
|
||||
{
|
||||
"icao24": "3c6708",
|
||||
"callsign": "DLH459 ",
|
||||
"origin_country": "Germany",
|
||||
"time_position": 1691244522,
|
||||
"last_contact": 1691244522,
|
||||
"longitude": 5.4445,
|
||||
"latitude": 52.2991,
|
||||
"baro_altitude": 12496.8,
|
||||
"on_ground": false,
|
||||
"velocity": 259.73,
|
||||
"true_track": 134.84,
|
||||
"vertical_rate": 0,
|
||||
"sensors": null,
|
||||
"geo_altitude": 12710.16,
|
||||
"squawk": "1151",
|
||||
"spi": false,
|
||||
"position_source": 0,
|
||||
"category": 6
|
||||
},
|
||||
{
|
||||
"icao24": "3c6708",
|
||||
"callsign": " ",
|
||||
"origin_country": "Germany",
|
||||
"time_position": 1691244522,
|
||||
"last_contact": 1691244522,
|
||||
"longitude": 5.4445,
|
||||
"latitude": 52.2991,
|
||||
"baro_altitude": 12496.8,
|
||||
"on_ground": false,
|
||||
"velocity": 259.73,
|
||||
"true_track": 134.84,
|
||||
"vertical_rate": 0,
|
||||
"sensors": null,
|
||||
"geo_altitude": 12710.16,
|
||||
"squawk": "1151",
|
||||
"spi": false,
|
||||
"position_source": 0,
|
||||
"category": 6
|
||||
},
|
||||
{
|
||||
"icao24": "4846df",
|
||||
"callsign": "",
|
||||
"origin_country": "Kingdom of the Netherlands",
|
||||
"time_position": 1691244404,
|
||||
"last_contact": 1691244404,
|
||||
"longitude": 4.7441,
|
||||
"latitude": 52.3076,
|
||||
"baro_altitude": null,
|
||||
"on_ground": true,
|
||||
"velocity": 8.75,
|
||||
"true_track": 272.81,
|
||||
"vertical_rate": null,
|
||||
"sensors": null,
|
||||
"geo_altitude": null,
|
||||
"squawk": null,
|
||||
"spi": false,
|
||||
"position_source": 0,
|
||||
"category": 17
|
||||
},
|
||||
{
|
||||
"icao24": "4846df",
|
||||
"callsign": "DLH420 ",
|
||||
"origin_country": "Kingdom of the Netherlands",
|
||||
"time_position": 1691244404,
|
||||
"last_contact": 1691244404,
|
||||
"longitude": 4.7441,
|
||||
"latitude": 52.3076,
|
||||
"baro_altitude": null,
|
||||
"on_ground": true,
|
||||
"velocity": 8.75,
|
||||
"true_track": 272.81,
|
||||
"vertical_rate": null,
|
||||
"sensors": null,
|
||||
"geo_altitude": null,
|
||||
"squawk": null,
|
||||
"spi": false,
|
||||
"position_source": 0,
|
||||
"category": 17
|
||||
},
|
||||
{
|
||||
"icao24": "3e3d01",
|
||||
"callsign": "ECA2HL ",
|
||||
"origin_country": "Germany",
|
||||
"time_position": 1691244533,
|
||||
"last_contact": 1691244533,
|
||||
"longitude": 5.5217,
|
||||
"latitude": 52.4561,
|
||||
"baro_altitude": 12500.8,
|
||||
"on_ground": false,
|
||||
"velocity": 201.9,
|
||||
"true_track": 82.39,
|
||||
"vertical_rate": 0,
|
||||
"sensors": null,
|
||||
"geo_altitude": 12733.02,
|
||||
"squawk": "1071",
|
||||
"spi": false,
|
||||
"position_source": 0,
|
||||
"category": 1
|
||||
}
|
||||
]
|
||||
}
|
45
tests/components/opensky/fixtures/states_1.json
Normal file
45
tests/components/opensky/fixtures/states_1.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"time": 1691244533,
|
||||
"states": [
|
||||
{
|
||||
"icao24": "4846df",
|
||||
"callsign": "",
|
||||
"origin_country": "Kingdom of the Netherlands",
|
||||
"time_position": 1691244404,
|
||||
"last_contact": 1691244404,
|
||||
"longitude": 4.7441,
|
||||
"latitude": 52.3076,
|
||||
"baro_altitude": null,
|
||||
"on_ground": true,
|
||||
"velocity": 8.75,
|
||||
"true_track": 272.81,
|
||||
"vertical_rate": null,
|
||||
"sensors": null,
|
||||
"geo_altitude": null,
|
||||
"squawk": null,
|
||||
"spi": false,
|
||||
"position_source": 0,
|
||||
"category": 17
|
||||
},
|
||||
{
|
||||
"icao24": "3e3d01",
|
||||
"callsign": "ECA2HL ",
|
||||
"origin_country": "Germany",
|
||||
"time_position": 1691244533,
|
||||
"last_contact": 1691244533,
|
||||
"longitude": 5.5217,
|
||||
"latitude": 52.4561,
|
||||
"baro_altitude": 12500.8,
|
||||
"on_ground": false,
|
||||
"velocity": 201.9,
|
||||
"true_track": 82.39,
|
||||
"vertical_rate": 0,
|
||||
"sensors": null,
|
||||
"geo_altitude": 12733.02,
|
||||
"squawk": "1071",
|
||||
"spi": false,
|
||||
"position_source": 0,
|
||||
"category": 1
|
||||
}
|
||||
]
|
||||
}
|
42
tests/components/opensky/snapshots/test_sensor.ambr
Normal file
42
tests/components/opensky/snapshots/test_sensor.ambr
Normal file
@@ -0,0 +1,42 @@
|
||||
# serializer version: 1
|
||||
# name: test_sensor
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Information provided by the OpenSky Network (https://opensky-network.org)',
|
||||
'friendly_name': 'OpenSky',
|
||||
'icon': 'mdi:airplane',
|
||||
'unit_of_measurement': 'flights',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.opensky',
|
||||
'last_changed': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '2',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_altitude
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Information provided by the OpenSky Network (https://opensky-network.org)',
|
||||
'friendly_name': 'OpenSky',
|
||||
'icon': 'mdi:airplane',
|
||||
'unit_of_measurement': 'flights',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.opensky',
|
||||
'last_changed': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '1',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_updating
|
||||
list([
|
||||
<Event opensky_exit[L]: callsign=DLH459, altitude=0, sensor=OpenSky, longitude=None, latitude=None, icao24=None>,
|
||||
])
|
||||
# ---
|
||||
# name: test_sensor_updating.1
|
||||
list([
|
||||
<Event opensky_exit[L]: callsign=DLH459, altitude=0, sensor=OpenSky, longitude=None, latitude=None, icao24=None>,
|
||||
<Event opensky_entry[L]: callsign=DLH459, altitude=12496.8, sensor=OpenSky, longitude=5.4445, latitude=52.2991, icao24=3c6708>,
|
||||
])
|
||||
# ---
|
@@ -1,20 +1,115 @@
|
||||
"""OpenSky sensor tests."""
|
||||
from homeassistant.components.opensky.const import DOMAIN
|
||||
from datetime import timedelta
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from python_opensky import StatesResponse
|
||||
from syrupy import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.opensky.const import (
|
||||
DOMAIN,
|
||||
EVENT_OPENSKY_ENTRY,
|
||||
EVENT_OPENSKY_EXIT,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_PLATFORM, CONF_RADIUS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .conftest import ComponentSetup
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture
|
||||
|
||||
LEGACY_CONFIG = {Platform.SENSOR: [{CONF_PLATFORM: DOMAIN, CONF_RADIUS: 10.0}]}
|
||||
|
||||
|
||||
async def test_legacy_migration(hass: HomeAssistant) -> None:
|
||||
"""Test migration from yaml to config flow."""
|
||||
assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].state is ConfigEntryState.LOADED
|
||||
issue_registry = ir.async_get(hass)
|
||||
assert len(issue_registry.issues) == 1
|
||||
json_fixture = load_fixture("opensky/states.json")
|
||||
with patch(
|
||||
"python_opensky.OpenSky.get_states",
|
||||
return_value=StatesResponse.parse_obj(json.loads(json_fixture)),
|
||||
):
|
||||
assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].state is ConfigEntryState.LOADED
|
||||
issue_registry = ir.async_get(hass)
|
||||
assert len(issue_registry.issues) == 1
|
||||
|
||||
|
||||
async def test_sensor(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
setup_integration: ComponentSetup,
|
||||
snapshot: SnapshotAssertion,
|
||||
):
|
||||
"""Test setup sensor."""
|
||||
await setup_integration(config_entry)
|
||||
|
||||
state = hass.states.get("sensor.opensky")
|
||||
assert state == snapshot
|
||||
events = []
|
||||
|
||||
async def event_listener(event: Event) -> None:
|
||||
events.append(event)
|
||||
|
||||
hass.bus.async_listen(EVENT_OPENSKY_ENTRY, event_listener)
|
||||
hass.bus.async_listen(EVENT_OPENSKY_EXIT, event_listener)
|
||||
assert events == []
|
||||
|
||||
|
||||
async def test_sensor_altitude(
|
||||
hass: HomeAssistant,
|
||||
config_entry_altitude: MockConfigEntry,
|
||||
setup_integration: ComponentSetup,
|
||||
snapshot: SnapshotAssertion,
|
||||
):
|
||||
"""Test setup sensor with a set altitude."""
|
||||
await setup_integration(config_entry_altitude)
|
||||
|
||||
state = hass.states.get("sensor.opensky")
|
||||
assert state == snapshot
|
||||
|
||||
|
||||
async def test_sensor_updating(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
setup_integration: ComponentSetup,
|
||||
snapshot: SnapshotAssertion,
|
||||
):
|
||||
"""Test updating sensor."""
|
||||
await setup_integration(config_entry)
|
||||
|
||||
def get_states_response_fixture(fixture: str) -> StatesResponse:
|
||||
json_fixture = load_fixture(fixture)
|
||||
return StatesResponse.parse_obj(json.loads(json_fixture))
|
||||
|
||||
events = []
|
||||
|
||||
async def event_listener(event: Event) -> None:
|
||||
events.append(event)
|
||||
|
||||
hass.bus.async_listen(EVENT_OPENSKY_ENTRY, event_listener)
|
||||
hass.bus.async_listen(EVENT_OPENSKY_EXIT, event_listener)
|
||||
|
||||
async def skip_time_and_check_events() -> None:
|
||||
future = dt_util.utcnow() + timedelta(minutes=15)
|
||||
async_fire_time_changed(hass, future)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert events == snapshot
|
||||
|
||||
with patch(
|
||||
"python_opensky.OpenSky.get_states",
|
||||
return_value=get_states_response_fixture("opensky/states_1.json"),
|
||||
):
|
||||
await skip_time_and_check_events()
|
||||
with patch(
|
||||
"python_opensky.OpenSky.get_states",
|
||||
return_value=get_states_response_fixture("opensky/states.json"),
|
||||
):
|
||||
await skip_time_and_check_events()
|
||||
|
@@ -67,6 +67,10 @@ async def setup_entry(
|
||||
return_value=PROP,
|
||||
), patch(
|
||||
"homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message"
|
||||
), patch(
|
||||
"homeassistant.components.roborock.RoborockMqttClient._wait_response"
|
||||
), patch(
|
||||
"homeassistant.components.roborock.coordinator.RoborockLocalClient._wait_response"
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"""Test the webhook component."""
|
||||
from http import HTTPStatus
|
||||
from ipaddress import ip_address
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from aiohttp import web
|
||||
import pytest
|
||||
@@ -206,6 +206,8 @@ async def test_webhook_not_allowed_method(hass: HomeAssistant) -> None:
|
||||
|
||||
async def test_webhook_local_only(hass: HomeAssistant, mock_client) -> None:
|
||||
"""Test posting a webhook with local only."""
|
||||
hass.config.components.add("cloud")
|
||||
|
||||
hooks = []
|
||||
webhook_id = webhook.async_generate_id()
|
||||
|
||||
@@ -234,6 +236,16 @@ async def test_webhook_local_only(hass: HomeAssistant, mock_client) -> None:
|
||||
# No hook received
|
||||
assert len(hooks) == 1
|
||||
|
||||
# Request from Home Assistant Cloud remote UI
|
||||
with patch(
|
||||
"hass_nabucasa.remote.is_cloud_request", Mock(get=Mock(return_value=True))
|
||||
):
|
||||
resp = await mock_client.post(f"/api/webhook/{webhook_id}", json={"data": True})
|
||||
|
||||
# No hook received
|
||||
assert resp.status == HTTPStatus.OK
|
||||
assert len(hooks) == 1
|
||||
|
||||
|
||||
async def test_listing_webhook(
|
||||
hass: HomeAssistant,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"""The tests for the webhook automation trigger."""
|
||||
from ipaddress import ip_address
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -68,6 +68,9 @@ async def test_webhook_post(
|
||||
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
|
||||
) -> None:
|
||||
"""Test triggering with a POST webhook."""
|
||||
# Set up fake cloud
|
||||
hass.config.components.add("cloud")
|
||||
|
||||
events = []
|
||||
|
||||
@callback
|
||||
@@ -114,6 +117,16 @@ async def test_webhook_post(
|
||||
await hass.async_block_till_done()
|
||||
assert len(events) == 1
|
||||
|
||||
# Request from Home Assistant Cloud remote UI
|
||||
with patch(
|
||||
"hass_nabucasa.remote.is_cloud_request", Mock(get=Mock(return_value=True))
|
||||
):
|
||||
await client.post("/api/webhook/post_webhook", data={"hello": "world"})
|
||||
|
||||
# No hook received
|
||||
await hass.async_block_till_done()
|
||||
assert len(events) == 1
|
||||
|
||||
|
||||
async def test_webhook_allowed_methods_internet(
|
||||
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
|
||||
@@ -141,7 +154,6 @@ async def test_webhook_allowed_methods_internet(
|
||||
},
|
||||
"action": {
|
||||
"event": "test_success",
|
||||
"event_data_template": {"hello": "yo {{ trigger.data.hello }}"},
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -150,7 +162,7 @@ async def test_webhook_allowed_methods_internet(
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
|
||||
await client.post("/api/webhook/post_webhook", data={"hello": "world"})
|
||||
await client.post("/api/webhook/post_webhook")
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(events) == 0
|
||||
@@ -160,7 +172,7 @@ async def test_webhook_allowed_methods_internet(
|
||||
"homeassistant.components.webhook.ip_address",
|
||||
return_value=ip_address("123.123.123.123"),
|
||||
):
|
||||
await client.put("/api/webhook/post_webhook", data={"hello": "world"})
|
||||
await client.put("/api/webhook/post_webhook")
|
||||
await hass.async_block_till_done()
|
||||
assert len(events) == 1
|
||||
|
||||
|
@@ -2,6 +2,7 @@
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from whirlpool.washerdryer import MachineState
|
||||
|
||||
from homeassistant.components.whirlpool.sensor import SCAN_INTERVAL
|
||||
@@ -325,6 +326,7 @@ async def test_no_restore_state(
|
||||
assert state.state != "unknown"
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2022-11-30 00:00:00")
|
||||
async def test_callback(
|
||||
hass: HomeAssistant,
|
||||
mock_sensor_api_instances: MagicMock,
|
||||
@@ -377,8 +379,8 @@ async def test_callback(
|
||||
assert state.state == time
|
||||
|
||||
# Test timestamp change for > 60 seconds.
|
||||
mock_sensor1_api.get_attribute.return_value = "120"
|
||||
mock_sensor1_api.get_attribute.return_value = "125"
|
||||
callback()
|
||||
state = hass.states.get("sensor.washer_end_time")
|
||||
newtime = utc_from_timestamp(as_timestamp(time) + 60)
|
||||
newtime = utc_from_timestamp(as_timestamp(time) + 65)
|
||||
assert state.state == newtime.isoformat()
|
||||
|
@@ -117,6 +117,7 @@ def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None:
|
||||
"login_attempts_threshold": -1,
|
||||
"server_port": 8123,
|
||||
"ssl_profile": "modern",
|
||||
"use_x_frame_options": True,
|
||||
}
|
||||
assert res["secret_cache"] == {
|
||||
get_test_config_dir("secrets.yaml"): {"http_pw": "http://google.com"}
|
||||
|
@@ -1544,6 +1544,28 @@ async def test_init_custom_integration(hass: HomeAssistant) -> None:
|
||||
await hass.config_entries.flow.async_init("bla", context={"source": "user"})
|
||||
|
||||
|
||||
async def test_init_custom_integration_with_missing_handler(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test initializing flow for custom integration with a missing handler."""
|
||||
integration = loader.Integration(
|
||||
hass,
|
||||
"custom_components.hue",
|
||||
None,
|
||||
{"name": "Hue", "dependencies": [], "requirements": [], "domain": "hue"},
|
||||
)
|
||||
mock_integration(
|
||||
hass,
|
||||
MockModule("hue"),
|
||||
)
|
||||
mock_entity_platform(hass, "config_flow.hue", None)
|
||||
with pytest.raises(data_entry_flow.UnknownHandler), patch(
|
||||
"homeassistant.loader.async_get_integration",
|
||||
return_value=integration,
|
||||
):
|
||||
await hass.config_entries.flow.async_init("bla", context={"source": "user"})
|
||||
|
||||
|
||||
async def test_support_entry_unload(hass: HomeAssistant) -> None:
|
||||
"""Test unloading entry."""
|
||||
assert await config_entries.support_entry_unload(hass, "light")
|
||||
|
Reference in New Issue
Block a user