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

This commit is contained in:
Jack Boswell
2023-08-07 20:17:30 +12:00
committed by GitHub
59 changed files with 1424 additions and 236 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View 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
}
]
}

View 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
}
]
}

View 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>,
])
# ---

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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