mirror of
https://github.com/home-assistant/core.git
synced 2026-02-24 11:11:16 +01:00
Compare commits
100 Commits
use-unix-s
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1f943ccda | ||
|
|
e37d84049a | ||
|
|
209473e376 | ||
|
|
334c3af448 | ||
|
|
5560139d24 | ||
|
|
d4dec5d1d3 | ||
|
|
6cb63a60bc | ||
|
|
991301e79e | ||
|
|
06e2b4633a | ||
|
|
048d8d217c | ||
|
|
3693bc5878 | ||
|
|
9c640fe0fa | ||
|
|
62145e5f9e | ||
|
|
c0fc414bb9 | ||
|
|
69411a05ff | ||
|
|
06c9ec861d | ||
|
|
946df1755f | ||
|
|
d0678e0641 | ||
|
|
ec56f183da | ||
|
|
033005e0de | ||
|
|
91f9f5a826 | ||
|
|
ac4fcab827 | ||
|
|
d0eea77178 | ||
|
|
fb38fa3844 | ||
|
|
440efb953e | ||
|
|
7ce47cca0d | ||
|
|
a5f607bb91 | ||
|
|
b03043aa6f | ||
|
|
0f3c7ca277 | ||
|
|
3abf7c22f3 | ||
|
|
292e1de126 | ||
|
|
2d776a8193 | ||
|
|
039bbbb48c | ||
|
|
ad5565df95 | ||
|
|
3e6bc29a6a | ||
|
|
ec8067a5a8 | ||
|
|
6f47716d0a | ||
|
|
efba5c6bcc | ||
|
|
d10e78079f | ||
|
|
6d4581580f | ||
|
|
0d9a41a540 | ||
|
|
cd69e6db73 | ||
|
|
1320367d0d | ||
|
|
dfa4698887 | ||
|
|
b426115de7 | ||
|
|
fb79fa37f8 | ||
|
|
6a5f7bf424 | ||
|
|
142ca6dec1 | ||
|
|
0f986c24d0 | ||
|
|
01f2b7b6f6 | ||
|
|
b9469027f5 | ||
|
|
fbb94af748 | ||
|
|
148bdf6e3a | ||
|
|
91999f8871 | ||
|
|
aecca4eb99 | ||
|
|
bf8aa49bae | ||
|
|
4423425683 | ||
|
|
44202da53d | ||
|
|
9f7dfb72c4 | ||
|
|
de07a69e4f | ||
|
|
bbf4c38115 | ||
|
|
e1bb5d52ef | ||
|
|
eb64b6bdee | ||
|
|
ecb288b735 | ||
|
|
a419c9c420 | ||
|
|
dd29133324 | ||
|
|
90f22ea516 | ||
|
|
9db1428265 | ||
|
|
a696b05b0d | ||
|
|
77ddb63b73 | ||
|
|
4180a6e176 | ||
|
|
6d74c912d2 | ||
|
|
8a01dfcc00 | ||
|
|
9722898dc6 | ||
|
|
7438c71fcb | ||
|
|
0b5e55b923 | ||
|
|
61ed959e8e | ||
|
|
3989532465 | ||
|
|
28027ddca4 | ||
|
|
fe0d7b3cca | ||
|
|
0dcc4e9527 | ||
|
|
b13b189703 | ||
|
|
150829f599 | ||
|
|
57dd9d9c23 | ||
|
|
e2056cb12c | ||
|
|
fa2c8992cf | ||
|
|
ddf5c7fe3a | ||
|
|
7034ed6d3f | ||
|
|
9015b53c1b | ||
|
|
1cfa6561f7 | ||
|
|
eead02dcca | ||
|
|
456e51a221 | ||
|
|
5d984ce186 | ||
|
|
61f45489ac | ||
|
|
f72c643b38 | ||
|
|
27bc26e886 | ||
|
|
0e9f03cbc1 | ||
|
|
9480c33fb0 | ||
|
|
3e6b8663e8 | ||
|
|
1c69a83793 |
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -555,8 +555,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
|
||||
/homeassistant/components/fritzbox/ @mib1185 @flabbamann
|
||||
/tests/components/fritzbox/ @mib1185 @flabbamann
|
||||
/homeassistant/components/fritzbox_callmonitor/ @cdce8p
|
||||
/tests/components/fritzbox_callmonitor/ @cdce8p
|
||||
/homeassistant/components/fronius/ @farmio
|
||||
/tests/components/fronius/ @farmio
|
||||
/homeassistant/components/frontend/ @home-assistant/frontend
|
||||
|
||||
@@ -300,16 +300,23 @@ class RuntimeEntryData:
|
||||
needed_platforms.add(Platform.BINARY_SENSOR)
|
||||
needed_platforms.add(Platform.SELECT)
|
||||
|
||||
needed_platforms.update(INFO_TYPE_TO_PLATFORM[type(info)] for info in infos)
|
||||
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
|
||||
|
||||
# Make a dict of the EntityInfo by type and send
|
||||
# them to the listeners for each specific EntityInfo type
|
||||
info_types_to_platform = INFO_TYPE_TO_PLATFORM
|
||||
infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict(
|
||||
list
|
||||
)
|
||||
for info in infos:
|
||||
infos_by_type[type(info)].append(info)
|
||||
info_type = type(info)
|
||||
if platform := info_types_to_platform.get(info_type):
|
||||
needed_platforms.add(platform)
|
||||
infos_by_type[info_type].append(info)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Entity type %s is not supported in this version of Home Assistant",
|
||||
info_type,
|
||||
)
|
||||
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
|
||||
|
||||
for type_, callbacks in self.entity_info_callbacks.items():
|
||||
# If all entities for a type are removed, we
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "fritzbox_callmonitor",
|
||||
"name": "FRITZ!Box Call Monitor",
|
||||
"codeowners": ["@cdce8p"],
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -1752,15 +1752,15 @@ class FanSpeedTrait(_Trait):
|
||||
"""Initialize a trait for a state."""
|
||||
super().__init__(hass, state, config)
|
||||
if state.domain == fan.DOMAIN:
|
||||
speed_count = min(
|
||||
FAN_SPEED_MAX_SPEED_COUNT,
|
||||
round(
|
||||
100 / (self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0)
|
||||
),
|
||||
speed_count = round(
|
||||
100 / (self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0)
|
||||
)
|
||||
self._ordered_speed = [
|
||||
f"{speed}/{speed_count}" for speed in range(1, speed_count + 1)
|
||||
]
|
||||
if speed_count <= FAN_SPEED_MAX_SPEED_COUNT:
|
||||
self._ordered_speed = [
|
||||
f"{speed}/{speed_count}" for speed in range(1, speed_count + 1)
|
||||
]
|
||||
else:
|
||||
self._ordered_speed = []
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features, device_class, _):
|
||||
@@ -1786,7 +1786,11 @@ class FanSpeedTrait(_Trait):
|
||||
result.update(
|
||||
{
|
||||
"reversible": reversible,
|
||||
"supportsFanSpeedPercent": True,
|
||||
# supportsFanSpeedPercent is mutually exclusive with
|
||||
# availableFanSpeeds, where supportsFanSpeedPercent takes
|
||||
# precedence. Report it only when step speeds are not
|
||||
# supported so Google renders a percent slider (1-100%).
|
||||
"supportsFanSpeedPercent": not self._ordered_speed,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1832,10 +1836,12 @@ class FanSpeedTrait(_Trait):
|
||||
|
||||
if domain == fan.DOMAIN:
|
||||
percent = attrs.get(fan.ATTR_PERCENTAGE) or 0
|
||||
response["currentFanSpeedPercent"] = percent
|
||||
response["currentFanSpeedSetting"] = percentage_to_ordered_list_item(
|
||||
self._ordered_speed, percent
|
||||
)
|
||||
if self._ordered_speed:
|
||||
response["currentFanSpeedSetting"] = percentage_to_ordered_list_item(
|
||||
self._ordered_speed, percent
|
||||
)
|
||||
else:
|
||||
response["currentFanSpeedPercent"] = percent
|
||||
|
||||
return response
|
||||
|
||||
@@ -1855,7 +1861,7 @@ class FanSpeedTrait(_Trait):
|
||||
)
|
||||
|
||||
if domain == fan.DOMAIN:
|
||||
if fan_speed := params.get("fanSpeed"):
|
||||
if self._ordered_speed and (fan_speed := params.get("fanSpeed")):
|
||||
fan_speed_percent = ordered_list_item_to_percentage(
|
||||
self._ordered_speed, fan_speed
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ from functools import partial
|
||||
from ipaddress import IPv4Network, IPv6Network, ip_network
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import ssl
|
||||
from tempfile import NamedTemporaryFile
|
||||
@@ -70,7 +69,7 @@ from .headers import setup_headers
|
||||
from .request_context import setup_request_context
|
||||
from .security_filter import setup_security_filter
|
||||
from .static import CACHE_HEADERS, CachingStaticResource
|
||||
from .web_runner import HomeAssistantTCPSite, HomeAssistantUnixSite
|
||||
from .web_runner import HomeAssistantTCPSite
|
||||
|
||||
CONF_SERVER_HOST: Final = "server_host"
|
||||
CONF_SERVER_PORT: Final = "server_port"
|
||||
@@ -236,16 +235,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
source_ip_task = create_eager_task(async_get_source_ip(hass))
|
||||
|
||||
unix_socket_path: Path | None = None
|
||||
if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"):
|
||||
socket_path = Path(socket_env)
|
||||
if socket_path.is_absolute():
|
||||
unix_socket_path = socket_path
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Invalid unix socket path %s: path must be absolute", socket_env
|
||||
)
|
||||
|
||||
server = HomeAssistantHTTP(
|
||||
hass,
|
||||
server_host=server_host,
|
||||
@@ -255,7 +244,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
ssl_key=ssl_key,
|
||||
trusted_proxies=trusted_proxies,
|
||||
ssl_profile=ssl_profile,
|
||||
unix_socket_path=unix_socket_path,
|
||||
)
|
||||
await server.async_initialize(
|
||||
cors_origins=cors_origins,
|
||||
@@ -378,7 +366,6 @@ class HomeAssistantHTTP:
|
||||
server_port: int,
|
||||
trusted_proxies: list[IPv4Network | IPv6Network],
|
||||
ssl_profile: str,
|
||||
unix_socket_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Initialize the HTTP Home Assistant server."""
|
||||
self.app = HomeAssistantApplication(
|
||||
@@ -397,10 +384,8 @@ class HomeAssistantHTTP:
|
||||
self.server_port = server_port
|
||||
self.trusted_proxies = trusted_proxies
|
||||
self.ssl_profile = ssl_profile
|
||||
self.unix_socket_path = unix_socket_path
|
||||
self.runner: web.AppRunner | None = None
|
||||
self.site: HomeAssistantTCPSite | None = None
|
||||
self.unix_site: HomeAssistantUnixSite | None = None
|
||||
self.context: ssl.SSLContext | None = None
|
||||
|
||||
async def async_initialize(
|
||||
@@ -638,20 +623,6 @@ class HomeAssistantHTTP:
|
||||
)
|
||||
await self.runner.setup()
|
||||
|
||||
if self.unix_socket_path is not None:
|
||||
self.unix_site = HomeAssistantUnixSite(self.runner, self.unix_socket_path)
|
||||
try:
|
||||
await self.unix_site.start()
|
||||
except OSError as error:
|
||||
_LOGGER.error(
|
||||
"Failed to create HTTP server on unix socket %s: %s",
|
||||
self.unix_socket_path,
|
||||
error,
|
||||
)
|
||||
self.unix_site = None
|
||||
else:
|
||||
_LOGGER.info("Now listening on unix socket %s", self.unix_socket_path)
|
||||
|
||||
self.site = HomeAssistantTCPSite(
|
||||
self.runner, self.server_host, self.server_port, ssl_context=self.context
|
||||
)
|
||||
@@ -666,10 +637,6 @@ class HomeAssistantHTTP:
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the aiohttp server."""
|
||||
if self.unix_site is not None:
|
||||
await self.unix_site.stop()
|
||||
if self.unix_socket_path is not None:
|
||||
self.unix_socket_path.unlink(missing_ok=True)
|
||||
if self.site is not None:
|
||||
await self.site.stop()
|
||||
if self.runner is not None:
|
||||
|
||||
@@ -20,7 +20,6 @@ from homeassistant.auth import jwt_wrapper
|
||||
from homeassistant.auth.const import GROUP_ID_READ_ONLY
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.const import HASSIO_USER_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.http import current_request
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
@@ -28,12 +27,7 @@ from homeassistant.helpers.network import is_cloud_connection
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util.network import is_local
|
||||
|
||||
from .const import (
|
||||
KEY_AUTHENTICATED,
|
||||
KEY_HASS_REFRESH_TOKEN_ID,
|
||||
KEY_HASS_USER,
|
||||
is_unix_socket_request,
|
||||
)
|
||||
from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -123,7 +117,7 @@ def async_user_not_allowed_do_auth(
|
||||
return "User cannot authenticate remotely"
|
||||
|
||||
|
||||
async def async_setup_auth( # noqa: C901
|
||||
async def async_setup_auth(
|
||||
hass: HomeAssistant,
|
||||
app: Application,
|
||||
) -> None:
|
||||
@@ -213,27 +207,6 @@ async def async_setup_auth( # noqa: C901
|
||||
request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id
|
||||
return True
|
||||
|
||||
supervisor_user_id: str | None = None
|
||||
|
||||
async def async_authenticate_unix_socket(request: Request) -> bool:
|
||||
"""Authenticate a request from a Unix socket as the Supervisor user."""
|
||||
nonlocal supervisor_user_id
|
||||
|
||||
# Fast path: use cached user ID
|
||||
if supervisor_user_id is not None:
|
||||
if user := await hass.auth.async_get_user(supervisor_user_id):
|
||||
request[KEY_HASS_USER] = user
|
||||
return True
|
||||
supervisor_user_id = None
|
||||
|
||||
# Slow path: find the Supervisor user by name
|
||||
for user in await hass.auth.async_get_users():
|
||||
if user.system_generated and user.name == HASSIO_USER_NAME:
|
||||
supervisor_user_id = user.id
|
||||
request[KEY_HASS_USER] = user
|
||||
return True
|
||||
return False
|
||||
|
||||
@middleware
|
||||
async def auth_middleware(
|
||||
request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
|
||||
@@ -241,11 +214,7 @@ async def async_setup_auth( # noqa: C901
|
||||
"""Authenticate as middleware."""
|
||||
authenticated = False
|
||||
|
||||
if is_unix_socket_request(request):
|
||||
authenticated = await async_authenticate_unix_socket(request)
|
||||
auth_type = "unix socket"
|
||||
|
||||
elif hdrs.AUTHORIZATION in request.headers and async_validate_auth_header(
|
||||
if hdrs.AUTHORIZATION in request.headers and async_validate_auth_header(
|
||||
request
|
||||
):
|
||||
authenticated = True
|
||||
@@ -264,7 +233,7 @@ async def async_setup_auth( # noqa: C901
|
||||
if authenticated and _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug(
|
||||
"Authenticated %s for %s using %s",
|
||||
request.remote or "unknown",
|
||||
request.remote,
|
||||
request.path,
|
||||
auth_type,
|
||||
)
|
||||
|
||||
@@ -30,7 +30,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.hassio import get_supervisor_ip, is_hassio
|
||||
from homeassistant.util import dt as dt_util, yaml as yaml_util
|
||||
|
||||
from .const import KEY_HASS, is_unix_socket_request
|
||||
from .const import KEY_HASS
|
||||
from .view import HomeAssistantView
|
||||
|
||||
_LOGGER: Final = logging.getLogger(__name__)
|
||||
@@ -72,10 +72,6 @@ async def ban_middleware(
|
||||
request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
|
||||
) -> StreamResponse:
|
||||
"""IP Ban middleware."""
|
||||
# Unix socket connections are trusted, skip ban checks
|
||||
if is_unix_socket_request(request):
|
||||
return await handler(request)
|
||||
|
||||
if (ban_manager := request.app.get(KEY_BAN_MANAGER)) is None:
|
||||
_LOGGER.error("IP Ban middleware loaded but banned IPs not loaded")
|
||||
return await handler(request)
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
"""HTTP specific constants."""
|
||||
|
||||
import socket
|
||||
from typing import Final
|
||||
|
||||
from aiohttp.web import Request
|
||||
|
||||
from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401
|
||||
|
||||
DOMAIN: Final = "http"
|
||||
|
||||
KEY_HASS_USER: Final = "hass_user"
|
||||
KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id"
|
||||
|
||||
|
||||
def is_unix_socket_request(request: Request) -> bool:
|
||||
"""Check if request arrived over a Unix socket."""
|
||||
if (transport := request.transport) is None:
|
||||
return False
|
||||
if (sock := transport.get_extra_info("socket")) is None:
|
||||
return False
|
||||
return bool(sock.family == socket.AF_UNIX)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from ssl import SSLContext
|
||||
|
||||
from aiohttp import web
|
||||
@@ -69,46 +68,3 @@ class HomeAssistantTCPSite(web.BaseSite):
|
||||
reuse_address=self._reuse_address,
|
||||
reuse_port=self._reuse_port,
|
||||
)
|
||||
|
||||
|
||||
class HomeAssistantUnixSite(web.BaseSite):
|
||||
"""HomeAssistant specific aiohttp UnixSite.
|
||||
|
||||
Listens on a Unix socket for local inter-process communication,
|
||||
used for Supervisor to Core communication.
|
||||
"""
|
||||
|
||||
__slots__ = ("_path",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runner: web.BaseRunner,
|
||||
path: Path,
|
||||
*,
|
||||
backlog: int = 128,
|
||||
) -> None:
|
||||
"""Initialize HomeAssistantUnixSite."""
|
||||
super().__init__(
|
||||
runner,
|
||||
backlog=backlog,
|
||||
)
|
||||
self._path = path
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return server URL."""
|
||||
return f"http://unix:{self._path}:"
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start server."""
|
||||
await super().start()
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._path.unlink(missing_ok=True)
|
||||
loop = asyncio.get_running_loop()
|
||||
server = self._runner.server
|
||||
assert server is not None
|
||||
self._server = await loop.create_unix_server(
|
||||
server,
|
||||
self._path,
|
||||
backlog=self._backlog,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from enum import Enum
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from HueBLE import ConnectionError, HueBleError, HueBleLight, PairingError
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -26,6 +27,17 @@ from .light import get_available_color_modes
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SERVICE_UUID = SERVICE_DATA_UUID = "0000fe0f-0000-1000-8000-00805f9b34fb"
|
||||
|
||||
|
||||
def device_filter(advertisement_data: AdvertisementData) -> bool:
|
||||
"""Return True if the device is supported."""
|
||||
return (
|
||||
SERVICE_UUID in advertisement_data.service_uuids
|
||||
and SERVICE_DATA_UUID in advertisement_data.service_data
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, address: str) -> Error | None:
|
||||
"""Return error if cannot connect and validate."""
|
||||
|
||||
@@ -70,28 +82,66 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovered_devices: dict[str, bluetooth.BluetoothServiceInfoBleak] = {}
|
||||
self._discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the user step to pick discovered device."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
unique_id = dr.format_mac(user_input[CONF_MAC])
|
||||
# Don't raise on progress because there may be discovery flows
|
||||
await self.async_set_unique_id(unique_id, raise_on_progress=False)
|
||||
# Guard against the user selecting a device which has been configured by
|
||||
# another flow.
|
||||
self._abort_if_unique_id_configured()
|
||||
self._discovery_info = self._discovered_devices[user_input[CONF_MAC]]
|
||||
return await self.async_step_confirm()
|
||||
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery in bluetooth.async_discovered_service_info(self.hass):
|
||||
if (
|
||||
discovery.address in current_addresses
|
||||
or discovery.address in self._discovered_devices
|
||||
or not device_filter(discovery.advertisement)
|
||||
):
|
||||
continue
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
|
||||
if not self._discovered_devices:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_MAC): vol.In(
|
||||
{
|
||||
service_info.address: (
|
||||
f"{service_info.name} ({service_info.address})"
|
||||
)
|
||||
for service_info in self._discovered_devices.values()
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: bluetooth.BluetoothServiceInfoBleak
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the home assistant scanner."""
|
||||
|
||||
_LOGGER.debug(
|
||||
"HA found light %s. Will show in UI but not auto connect",
|
||||
"HA found light %s. Use user flow to show in UI and connect",
|
||||
discovery_info.name,
|
||||
)
|
||||
|
||||
unique_id = dr.format_mac(discovery_info.address)
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
name = f"{discovery_info.name} ({discovery_info.address})"
|
||||
self.context.update({"title_placeholders": {CONF_NAME: name}})
|
||||
|
||||
self._discovery_info = discovery_info
|
||||
|
||||
return await self.async_step_confirm()
|
||||
return self.async_abort(reason="discovery_unsupported")
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -103,7 +153,10 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
unique_id = dr.format_mac(self._discovery_info.address)
|
||||
await self.async_set_unique_id(unique_id)
|
||||
# Don't raise on progress because there may be discovery flows
|
||||
await self.async_set_unique_id(unique_id, raise_on_progress=False)
|
||||
# Guard against the user selecting a device which has been configured by
|
||||
# another flow.
|
||||
self._abort_if_unique_id_configured()
|
||||
error = await validate_input(self.hass, unique_id)
|
||||
if error:
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"not_implemented": "This integration can only be set up via discovery."
|
||||
"discovery_unsupported": "Discovery flow is not supported by the Hue BLE integration.",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -14,7 +15,16 @@
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Do you want to set up {name} ({mac})?. Make sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
|
||||
"description": "Do you want to set up {name} ({mac})?\nMake sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"mac": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"data_description": {
|
||||
"mac": "Select the Hue device you want to set up"
|
||||
},
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,14 +109,18 @@ class LunatoneLight(
|
||||
return self._device is not None and self._device.is_on
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
def brightness(self) -> int | None:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
|
||||
return (
|
||||
value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
|
||||
if self._device.brightness is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if self._device is not None and self._device.is_dimmable:
|
||||
if self._device is not None and self._device.brightness is not None:
|
||||
return ColorMode.BRIGHTNESS
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@@ -149,7 +153,8 @@ class LunatoneLight(
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn off."""
|
||||
if brightness_supported(self.supported_color_modes):
|
||||
self._last_brightness = self.brightness
|
||||
if self.brightness:
|
||||
self._last_brightness = self.brightness
|
||||
await self._device.fade_to_brightness(0)
|
||||
else:
|
||||
await self._device.switch_off()
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["lunatone-rest-api-client==0.6.3"]
|
||||
"requirements": ["lunatone-rest-api-client==0.7.0"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2026.02.04"],
|
||||
"requirements": ["yt-dlp[default]==2026.02.21"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -394,10 +394,10 @@
|
||||
"name": "Delete notification"
|
||||
},
|
||||
"publish": {
|
||||
"description": "Publishes a notification message to a ntfy topic",
|
||||
"description": "Publishes a notification message to a ntfy topic.",
|
||||
"fields": {
|
||||
"actions": {
|
||||
"description": "Up to three actions ('view', 'broadcast', or 'http') can be added as buttons below the notification. Actions are executed when the corresponding button is tapped or clicked.",
|
||||
"description": "Up to three actions (`view`, `broadcast`, `http`, or `copy`) can be added as buttons below the notification. Actions are executed when the corresponding button is tapped or clicked.",
|
||||
"name": "Action buttons"
|
||||
},
|
||||
"attach": {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["hass_splunk"],
|
||||
"quality_scale": "legacy",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["hass-splunk==0.1.4"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -18,18 +18,9 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration does not provide custom actions.
|
||||
docs-high-level-description:
|
||||
status: todo
|
||||
comment: |
|
||||
Verify integration docs at https://www.home-assistant.io/integrations/splunk/ include a high-level description of Splunk with a link to https://www.splunk.com/ and explain the integration's purpose for users unfamiliar with Splunk.
|
||||
docs-installation-instructions:
|
||||
status: todo
|
||||
comment: |
|
||||
Verify integration docs include clear prerequisites and step-by-step setup instructions including how to configure Splunk HTTP Event Collector and obtain the required token.
|
||||
docs-removal-instructions:
|
||||
status: todo
|
||||
comment: |
|
||||
Verify integration docs include instructions on how to remove the integration and clarify what happens to data already in Splunk.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from abc import abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import Callable, Sequence
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
@@ -430,48 +430,35 @@ class TelegramNotificationService:
|
||||
params[ATTR_PARSER] = None
|
||||
return params
|
||||
|
||||
async def _send_msgs(
|
||||
async def _send_msg_formatted(
|
||||
self,
|
||||
func_send: Callable,
|
||||
func_send: Callable[..., Awaitable[Message]],
|
||||
message_tag: str | None,
|
||||
*args_msg: Any,
|
||||
context: Context | None = None,
|
||||
**kwargs_msg: Any,
|
||||
) -> dict[str, JsonValueType]:
|
||||
"""Sends a message to each of the targets.
|
||||
|
||||
If there is only 1 targtet, an error is raised if the send fails.
|
||||
For multiple targets, errors are logged and the caller is responsible for checking which target is successful/failed based on the return value.
|
||||
"""Sends a message and formats the response.
|
||||
|
||||
:return: dict with chat_id keys and message_id values for successful sends
|
||||
"""
|
||||
chat_ids = [kwargs_msg.pop(ATTR_CHAT_ID)]
|
||||
msg_ids: dict[str, JsonValueType] = {}
|
||||
for chat_id in chat_ids:
|
||||
_LOGGER.debug("%s to chat ID %s", func_send.__name__, chat_id)
|
||||
chat_id: int = kwargs_msg.pop(ATTR_CHAT_ID)
|
||||
_LOGGER.debug("%s to chat ID %s", func_send.__name__, chat_id)
|
||||
|
||||
for file_type in _FILE_TYPES:
|
||||
if file_type in kwargs_msg and isinstance(
|
||||
kwargs_msg[file_type], io.BytesIO
|
||||
):
|
||||
kwargs_msg[file_type].seek(0)
|
||||
response: Message = await self._send_msg(
|
||||
func_send,
|
||||
message_tag,
|
||||
chat_id,
|
||||
*args_msg,
|
||||
context=context,
|
||||
**kwargs_msg,
|
||||
)
|
||||
|
||||
response: Message = await self._send_msg(
|
||||
func_send,
|
||||
message_tag,
|
||||
chat_id,
|
||||
*args_msg,
|
||||
context=context,
|
||||
**kwargs_msg,
|
||||
)
|
||||
if response:
|
||||
msg_ids[str(chat_id)] = response.id
|
||||
|
||||
return msg_ids
|
||||
return {str(chat_id): response.id}
|
||||
|
||||
async def _send_msg(
|
||||
self,
|
||||
func_send: Callable,
|
||||
func_send: Callable[..., Awaitable[Any]],
|
||||
message_tag: str | None,
|
||||
*args_msg: Any,
|
||||
context: Context | None = None,
|
||||
@@ -518,7 +505,7 @@ class TelegramNotificationService:
|
||||
title = kwargs.get(ATTR_TITLE)
|
||||
text = f"{title}\n{message}" if title else message
|
||||
params = self._get_msg_kwargs(kwargs)
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_message,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
text,
|
||||
@@ -759,7 +746,7 @@ class TelegramNotificationService:
|
||||
)
|
||||
|
||||
if file_type == SERVICE_SEND_PHOTO:
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_photo,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -775,7 +762,7 @@ class TelegramNotificationService:
|
||||
)
|
||||
|
||||
if file_type == SERVICE_SEND_STICKER:
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_sticker,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -789,7 +776,7 @@ class TelegramNotificationService:
|
||||
)
|
||||
|
||||
if file_type == SERVICE_SEND_VIDEO:
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_video,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -805,7 +792,7 @@ class TelegramNotificationService:
|
||||
)
|
||||
|
||||
if file_type == SERVICE_SEND_DOCUMENT:
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_document,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -821,7 +808,7 @@ class TelegramNotificationService:
|
||||
)
|
||||
|
||||
if file_type == SERVICE_SEND_VOICE:
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_voice,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -836,7 +823,7 @@ class TelegramNotificationService:
|
||||
)
|
||||
|
||||
# SERVICE_SEND_ANIMATION
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_animation,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -861,7 +848,7 @@ class TelegramNotificationService:
|
||||
stickerid = kwargs.get(ATTR_STICKER_ID)
|
||||
|
||||
if stickerid:
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_sticker,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -886,7 +873,7 @@ class TelegramNotificationService:
|
||||
latitude = float(latitude)
|
||||
longitude = float(longitude)
|
||||
params = self._get_msg_kwargs(kwargs)
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_location,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -911,7 +898,7 @@ class TelegramNotificationService:
|
||||
"""Send a poll."""
|
||||
params = self._get_msg_kwargs(kwargs)
|
||||
openperiod = kwargs.get(ATTR_OPEN_PERIOD)
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_poll,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
|
||||
4
requirements_all.txt
generated
4
requirements_all.txt
generated
@@ -1452,7 +1452,7 @@ loqedAPI==2.1.10
|
||||
luftdaten==0.7.4
|
||||
|
||||
# homeassistant.components.lunatone
|
||||
lunatone-rest-api-client==0.6.3
|
||||
lunatone-rest-api-client==0.7.0
|
||||
|
||||
# homeassistant.components.lupusec
|
||||
lupupy==0.3.2
|
||||
@@ -3326,7 +3326,7 @@ youless-api==2.2.0
|
||||
youtubeaio==2.1.1
|
||||
|
||||
# homeassistant.components.media_extractor
|
||||
yt-dlp[default]==2026.02.04
|
||||
yt-dlp[default]==2026.02.21
|
||||
|
||||
# homeassistant.components.zabbix
|
||||
zabbix-utils==2.0.3
|
||||
|
||||
4
requirements_test_all.txt
generated
4
requirements_test_all.txt
generated
@@ -1271,7 +1271,7 @@ loqedAPI==2.1.10
|
||||
luftdaten==0.7.4
|
||||
|
||||
# homeassistant.components.lunatone
|
||||
lunatone-rest-api-client==0.6.3
|
||||
lunatone-rest-api-client==0.7.0
|
||||
|
||||
# homeassistant.components.lupusec
|
||||
lupupy==0.3.2
|
||||
@@ -2799,7 +2799,7 @@ youless-api==2.2.0
|
||||
youtubeaio==2.1.1
|
||||
|
||||
# homeassistant.components.media_extractor
|
||||
yt-dlp[default]==2026.02.04
|
||||
yt-dlp[default]==2026.02.21
|
||||
|
||||
# homeassistant.components.zamg
|
||||
zamg==0.3.6
|
||||
|
||||
@@ -1895,7 +1895,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
|
||||
"spc",
|
||||
"speedtestdotnet",
|
||||
"spider",
|
||||
"splunk",
|
||||
"spotify",
|
||||
"sql",
|
||||
"srp_energy",
|
||||
|
||||
@@ -5,9 +5,11 @@ from unittest.mock import Mock, patch
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
EntityCategory as ESPHomeEntityCategory,
|
||||
EntityInfo,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.esphome import DOMAIN
|
||||
from homeassistant.components.esphome.entry_data import RuntimeEntryData
|
||||
@@ -152,3 +154,42 @@ async def test_discover_zwave_without_home_id() -> None:
|
||||
)
|
||||
# Verify async_create_flow was NOT called when zwave_home_id is 0
|
||||
mock_create_flow.assert_not_called()
|
||||
|
||||
|
||||
async def test_unknown_entity_type_skipped(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_generic_device_entry: MockGenericDeviceEntryType,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that unknown entity types are skipped gracefully."""
|
||||
|
||||
class UnknownInfo(EntityInfo):
|
||||
"""Mock unknown entity info type."""
|
||||
|
||||
entity_info = [
|
||||
SensorInfo(
|
||||
object_id="mysensor",
|
||||
key=1,
|
||||
name="my sensor",
|
||||
),
|
||||
UnknownInfo(
|
||||
object_id="unknown",
|
||||
key=2,
|
||||
name="unknown entity",
|
||||
),
|
||||
]
|
||||
states = [SensorState(key=1, state=42)]
|
||||
await mock_generic_device_entry(
|
||||
mock_client=mock_client,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
|
||||
assert "UnknownInfo" in caplog.text
|
||||
assert "not supported in this version of Home Assistant" in caplog.text
|
||||
|
||||
# Known entity still works
|
||||
state = hass.states.get("sensor.test_my_sensor")
|
||||
assert state is not None
|
||||
assert state.state == "42"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
@@ -2291,12 +2291,10 @@ async def test_fan_speed(hass: HomeAssistant) -> None:
|
||||
assert trt.sync_attributes() == {
|
||||
"reversible": False,
|
||||
"supportsFanSpeedPercent": True,
|
||||
"availableFanSpeeds": ANY,
|
||||
}
|
||||
|
||||
assert trt.query_attributes() == {
|
||||
"currentFanSpeedPercent": 33,
|
||||
"currentFanSpeedSetting": ANY,
|
||||
}
|
||||
|
||||
assert trt.can_execute(trait.COMMAND_SET_FAN_SPEED, params={"fanSpeedPercent": 10})
|
||||
@@ -2311,7 +2309,7 @@ async def test_fan_speed(hass: HomeAssistant) -> None:
|
||||
|
||||
|
||||
async def test_fan_speed_without_percentage_step(hass: HomeAssistant) -> None:
|
||||
"""Test FanSpeed trait speed control percentage step for fan domain."""
|
||||
"""Test FanSpeed trait falls back to percent-only when percentage_step is missing."""
|
||||
assert helpers.get_google_type(fan.DOMAIN, None) is not None
|
||||
assert trait.FanSpeedTrait.supported(
|
||||
fan.DOMAIN, FanEntityFeature.SET_SPEED, None, None
|
||||
@@ -2322,6 +2320,9 @@ async def test_fan_speed_without_percentage_step(hass: HomeAssistant) -> None:
|
||||
State(
|
||||
"fan.living_room_fan",
|
||||
STATE_ON,
|
||||
attributes={
|
||||
"percentage": 50,
|
||||
},
|
||||
),
|
||||
BASIC_CONFIG,
|
||||
)
|
||||
@@ -2329,12 +2330,10 @@ async def test_fan_speed_without_percentage_step(hass: HomeAssistant) -> None:
|
||||
assert trt.sync_attributes() == {
|
||||
"reversible": False,
|
||||
"supportsFanSpeedPercent": True,
|
||||
"availableFanSpeeds": ANY,
|
||||
}
|
||||
# If a fan state has (temporary) no percentage_step attribute return 1 available
|
||||
|
||||
assert trt.query_attributes() == {
|
||||
"currentFanSpeedPercent": 0,
|
||||
"currentFanSpeedSetting": "1/5",
|
||||
"currentFanSpeedPercent": 50,
|
||||
}
|
||||
|
||||
|
||||
@@ -2343,7 +2342,7 @@ async def test_fan_speed_without_percentage_step(hass: HomeAssistant) -> None:
|
||||
[
|
||||
(
|
||||
33,
|
||||
1.0,
|
||||
20.0,
|
||||
"2/5",
|
||||
[
|
||||
["Low", "Min", "Slow", "1"],
|
||||
@@ -2356,7 +2355,7 @@ async def test_fan_speed_without_percentage_step(hass: HomeAssistant) -> None:
|
||||
),
|
||||
(
|
||||
40,
|
||||
1.0,
|
||||
20.0,
|
||||
"2/5",
|
||||
[
|
||||
["Low", "Min", "Slow", "1"],
|
||||
@@ -2421,7 +2420,7 @@ async def test_fan_speed_ordered(
|
||||
|
||||
assert trt.sync_attributes() == {
|
||||
"reversible": False,
|
||||
"supportsFanSpeedPercent": True,
|
||||
"supportsFanSpeedPercent": False,
|
||||
"availableFanSpeeds": {
|
||||
"ordered": True,
|
||||
"speeds": [
|
||||
@@ -2435,7 +2434,6 @@ async def test_fan_speed_ordered(
|
||||
}
|
||||
|
||||
assert trt.query_attributes() == {
|
||||
"currentFanSpeedPercent": percentage,
|
||||
"currentFanSpeedSetting": speed,
|
||||
}
|
||||
|
||||
@@ -2484,12 +2482,10 @@ async def test_fan_reverse(
|
||||
assert trt.sync_attributes() == {
|
||||
"reversible": True,
|
||||
"supportsFanSpeedPercent": True,
|
||||
"availableFanSpeeds": ANY,
|
||||
}
|
||||
|
||||
assert trt.query_attributes() == {
|
||||
"currentFanSpeedPercent": 33,
|
||||
"currentFanSpeedSetting": ANY,
|
||||
}
|
||||
|
||||
assert trt.can_execute(trait.COMMAND_REVERSE, params={})
|
||||
|
||||
@@ -13,7 +13,7 @@ import jwt
|
||||
import pytest
|
||||
import yarl
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY
|
||||
from homeassistant.auth.const import GROUP_ID_READ_ONLY
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.auth.providers import trusted_networks
|
||||
from homeassistant.auth.providers.homeassistant import HassAuthProvider
|
||||
@@ -32,7 +32,6 @@ from homeassistant.components.http.request_context import (
|
||||
current_request,
|
||||
setup_request_context,
|
||||
)
|
||||
from homeassistant.const import HASSIO_USER_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -659,78 +658,3 @@ async def test_create_user_once(hass: HomeAssistant) -> None:
|
||||
|
||||
# test it did not create a user
|
||||
assert len(await hass.auth.async_get_users()) == cur_users + 1
|
||||
|
||||
|
||||
async def test_unix_socket_auth_with_supervisor_user(
|
||||
hass: HomeAssistant,
|
||||
app: web.Application,
|
||||
aiohttp_client: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Test that Unix socket requests are authenticated as Supervisor user."""
|
||||
supervisor_user = await hass.auth.async_create_system_user(
|
||||
HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN]
|
||||
)
|
||||
await hass.auth.async_create_refresh_token(supervisor_user)
|
||||
|
||||
await async_setup_auth(hass, app)
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.http.auth.is_unix_socket_request", return_value=True
|
||||
):
|
||||
req = await client.get("/")
|
||||
assert req.status == HTTPStatus.OK
|
||||
data = await req.json()
|
||||
assert data["user_id"] == supervisor_user.id
|
||||
|
||||
|
||||
async def test_unix_socket_auth_without_supervisor_user(
|
||||
hass: HomeAssistant,
|
||||
app: web.Application,
|
||||
aiohttp_client: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Test that Unix socket requests fail when no Supervisor user exists."""
|
||||
await async_setup_auth(hass, app)
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.http.auth.is_unix_socket_request", return_value=True
|
||||
):
|
||||
req = await client.get("/")
|
||||
assert req.status == HTTPStatus.UNAUTHORIZED
|
||||
|
||||
|
||||
async def test_unix_socket_auth_caches_user_id(
|
||||
hass: HomeAssistant,
|
||||
app: web.Application,
|
||||
aiohttp_client: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Test that Unix socket auth caches the Supervisor user ID."""
|
||||
supervisor_user = await hass.auth.async_create_system_user(
|
||||
HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN]
|
||||
)
|
||||
await hass.auth.async_create_refresh_token(supervisor_user)
|
||||
|
||||
await async_setup_auth(hass, app)
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.http.auth.is_unix_socket_request", return_value=True
|
||||
):
|
||||
# First request triggers user lookup
|
||||
req = await client.get("/")
|
||||
assert req.status == HTTPStatus.OK
|
||||
|
||||
# Second request should use cached user ID
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.http.auth.is_unix_socket_request",
|
||||
return_value=True,
|
||||
),
|
||||
patch.object(
|
||||
hass.auth, "async_get_users", wraps=hass.auth.async_get_users
|
||||
) as mock_get_users,
|
||||
):
|
||||
req = await client.get("/")
|
||||
assert req.status == HTTPStatus.OK
|
||||
mock_get_users.assert_not_called()
|
||||
|
||||
@@ -465,33 +465,3 @@ async def test_single_ban_file_entry(
|
||||
await manager.async_add_ban(remote_ip)
|
||||
|
||||
assert m_open.call_count == 1
|
||||
|
||||
|
||||
async def test_unix_socket_skips_ban_check(
|
||||
hass: HomeAssistant, aiohttp_client: ClientSessionGenerator
|
||||
) -> None:
|
||||
"""Test that Unix socket requests bypass ban middleware."""
|
||||
app = web.Application()
|
||||
app[KEY_HASS] = hass
|
||||
setup_bans(hass, app, 5)
|
||||
set_real_ip = mock_real_ip(app)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.http.ban.load_yaml_config_file",
|
||||
return_value={
|
||||
banned_ip: {"banned_at": "2016-11-16T19:20:03"} for banned_ip in BANNED_IPS
|
||||
},
|
||||
):
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
# Verify the IP is actually banned for normal requests
|
||||
set_real_ip(BANNED_IPS[0])
|
||||
resp = await client.get("/")
|
||||
assert resp.status == HTTPStatus.FORBIDDEN
|
||||
|
||||
# Unix socket requests should bypass ban checks
|
||||
with patch(
|
||||
"homeassistant.components.http.ban.is_unix_socket_request", return_value=True
|
||||
):
|
||||
resp = await client.get("/")
|
||||
assert resp.status == HTTPStatus.NOT_FOUND
|
||||
|
||||
@@ -6,7 +6,6 @@ from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
from ipaddress import ip_network
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import ANY, Mock, patch
|
||||
|
||||
@@ -736,81 +735,3 @@ async def test_server_host(
|
||||
)
|
||||
|
||||
assert set(issue_registry.issues) == expected_issues
|
||||
|
||||
|
||||
async def test_unix_socket_started_with_supervisor(
|
||||
hass: HomeAssistant,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test unix socket is started when running under Supervisor."""
|
||||
socket_path = tmp_path / "core.sock"
|
||||
mock_server = Mock()
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ, {"SUPERVISOR_CORE_API_SOCKET": str(socket_path)}, clear=False
|
||||
),
|
||||
patch("asyncio.BaseEventLoop.create_server", return_value=mock_server),
|
||||
patch(
|
||||
"asyncio.unix_events._UnixSelectorEventLoop.create_unix_server",
|
||||
return_value=mock_server,
|
||||
) as mock_create_unix,
|
||||
):
|
||||
assert await async_setup_component(hass, "http", {"http": {}})
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_create_unix.assert_called_once_with(
|
||||
ANY,
|
||||
socket_path,
|
||||
backlog=128,
|
||||
)
|
||||
assert hass.http.unix_site is not None
|
||||
|
||||
|
||||
async def test_unix_socket_not_started_without_supervisor(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test unix socket is not started when not running under Supervisor."""
|
||||
mock_server = Mock()
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch("asyncio.BaseEventLoop.create_server", return_value=mock_server),
|
||||
patch(
|
||||
"asyncio.unix_events._UnixSelectorEventLoop.create_unix_server",
|
||||
return_value=mock_server,
|
||||
) as mock_create_unix,
|
||||
):
|
||||
os.environ.pop("SUPERVISOR_CORE_API_SOCKET", None)
|
||||
assert await async_setup_component(hass, "http", {"http": {}})
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_create_unix.assert_not_called()
|
||||
assert hass.http.unix_site is None
|
||||
|
||||
|
||||
async def test_unix_socket_rejected_relative_path(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test unix socket is rejected when path is relative."""
|
||||
mock_server = Mock()
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"SUPERVISOR_CORE_API_SOCKET": "relative/path.sock"},
|
||||
clear=False,
|
||||
),
|
||||
patch("asyncio.BaseEventLoop.create_server", return_value=mock_server),
|
||||
patch(
|
||||
"asyncio.unix_events._UnixSelectorEventLoop.create_unix_server",
|
||||
return_value=mock_server,
|
||||
) as mock_create_unix,
|
||||
):
|
||||
assert await async_setup_component(hass, "http", {"http": {}})
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_create_unix.assert_not_called()
|
||||
assert hass.http.unix_site is None
|
||||
assert "path must be absolute" in caplog.text
|
||||
|
||||
@@ -42,3 +42,21 @@ HUE_BLE_SERVICE_INFO = BluetoothServiceInfoBleak(
|
||||
connectable=True,
|
||||
tx_power=-127,
|
||||
)
|
||||
|
||||
NOT_HUE_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak(
|
||||
name="Not",
|
||||
address="AA:BB:CC:DD:EE:F2",
|
||||
rssi=-60,
|
||||
manufacturer_data={
|
||||
33: b"\x00\x00\xd1\xf0b;\xd8\x1dE\xd6\xba\xeeL\xdd]\xf5\xb2\xe9",
|
||||
21: b"\x061\x00Z\x8f\x93\xb2\xec\x85\x06\x00i\x00\x02\x02Q\xed\x1d\xf0",
|
||||
},
|
||||
service_uuids=[],
|
||||
service_data={},
|
||||
source="local",
|
||||
device=generate_ble_device(address="AA:BB:CC:DD:EE:F2", name="Aug"),
|
||||
advertisement=generate_advertisement_data(),
|
||||
time=0,
|
||||
connectable=True,
|
||||
tx_power=-127,
|
||||
)
|
||||
|
||||
@@ -2,23 +2,28 @@
|
||||
|
||||
from unittest.mock import AsyncMock, PropertyMock, patch
|
||||
|
||||
from habluetooth import BluetoothServiceInfoBleak
|
||||
from HueBLE import ConnectionError, HueBleError, PairingError
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.hue_ble.config_flow import Error
|
||||
from homeassistant.components.hue_ble.const import (
|
||||
DOMAIN,
|
||||
URL_FACTORY_RESET,
|
||||
URL_PAIRING_MODE,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_BLUETOOTH
|
||||
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER
|
||||
from homeassistant.const import CONF_MAC, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from . import HUE_BLE_SERVICE_INFO, TEST_DEVICE_MAC, TEST_DEVICE_NAME
|
||||
from . import (
|
||||
HUE_BLE_SERVICE_INFO,
|
||||
NOT_HUE_BLE_DISCOVERY_INFO,
|
||||
TEST_DEVICE_MAC,
|
||||
TEST_DEVICE_NAME,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.bluetooth import BLEDevice, generate_ble_device
|
||||
@@ -27,17 +32,34 @@ AUTH_ERROR = ConnectionError()
|
||||
AUTH_ERROR.__cause__ = PairingError()
|
||||
|
||||
|
||||
async def test_bluetooth_form(
|
||||
async def test_user_form(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test bluetooth discovery form."""
|
||||
"""Test user form."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_BLUETOOTH},
|
||||
data=HUE_BLE_SERVICE_INFO,
|
||||
with patch(
|
||||
"homeassistant.components.hue_ble.config_flow.bluetooth.async_discovered_service_info",
|
||||
return_value=[NOT_HUE_BLE_DISCOVERY_INFO, HUE_BLE_SERVICE_INFO],
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["data_schema"].schema[CONF_MAC].container == {
|
||||
HUE_BLE_SERVICE_INFO.address: (
|
||||
f"{HUE_BLE_SERVICE_INFO.name} ({HUE_BLE_SERVICE_INFO.address})"
|
||||
),
|
||||
}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_MAC: HUE_BLE_SERVICE_INFO.address},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "confirm"
|
||||
assert result["description_placeholders"] == {
|
||||
@@ -78,6 +100,27 @@ async def test_bluetooth_form(
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("discovery_info", [[NOT_HUE_BLE_DISCOVERY_INFO], []])
|
||||
async def test_user_form_no_device(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
discovery_info: list[BluetoothServiceInfoBleak],
|
||||
) -> None:
|
||||
"""Test user form with no devices."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.hue_ble.config_flow.bluetooth.async_discovered_service_info",
|
||||
return_value=discovery_info,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "no_devices_found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"mock_return_device",
|
||||
@@ -155,7 +198,7 @@ async def test_bluetooth_form(
|
||||
"unknown",
|
||||
],
|
||||
)
|
||||
async def test_bluetooth_form_exception(
|
||||
async def test_user_form_exception(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_return_device: BLEDevice | None,
|
||||
@@ -165,13 +208,30 @@ async def test_bluetooth_form_exception(
|
||||
mock_poll_state: Exception | None,
|
||||
error: Error,
|
||||
) -> None:
|
||||
"""Test bluetooth discovery form with errors."""
|
||||
"""Test user form with errors."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_BLUETOOTH},
|
||||
data=HUE_BLE_SERVICE_INFO,
|
||||
with patch(
|
||||
"homeassistant.components.hue_ble.config_flow.bluetooth.async_discovered_service_info",
|
||||
return_value=[NOT_HUE_BLE_DISCOVERY_INFO, HUE_BLE_SERVICE_INFO],
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["data_schema"].schema[CONF_MAC].container == {
|
||||
HUE_BLE_SERVICE_INFO.address: (
|
||||
f"{HUE_BLE_SERVICE_INFO.name} ({HUE_BLE_SERVICE_INFO.address})"
|
||||
),
|
||||
}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_MAC: HUE_BLE_SERVICE_INFO.address},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "confirm"
|
||||
|
||||
@@ -232,17 +292,19 @@ async def test_bluetooth_form_exception(
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_user_form_exception(
|
||||
async def test_bluetooth_discovery_aborts(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test the user form raises a discovery only error."""
|
||||
"""Test bluetooth form aborts."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_BLUETOOTH},
|
||||
data=HUE_BLE_SERVICE_INFO,
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "not_implemented"
|
||||
assert result["reason"] == "discovery_unsupported"
|
||||
|
||||
|
||||
async def test_bluetooth_form_exception_already_set_up(
|
||||
@@ -260,4 +322,38 @@ async def test_bluetooth_form_exception_already_set_up(
|
||||
data=HUE_BLE_SERVICE_INFO,
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "discovery_unsupported"
|
||||
|
||||
|
||||
async def test_user_form_exception_already_set_up(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test user form when device is already set up."""
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.hue_ble.config_flow.bluetooth.async_discovered_service_info",
|
||||
return_value=[NOT_HUE_BLE_DISCOVERY_INFO, HUE_BLE_SERVICE_INFO],
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["data_schema"].schema[CONF_MAC].container == {
|
||||
HUE_BLE_SERVICE_INFO.address: (
|
||||
f"{HUE_BLE_SERVICE_INFO.name} ({HUE_BLE_SERVICE_INFO.address})"
|
||||
),
|
||||
}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_MAC: HUE_BLE_SERVICE_INFO.address},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
@@ -11,7 +11,7 @@ from lunatone_rest_api_client.models import (
|
||||
InfoData,
|
||||
LineStatus,
|
||||
)
|
||||
from lunatone_rest_api_client.models.common import ColorRGBData, ColorWAFData, Status
|
||||
from lunatone_rest_api_client.models.common import Status
|
||||
from lunatone_rest_api_client.models.devices import DeviceStatus
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -77,13 +77,7 @@ def build_device_data_list() -> list[DeviceData]:
|
||||
name="Device 1",
|
||||
available=True,
|
||||
status=DeviceStatus(),
|
||||
features=FeaturesStatus(
|
||||
switchable=Status[bool](status=False),
|
||||
dimmable=Status[float](status=0.0),
|
||||
colorKelvin=Status[int](status=1000),
|
||||
colorRGB=Status[ColorRGBData](status=ColorRGBData(r=0, g=0, b=0)),
|
||||
colorWAF=Status[ColorWAFData](status=ColorWAFData(w=0, a=0, f=0)),
|
||||
),
|
||||
features=FeaturesStatus(switchable=Status[bool](status=False)),
|
||||
address=0,
|
||||
line=0,
|
||||
),
|
||||
@@ -95,9 +89,6 @@ def build_device_data_list() -> list[DeviceData]:
|
||||
features=FeaturesStatus(
|
||||
switchable=Status[bool](status=False),
|
||||
dimmable=Status[float](status=0.0),
|
||||
colorKelvin=Status[int](status=1000),
|
||||
colorRGB=Status[ColorRGBData](status=ColorRGBData(r=0, g=0, b=0)),
|
||||
colorWAF=Status[ColorWAFData](status=ColorWAFData(w=0, a=0, f=0)),
|
||||
),
|
||||
address=1,
|
||||
line=0,
|
||||
|
||||
@@ -27,7 +27,6 @@ def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
@pytest.fixture
|
||||
def mock_lunatone_devices() -> Generator[AsyncMock]:
|
||||
"""Mock a Lunatone devices object."""
|
||||
state = {"is_dimmable": False}
|
||||
|
||||
def build_devices_mock(devices: Devices):
|
||||
device_list = []
|
||||
@@ -39,9 +38,10 @@ def mock_lunatone_devices() -> Generator[AsyncMock]:
|
||||
device.id = device.data.id
|
||||
device.name = device.data.name
|
||||
device.is_on = device.data.features.switchable.status
|
||||
device.brightness = device.data.features.dimmable.status
|
||||
type(device).is_dimmable = PropertyMock(
|
||||
side_effect=lambda s=state: s["is_dimmable"]
|
||||
device.brightness = (
|
||||
device.data.features.dimmable.status
|
||||
if device.data.features.dimmable
|
||||
else None
|
||||
)
|
||||
device_list.append(device)
|
||||
return device_list
|
||||
@@ -54,7 +54,6 @@ def mock_lunatone_devices() -> Generator[AsyncMock]:
|
||||
type(devices).devices = PropertyMock(
|
||||
side_effect=lambda d=devices: build_devices_mock(d)
|
||||
)
|
||||
devices.set_is_dimmable = lambda value, s=state: s.update(is_dimmable=value)
|
||||
yield devices
|
||||
|
||||
|
||||
|
||||
@@ -8,34 +8,18 @@
|
||||
'dali_types': list([
|
||||
]),
|
||||
'features': dict({
|
||||
'color_kelvin': dict({
|
||||
'status': 1000.0,
|
||||
}),
|
||||
'color_kelvin': None,
|
||||
'color_kelvin_with_fade': None,
|
||||
'color_rgb': dict({
|
||||
'status': dict({
|
||||
'blue': 0.0,
|
||||
'green': 0.0,
|
||||
'red': 0.0,
|
||||
}),
|
||||
}),
|
||||
'color_rgb': None,
|
||||
'color_rgb_with_fade': None,
|
||||
'color_waf': dict({
|
||||
'status': dict({
|
||||
'amber': 0.0,
|
||||
'free_color': 0.0,
|
||||
'white': 0.0,
|
||||
}),
|
||||
}),
|
||||
'color_waf': None,
|
||||
'color_waf_with_fade': None,
|
||||
'color_xy': None,
|
||||
'color_xy_with_fade': None,
|
||||
'dali_cmd16': None,
|
||||
'dim_down': None,
|
||||
'dim_up': None,
|
||||
'dimmable': dict({
|
||||
'status': 0.0,
|
||||
}),
|
||||
'dimmable': None,
|
||||
'dimmable_kelvin': None,
|
||||
'dimmable_rgb': None,
|
||||
'dimmable_waf': None,
|
||||
@@ -79,25 +63,11 @@
|
||||
'dali_types': list([
|
||||
]),
|
||||
'features': dict({
|
||||
'color_kelvin': dict({
|
||||
'status': 1000.0,
|
||||
}),
|
||||
'color_kelvin': None,
|
||||
'color_kelvin_with_fade': None,
|
||||
'color_rgb': dict({
|
||||
'status': dict({
|
||||
'blue': 0.0,
|
||||
'green': 0.0,
|
||||
'red': 0.0,
|
||||
}),
|
||||
}),
|
||||
'color_rgb': None,
|
||||
'color_rgb_with_fade': None,
|
||||
'color_waf': dict({
|
||||
'status': dict({
|
||||
'amber': 0.0,
|
||||
'free_color': 0.0,
|
||||
'white': 0.0,
|
||||
}),
|
||||
}),
|
||||
'color_waf': None,
|
||||
'color_waf_with_fade': None,
|
||||
'color_xy': None,
|
||||
'color_xy_with_fade': None,
|
||||
@@ -208,6 +178,7 @@
|
||||
'node_red': False,
|
||||
'startup_mode': 'normal',
|
||||
'tier': 'basic',
|
||||
'uid': None,
|
||||
'version': 'v1.14.1/1.4.3',
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.ONOFF: 'onoff'>,
|
||||
<ColorMode.BRIGHTNESS: 'brightness'>,
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
@@ -100,10 +100,11 @@
|
||||
# name: test_setup[light.device_2-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'brightness': None,
|
||||
'color_mode': None,
|
||||
'friendly_name': 'Device 2',
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.ONOFF: 'onoff'>,
|
||||
<ColorMode.BRIGHTNESS: 'brightness'>,
|
||||
]),
|
||||
'supported_features': <LightEntityFeature: 0>,
|
||||
}),
|
||||
|
||||
@@ -22,8 +22,6 @@ from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_ENTITY_ID = "light.device_1"
|
||||
|
||||
|
||||
async def test_setup(
|
||||
hass: HomeAssistant,
|
||||
@@ -52,10 +50,13 @@ async def test_turn_on_off(
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the light can be turned on and off."""
|
||||
device_id = 1
|
||||
entity_id = f"light.device_{device_id}"
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
async def fake_update():
|
||||
device = mock_lunatone_devices.data.devices[0]
|
||||
device = mock_lunatone_devices.data.devices[device_id - 1]
|
||||
device.features.switchable.status = not device.features.switchable.status
|
||||
|
||||
mock_lunatone_devices.async_update.side_effect = fake_update
|
||||
@@ -63,22 +64,22 @@ async def test_turn_on_off(
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_ON
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
@@ -90,16 +91,16 @@ async def test_turn_on_off_with_brightness(
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the light can be turned on with brightness."""
|
||||
device_id = 2
|
||||
entity_id = f"light.device_{device_id}"
|
||||
expected_brightness = 128
|
||||
brightness_percentages = iter([50.0, 0.0, 50.0])
|
||||
|
||||
mock_lunatone_devices.set_is_dimmable(True)
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
async def fake_update():
|
||||
brightness = next(brightness_percentages)
|
||||
device = mock_lunatone_devices.data.devices[0]
|
||||
device = mock_lunatone_devices.data.devices[device_id - 1]
|
||||
device.features.switchable.status = brightness > 0
|
||||
device.features.dimmable.status = brightness
|
||||
|
||||
@@ -108,11 +109,11 @@ async def test_turn_on_off_with_brightness(
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_BRIGHTNESS: expected_brightness},
|
||||
{ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: expected_brightness},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes["brightness"] == expected_brightness
|
||||
@@ -120,11 +121,11 @@ async def test_turn_on_off_with_brightness(
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_OFF
|
||||
assert not state.attributes["brightness"]
|
||||
@@ -132,11 +133,11 @@ async def test_turn_on_off_with_brightness(
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes["brightness"] == expected_brightness
|
||||
|
||||
Binary file not shown.
BIN
tests/fixtures/core/backup_restore/backup_with_database_protected_v2.tar
vendored
Normal file
BIN
tests/fixtures/core/backup_restore/backup_with_database_protected_v2.tar
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/core/backup_restore/backup_with_database_protected_v3.tar
vendored
Normal file
BIN
tests/fixtures/core/backup_restore/backup_with_database_protected_v3.tar
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/core/backup_restore/malicious_backup_with_database.tar
vendored
Normal file
BIN
tests/fixtures/core/backup_restore/malicious_backup_with_database.tar
vendored
Normal file
Binary file not shown.
@@ -240,6 +240,14 @@ def test_aborting_for_older_versions(restore_config: str, tmp_path: Path) -> Non
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("backup", "password"),
|
||||
[
|
||||
("backup_with_database.tar", None),
|
||||
("backup_with_database_protected_v2.tar", "hunter2"),
|
||||
("backup_with_database_protected_v3.tar", "hunter2"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"restore_backup_content",
|
||||
@@ -287,6 +295,8 @@ def test_aborting_for_older_versions(restore_config: str, tmp_path: Path) -> Non
|
||||
],
|
||||
)
|
||||
def test_restore_backup(
|
||||
backup: str,
|
||||
password: str | None,
|
||||
restore_backup_content: backup_restore.RestoreBackupFileContent,
|
||||
expected_kept_files: set[str],
|
||||
expected_restored_files: set[str],
|
||||
@@ -321,9 +331,7 @@ def test_restore_backup(
|
||||
for f in existing_files:
|
||||
(tmp_path / f).write_text("before_restore")
|
||||
|
||||
get_fixture_path(
|
||||
"core/backup_restore/empty_backup_database_included.tar", None
|
||||
).copy(backup_file_path)
|
||||
get_fixture_path(f"core/backup_restore/{backup}", None).copy(backup_file_path)
|
||||
|
||||
files_before_restore = get_files(tmp_path)
|
||||
assert files_before_restore == {
|
||||
@@ -341,6 +349,7 @@ def test_restore_backup(
|
||||
kept_files_data[file] = (tmp_path / file).read_bytes()
|
||||
|
||||
restore_backup_content.backup_file_path = backup_file_path
|
||||
restore_backup_content.password = password
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -378,7 +387,7 @@ def test_restore_backup_filter_files(tmp_path: Path) -> None:
|
||||
backup_file_path = tmp_path / "backups" / "test.tar"
|
||||
backup_file_path.parent.mkdir()
|
||||
get_fixture_path(
|
||||
"core/backup_restore/empty_backup_database_included.tar", None
|
||||
"core/backup_restore/malicious_backup_with_database.tar", None
|
||||
).copy(backup_file_path)
|
||||
|
||||
with (
|
||||
@@ -440,9 +449,9 @@ def test_remove_backup_file_after_restore(
|
||||
"""Test removing a backup file after restore."""
|
||||
backup_file_path = tmp_path / "backups" / "test.tar"
|
||||
backup_file_path.parent.mkdir()
|
||||
get_fixture_path(
|
||||
"core/backup_restore/empty_backup_database_included.tar", None
|
||||
).copy(backup_file_path)
|
||||
get_fixture_path("core/backup_restore/backup_with_database.tar", None).copy(
|
||||
backup_file_path
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
|
||||
Reference in New Issue
Block a user