Compare commits

..

18 Commits

Author SHA1 Message Date
dependabot[bot]
e7b6ef04ee Bump actions/setup-python from 6.1.0 to 6.2.0
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](83679a892e...a309ff8b42)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-22 06:02:51 +00:00
Robert Resch
6b14eb7ad1 Migrate config entries to string unique id (#161370) 2026-01-21 23:36:21 -05:00
J. Nick Koston
83a53dea94 Fix SSL context mutation by httpx/httpcore with ALPN protocol bucketing (#161330) 2026-01-21 16:53:38 -10:00
Joost Lekkerkerker
4fb89e68a7 Add integration_type hub to sanix (#161322) 2026-01-21 23:18:32 +01:00
Glenn de Haan
5202ddf095 Bump hdfury to 1.4.2 (#161401) 2026-01-21 23:06:06 +01:00
Marc Mueller
f7d7a4502e Update ruff to 0.14.13 (#161399) 2026-01-21 22:43:26 +01:00
Petro31
c7417d77b5 Update template select test framework (#161389) 2026-01-21 22:31:00 +01:00
Petro31
22018f1f80 Update template number tests to new framework (#161395) 2026-01-21 22:30:13 +01:00
Raphael Hehl
22c6704d81 Fix detection of multiple smart object types in single event (#161189)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-21 22:22:34 +01:00
Raphael Hehl
0552934b3c Bump uiprotect to 10.0.1 (#161397)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-01-21 22:20:33 +01:00
Joost Lekkerkerker
bbe1d28e88 Refactor GitHub tests to patch the library instead (#160568) 2026-01-21 22:09:56 +01:00
Robert Resch
b700a27c8f Enable apple tv on Python 3.14 (#161396) 2026-01-21 21:56:51 +01:00
Joost Lekkerkerker
0566a668a9 Add translation for add entry to RDW (#161329) 2026-01-21 21:28:27 +01:00
Marc Mueller
94f636bc2d Update pyatv to 0.17.0 (#161394) 2026-01-21 21:22:26 +01:00
Manu
a6e7546142 Add support for sequence ID to publish action in ntfy integration (#161342) 2026-01-21 17:41:46 +00:00
Thomas55555
493319894b Use device_class for O3 in Google Air Quality (#161380) 2026-01-21 17:34:46 +01:00
Erik Montnemery
987396722b Adjust entity condition strings (#161055) 2026-01-21 16:56:47 +01:00
epenet
4f52b0363d Reorder unit conversion classes alphabetically (#161364) 2026-01-21 15:53:43 +00:00
89 changed files with 1672 additions and 1638 deletions

View File

@@ -33,7 +33,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -122,7 +122,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -477,7 +477,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}

View File

@@ -297,7 +297,7 @@ jobs:
- &setup-python-matrix
name: Set up Python ${{ matrix.python-version }}
id: python
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: &actions-setup-python actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true

View File

@@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}

View File

@@ -35,7 +35,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.0
rev: v0.14.13
hooks:
- id: ruff-check
args:

View File

@@ -14,7 +14,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed"
"name": "Alarm is armed"
},
"is_armed_away": {
"description": "Tests if one or more alarms are armed in away mode.",
@@ -24,7 +24,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed away"
"name": "Alarm is armed away"
},
"is_armed_home": {
"description": "Tests if one or more alarms are armed in home mode.",
@@ -34,7 +34,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed home"
"name": "Alarm is armed home"
},
"is_armed_night": {
"description": "Tests if one or more alarms are armed in night mode.",
@@ -44,7 +44,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed night"
"name": "Alarm is armed night"
},
"is_armed_vacation": {
"description": "Tests if one or more alarms are armed in vacation mode.",
@@ -54,7 +54,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed vacation"
"name": "Alarm is armed vacation"
},
"is_disarmed": {
"description": "Tests if one or more alarms are disarmed.",
@@ -64,7 +64,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is disarmed"
"name": "Alarm is disarmed"
},
"is_triggered": {
"description": "Tests if one or more alarms are triggered.",
@@ -74,7 +74,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is triggered"
"name": "Alarm is triggered"
}
},
"device_automation": {

View File

@@ -5,9 +5,14 @@ from __future__ import annotations
import asyncio
import logging
from random import randrange
import sys
from typing import Any, cast
from pyatv import connect, exceptions, scan
from pyatv.conf import AppleTV
from pyatv.const import DeviceModel, Protocol
from pyatv.convert import model_str
from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -24,11 +29,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -42,18 +43,6 @@ from .const import (
SIGNAL_DISCONNECTED,
)
if sys.version_info < (3, 14):
from pyatv import connect, exceptions, scan
from pyatv.conf import AppleTV
from pyatv.const import DeviceModel, Protocol
from pyatv.convert import model_str
from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener
else:
class DeviceListener:
"""Dummy class."""
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME_TV = "Apple TV"
@@ -64,30 +53,25 @@ BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
if sys.version_info < (3, 14):
AUTH_EXCEPTIONS = (
exceptions.AuthenticationError,
exceptions.InvalidCredentialsError,
exceptions.NoCredentialsError,
)
CONNECTION_TIMEOUT_EXCEPTIONS = (
OSError,
asyncio.CancelledError,
TimeoutError,
exceptions.ConnectionLostError,
exceptions.ConnectionFailedError,
)
DEVICE_EXCEPTIONS = (
exceptions.ProtocolError,
exceptions.NoServiceError,
exceptions.PairingError,
exceptions.BackOffError,
exceptions.DeviceIdMissingError,
)
else:
AUTH_EXCEPTIONS = ()
CONNECTION_TIMEOUT_EXCEPTIONS = ()
DEVICE_EXCEPTIONS = ()
AUTH_EXCEPTIONS = (
exceptions.AuthenticationError,
exceptions.InvalidCredentialsError,
exceptions.NoCredentialsError,
)
CONNECTION_TIMEOUT_EXCEPTIONS = (
OSError,
asyncio.CancelledError,
TimeoutError,
exceptions.ConnectionLostError,
exceptions.ConnectionFailedError,
)
DEVICE_EXCEPTIONS = (
exceptions.ProtocolError,
exceptions.NoServiceError,
exceptions.PairingError,
exceptions.BackOffError,
exceptions.DeviceIdMissingError,
)
type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
@@ -95,10 +79,6 @@ type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool:
"""Set up a config entry for Apple TV."""
if sys.version_info >= (3, 14):
raise HomeAssistantError(
"Apple TV is not supported on Python 3.14. Please use Python 3.13."
)
manager = AppleTVManager(hass, entry)
if manager.is_on:

View File

@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyatv", "srptools"],
"requirements": ["pyatv==0.16.1;python_version<'3.14'"],
"requirements": ["pyatv==0.17.0"],
"zeroconf": [
"_mediaremotetv._tcp.local.",
"_companion-link._tcp.local.",

View File

@@ -239,6 +239,15 @@ class AppleTvMediaPlayer(
"""
self.async_write_ha_state()
@callback
def volume_device_update(
self, output_device: OutputDevice, old_level: float, new_level: float
) -> None:
"""Output device volume was updated.
This is a callback function from pyatv.interface.AudioListener.
"""
@callback
def outputdevices_update(
self, old_devices: list[OutputDevice], new_devices: list[OutputDevice]

View File

@@ -2,14 +2,35 @@
from __future__ import annotations
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import ArveConfigEntry, ArveCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_migrate_entry(hass: HomeAssistant, entry: ArveConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
if entry.version == 1:
# 1 -> 1.2: Unique ID from integer to string
if entry.minor_version == 1:
minor_version = 2
hass.config_entries.async_update_entry(
entry, unique_id=str(entry.unique_id), minor_version=minor_version
)
_LOGGER.debug("Migration successful")
return True
async def async_setup_entry(hass: HomeAssistant, entry: ArveConfigEntry) -> bool:
"""Set up Arve from a config entry."""

View File

@@ -19,6 +19,9 @@ _LOGGER = logging.getLogger(__name__)
class ArveConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Arve."""
VERSION = 1
MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -35,7 +38,7 @@ class ArveConfigFlowHandler(ConfigFlow, domain=DOMAIN):
except ArveConnectionError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(customer.customerId)
await self.async_set_unique_id(str(customer.customerId))
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Arve",

View File

@@ -14,7 +14,7 @@
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is idle"
"name": "Satellite is idle"
},
"is_listening": {
"description": "Tests if one or more Assist satellites are listening.",
@@ -24,7 +24,7 @@
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is listening"
"name": "Satellite is listening"
},
"is_processing": {
"description": "Tests if one or more Assist satellites are processing.",
@@ -34,7 +34,7 @@
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is processing"
"name": "Satellite is processing"
},
"is_responding": {
"description": "Tests if one or more Assist satellites are responding.",
@@ -44,7 +44,7 @@
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is responding"
"name": "Satellite is responding"
}
},
"entity_component": {

View File

@@ -125,7 +125,6 @@ NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
_EXPERIMENTAL_CONDITION_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
"device_tracker",
"fan",
"light",
"siren",

View File

@@ -1,17 +0,0 @@
"""Provides conditions for device trackers."""
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"is_home": make_entity_state_condition(DOMAIN, STATE_HOME),
"is_not_home": make_entity_state_condition(DOMAIN, STATE_NOT_HOME),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for device trackers."""
return CONDITIONS

View File

@@ -1,17 +0,0 @@
.condition_common: &condition_common
target:
entity:
domain: device_tracker
fields:
behavior:
required: true
default: any
selector:
select:
options:
- all
- any
translation_key: condition_behavior
is_home: *condition_common
is_not_home: *condition_common

View File

@@ -1,12 +1,4 @@
{
"conditions": {
"is_home": {
"condition": "mdi:account"
},
"is_not_home": {
"condition": "mdi:account-arrow-right"
}
},
"entity_component": {
"_": {
"default": "mdi:account",

View File

@@ -1,32 +1,8 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted device trackers.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted device trackers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_home": {
"description": "Tests if one or more device trackers are home.",
"fields": {
"behavior": {
"description": "[%key:component::device_tracker::common::condition_behavior_description%]",
"name": "[%key:component::device_tracker::common::condition_behavior_name%]"
}
},
"name": "If a device tracker is home"
},
"is_not_home": {
"description": "Tests if one or more device trackers are not home.",
"fields": {
"behavior": {
"description": "[%key:component::device_tracker::common::condition_behavior_description%]",
"name": "[%key:component::device_tracker::common::condition_behavior_name%]"
}
},
"name": "If a device tracker is not home"
}
},
"device_automation": {
"condition_type": {
"is_home": "{entity_name} is home",
@@ -73,12 +49,6 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -14,7 +14,7 @@
"name": "[%key:component::fan::common::condition_behavior_name%]"
}
},
"name": "If a fan is off"
"name": "Fan is off"
},
"is_on": {
"description": "Tests if one or more fans are on.",
@@ -24,7 +24,7 @@
"name": "[%key:component::fan::common::condition_behavior_name%]"
}
},
"name": "If a fan is on"
"name": "Fan is on"
}
},
"device_automation": {

View File

@@ -12,9 +12,6 @@
},
"non_methane_hydrocarbons": {
"default": "mdi:molecule"
},
"ozone": {
"default": "mdi:molecule"
}
}
}

View File

@@ -154,8 +154,8 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
),
AirQualitySensorEntityDescription(
key="o3",
translation_key="ozone",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.OZONE,
native_unit_of_measurement_fn=lambda x: x.pollutants.o3.concentration.units,
exists_fn=lambda x: "o3" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.o3.concentration.value,

View File

@@ -211,9 +211,6 @@
"non_methane_hydrocarbons": {
"name": "Non-methane hydrocarbons"
},
"ozone": {
"name": "[%key:component::sensor::entity_component::ozone::name%]"
},
"uaqi": {
"name": "Universal Air Quality Index"
},

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["hdfury==1.3.1"]
"requirements": ["hdfury==1.4.2"]
}

View File

@@ -28,6 +28,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
from .const import DOMAIN, UPDATE_INTERVAL
from .entity import AqualinkEntity
@@ -66,7 +67,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
aqualink = AqualinkClient(username, password, httpx_client=get_async_client(hass))
aqualink = AqualinkClient(
username,
password,
httpx_client=get_async_client(hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2),
)
try:
await aqualink.login()
except AqualinkServiceException as login_exception:

View File

@@ -15,6 +15,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
from .const import DOMAIN
@@ -36,7 +37,11 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
try:
async with AqualinkClient(
username, password, httpx_client=get_async_client(self.hass)
username,
password,
httpx_client=get_async_client(
self.hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2
),
):
pass
except AqualinkServiceUnauthorizedException:

View File

@@ -49,7 +49,7 @@
"name": "[%key:component::light::common::condition_behavior_name%]"
}
},
"name": "If a light is off"
"name": "Light is off"
},
"is_on": {
"description": "Tests if one or more lights are on.",
@@ -59,7 +59,7 @@
"name": "[%key:component::light::common::condition_behavior_name%]"
}
},
"name": "If a light is on"
"name": "Light is on"
}
},
"device_automation": {

View File

@@ -2,6 +2,7 @@
from dataclasses import dataclass
from http import HTTPStatus
import logging
import aiohttp
from microBeesPy import MicroBees
@@ -15,6 +16,8 @@ from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN, PLATFORMS
from .coordinator import MicroBeesUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class HomeAssistantMicroBeesData:
@@ -25,6 +28,23 @@ class HomeAssistantMicroBeesData:
session: config_entry_oauth2_flow.OAuth2Session
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
if entry.version == 1:
# 1 -> 1.2: Unique ID from integer to string
if entry.minor_version == 1:
minor_version = 2
hass.config_entries.async_update_entry(
entry, unique_id=str(entry.unique_id), minor_version=minor_version
)
_LOGGER.debug("Migration successful")
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up microBees from a config entry."""
implementation = (

View File

@@ -19,6 +19,8 @@ class OAuth2FlowHandler(
"""Handle a config flow for microBees."""
DOMAIN = DOMAIN
VERSION = 1
MINOR_VERSION = 2
@property
def logger(self) -> logging.Logger:
@@ -47,7 +49,7 @@ class OAuth2FlowHandler(
self.logger.exception("Unexpected error")
return self.async_abort(reason="unknown")
await self.async_set_unique_id(current_user.id)
await self.async_set_unique_id(str(current_user.id))
if self.source != SOURCE_REAUTH:
self._abort_if_unique_id_configured()
return self.async_create_entry(

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@@ -15,9 +17,28 @@ from .api import AuthenticatedMonzoAPI
from .const import DOMAIN
from .coordinator import MonzoCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
if entry.version == 1:
# 1 -> 1.2: Unique ID from integer to string
if entry.minor_version == 1:
minor_version = 2
hass.config_entries.async_update_entry(
entry, unique_id=str(entry.unique_id), minor_version=minor_version
)
_LOGGER.debug("Migration successful")
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Monzo from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)

View File

@@ -21,6 +21,8 @@ class MonzoFlowHandler(
"""Handle a config flow."""
DOMAIN = DOMAIN
VERSION = 1
MINOR_VERSION = 2
oauth_data: dict[str, Any]
@@ -51,7 +53,7 @@ class MonzoFlowHandler(
"""Create an entry for the flow."""
self.oauth_data = data
user_id = data[CONF_TOKEN]["user_id"]
await self.async_set_unique_id(user_id)
await self.async_set_unique_id(str(user_id))
if self.source != SOURCE_REAUTH:
self._abort_if_unique_id_configured()
else:

View File

@@ -43,6 +43,7 @@ ATTR_ICON = "icon"
ATTR_MARKDOWN = "markdown"
ATTR_PRIORITY = "priority"
ATTR_TAGS = "tags"
ATTR_SEQUENCE_ID = "sequence_id"
SERVICE_PUBLISH_SCHEMA = cv.make_entity_service_schema(
{
@@ -60,6 +61,7 @@ SERVICE_PUBLISH_SCHEMA = cv.make_entity_service_schema(
vol.Optional(ATTR_EMAIL): vol.Email(),
vol.Optional(ATTR_CALL): cv.string,
vol.Optional(ATTR_ICON): vol.All(vol.Url(), vol.Coerce(URL)),
vol.Optional(ATTR_SEQUENCE_ID): cv.string,
}
)

View File

@@ -88,3 +88,8 @@ publish:
type: url
autocomplete: url
example: https://example.org/logo.png
sequence_id:
required: false
selector:
text:
example: "Mc3otamDNcpJ"

View File

@@ -1,6 +1,7 @@
{
"common": {
"add_topic_description": "Set up a topic for notifications.",
"sequence_id": "Sequence ID",
"topic": "Topic"
},
"config": {
@@ -171,6 +172,9 @@
"icon": { "name": "Icon" },
"message": { "name": "Message" },
"priority": { "name": "Priority" },
"sequence_id": {
"name": "[%key:component::ntfy::common::sequence_id%]"
},
"tags": { "name": "Tags" },
"time": { "name": "Time" },
"title": { "name": "Title" },
@@ -356,6 +360,10 @@
"description": "All messages have a priority that defines how urgently your phone notifies you, depending on the configured vibration patterns, notification sounds, and visibility in the notification drawer or pop-over.",
"name": "Message priority"
},
"sequence_id": {
"description": "Enter a message or sequence ID to update an existing notification, or specify a sequence ID to reference later when updating, clearing (mark as read and dismiss), or deleting a notification.",
"name": "[%key:component::ntfy::common::sequence_id%]"
},
"tags": {
"description": "Add tags or emojis to the notification. Emojis (using shortcodes like smile) will appear in the notification title or message. Other tags will be displayed below the notification content.",
"name": "Tags/Emojis"

View File

@@ -7,6 +7,9 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown_license_plate": "Unknown license plate"
},
"initiate_flow": {
"user": "Add vehicle"
},
"step": {
"user": {
"data": {

View File

@@ -4,6 +4,7 @@
"codeowners": ["@tomaszsluszniak"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sanix",
"integration_type": "device",
"iot_class": "cloud_polling",
"requirements": ["sanix==1.0.6"]
}

View File

@@ -49,8 +49,8 @@ DEFAULT_NAME = "Template Select"
SELECT_COMMON_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_OPTIONS): cv.template,
vol.Optional(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA,
vol.Required(ATTR_OPTIONS): cv.template,
vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_STATE): cv.template,
}
)

View File

@@ -83,6 +83,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
return False
if entry.version == 2:
# 2 -> 2.2: Unique ID from integer to string
if entry.minor_version == 1:
minor_version = 2
hass.config_entries.async_update_entry(
entry, unique_id=str(entry.unique_id), minor_version=minor_version
)
return True

View File

@@ -20,6 +20,7 @@ class ToonFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
DOMAIN = DOMAIN
VERSION = 2
MINOR_VERSION = 2
agreements: list[Agreement]
data: dict[str, Any]
@@ -92,7 +93,7 @@ class ToonFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
if self.migrate_entry:
await self.hass.config_entries.async_remove(self.migrate_entry)
await self.async_set_unique_id(agreement.agreement_id)
await self.async_set_unique_id(str(agreement.agreement_id))
self._abort_if_unique_id_configured()
self.data[CONF_AGREEMENT_ID] = agreement.agreement_id

View File

@@ -8,6 +8,7 @@ import dataclasses
from uiprotect.data import (
NVR,
Camera,
Event,
ModelType,
MountType,
ProtectAdoptableDeviceModel,
@@ -644,6 +645,31 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity):
self._attr_is_on = False
self._attr_extra_state_attributes = {}
@callback
def _find_active_event_with_object_type(
self, device: ProtectDeviceType
) -> Event | None:
"""Find an active event containing this sensor's object type.
Fallback for issue #152133: last_smart_detect_event_ids may not update
immediately when a new detection type is added to an ongoing event.
"""
obj_type = self.entity_description.ufp_obj_type
if obj_type is None or not isinstance(device, Camera):
return None
# Check known active event IDs from camera first (fast path)
for event_id in device.last_smart_detect_event_ids.values():
if (
event_id
and (event := self.data.api.bootstrap.events.get(event_id))
and event.end is None
and obj_type in event.smart_detect_types
):
return event
return None
@callback
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
description = self.entity_description
@@ -651,9 +677,15 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity):
prev_event = self._event
prev_event_end = self._event_end
super()._async_update_device_from_protect(device)
if event := description.get_event_obj(device):
event = description.get_event_obj(device)
if event is None:
# Fallback for #152133: check active events directly
event = self._find_active_event_with_object_type(device)
if event:
self._event = event
self._event_end = event.end if event else None
self._event_end = event.end
if not (
event

View File

@@ -41,7 +41,7 @@
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"quality_scale": "platinum",
"requirements": ["uiprotect==10.0.0", "unifi-discovery==1.2.0"],
"requirements": ["uiprotect==10.0.1", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

View File

@@ -5824,7 +5824,7 @@
},
"sanix": {
"name": "Sanix",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "cloud_polling"
},

View File

@@ -370,9 +370,13 @@ def _async_get_connector(
return connectors[connector_key]
if verify_ssl:
ssl_context: SSLContext = ssl_util.client_context(ssl_cipher)
ssl_context: SSLContext = ssl_util.client_context(
ssl_cipher, ssl_util.SSL_ALPN_HTTP11
)
else:
ssl_context = ssl_util.client_context_no_verify(ssl_cipher)
ssl_context = ssl_util.client_context_no_verify(
ssl_cipher, ssl_util.SSL_ALPN_HTTP11
)
connector = HomeAssistantTCPConnector(
family=family,

View File

@@ -17,6 +17,9 @@ from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.loader import bind_hass
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.ssl import (
SSL_ALPN_HTTP11,
SSL_ALPN_HTTP11_HTTP2,
SSLALPNProtocols,
SSLCipherList,
client_context,
create_no_verify_ssl_context,
@@ -28,9 +31,9 @@ from .frame import warn_use
# and we want to keep the connection open for a while so we
# don't have to reconnect every time so we use 15s to match aiohttp.
KEEP_ALIVE_TIMEOUT = 15
DATA_ASYNC_CLIENT: HassKey[httpx.AsyncClient] = HassKey("httpx_async_client")
DATA_ASYNC_CLIENT_NOVERIFY: HassKey[httpx.AsyncClient] = HassKey(
"httpx_async_client_noverify"
# Shared httpx clients keyed by (verify_ssl, alpn_protocols)
DATA_ASYNC_CLIENT: HassKey[dict[tuple[bool, SSLALPNProtocols], httpx.AsyncClient]] = (
HassKey("httpx_async_client")
)
DEFAULT_LIMITS = limits = httpx.Limits(keepalive_expiry=KEEP_ALIVE_TIMEOUT)
SERVER_SOFTWARE = (
@@ -42,15 +45,26 @@ USER_AGENT = "User-Agent"
@callback
@bind_hass
def get_async_client(hass: HomeAssistant, verify_ssl: bool = True) -> httpx.AsyncClient:
def get_async_client(
hass: HomeAssistant,
verify_ssl: bool = True,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_HTTP11,
) -> httpx.AsyncClient:
"""Return default httpx AsyncClient.
This method must be run in the event loop.
"""
key = DATA_ASYNC_CLIENT if verify_ssl else DATA_ASYNC_CLIENT_NOVERIFY
if (client := hass.data.get(key)) is None:
client = hass.data[key] = create_async_httpx_client(hass, verify_ssl)
Pass alpn_protocols=SSL_ALPN_HTTP11_HTTP2 to get a client configured for HTTP/2.
Clients are cached separately by ALPN protocol to ensure proper SSL context
configuration (ALPN protocols differ between HTTP versions).
"""
client_key = (verify_ssl, alpn_protocols)
clients = hass.data.setdefault(DATA_ASYNC_CLIENT, {})
if (client := clients.get(client_key)) is None:
client = clients[client_key] = create_async_httpx_client(
hass, verify_ssl, alpn_protocols=alpn_protocols
)
return client
@@ -77,6 +91,7 @@ def create_async_httpx_client(
verify_ssl: bool = True,
auto_cleanup: bool = True,
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_HTTP11,
**kwargs: Any,
) -> httpx.AsyncClient:
"""Create a new httpx.AsyncClient with kwargs, i.e. for cookies.
@@ -84,13 +99,22 @@ def create_async_httpx_client(
If auto_cleanup is False, the client will be
automatically closed on homeassistant_stop.
Pass alpn_protocols=SSL_ALPN_HTTP11_HTTP2 for HTTP/2 support (automatically
enables httpx http2 mode).
This method must be run in the event loop.
"""
# Use the requested ALPN protocols directly to ensure proper SSL context
# bucketing. httpx/httpcore mutates SSL contexts by calling set_alpn_protocols(),
# so we pre-set the correct protocols to prevent shared context corruption.
ssl_context = (
client_context(ssl_cipher_list)
client_context(ssl_cipher_list, alpn_protocols)
if verify_ssl
else create_no_verify_ssl_context(ssl_cipher_list)
else create_no_verify_ssl_context(ssl_cipher_list, alpn_protocols)
)
# Enable httpx HTTP/2 mode when HTTP/2 protocol is requested
if alpn_protocols == SSL_ALPN_HTTP11_HTTP2:
kwargs.setdefault("http2", True)
client = HassHttpXAsyncClient(
verify=ssl_context,
headers={USER_AGENT: SERVER_SOFTWARE},

View File

@@ -8,6 +8,17 @@ import ssl
import certifi
# Type alias for ALPN protocols tuple (None means no ALPN protocols set)
type SSLALPNProtocols = tuple[str, ...] | None
# ALPN protocol configurations
# No ALPN protocols - used for libraries that don't support/need ALPN (e.g., aioimap)
SSL_ALPN_NONE: SSLALPNProtocols = None
# HTTP/1.1 only - used by default and for aiohttp (which doesn't support HTTP/2)
SSL_ALPN_HTTP11: SSLALPNProtocols = ("http/1.1",)
# HTTP/1.1 with HTTP/2 support - used when httpx http2=True
SSL_ALPN_HTTP11_HTTP2: SSLALPNProtocols = ("http/1.1", "h2")
class SSLCipherList(StrEnum):
"""SSL cipher lists."""
@@ -64,7 +75,10 @@ SSL_CIPHER_LISTS = {
@cache
def _client_context_no_verify(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext:
def _client_context_no_verify(
ssl_cipher_list: SSLCipherList,
alpn_protocols: SSLALPNProtocols,
) -> ssl.SSLContext:
# This is a copy of aiohttp's create_default_context() function, with the
# ssl verify turned off.
# https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911
@@ -78,12 +92,18 @@ def _client_context_no_verify(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext:
sslcontext.set_default_verify_paths()
if ssl_cipher_list != SSLCipherList.PYTHON_DEFAULT:
sslcontext.set_ciphers(SSL_CIPHER_LISTS[ssl_cipher_list])
# Set ALPN protocols to prevent downstream libraries (e.g., httpx/httpcore)
# from mutating the shared SSL context with different protocol settings.
# If alpn_protocols is None, don't set ALPN (for libraries like aioimap).
if alpn_protocols is not None:
sslcontext.set_alpn_protocols(list(alpn_protocols))
return sslcontext
def _create_client_context(
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_NONE,
) -> ssl.SSLContext:
"""Return an independent SSL context for making requests."""
# Reuse environment variable definition from requests, since it's already a
@@ -96,6 +116,11 @@ def _create_client_context(
)
if ssl_cipher_list != SSLCipherList.PYTHON_DEFAULT:
sslcontext.set_ciphers(SSL_CIPHER_LISTS[ssl_cipher_list])
# Set ALPN protocols to prevent downstream libraries (e.g., httpx/httpcore)
# from mutating the shared SSL context with different protocol settings.
# If alpn_protocols is None, don't set ALPN (for libraries like aioimap).
if alpn_protocols is not None:
sslcontext.set_alpn_protocols(list(alpn_protocols))
return sslcontext
@@ -103,63 +128,63 @@ def _create_client_context(
@cache
def _client_context(
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_NONE,
) -> ssl.SSLContext:
# Cached version of _create_client_context
return _create_client_context(ssl_cipher_list)
return _create_client_context(ssl_cipher_list, alpn_protocols)
# Create this only once and reuse it
_DEFAULT_SSL_CONTEXT = _client_context(SSLCipherList.PYTHON_DEFAULT)
_DEFAULT_NO_VERIFY_SSL_CONTEXT = _client_context_no_verify(SSLCipherList.PYTHON_DEFAULT)
_NO_VERIFY_SSL_CONTEXTS = {
SSLCipherList.INTERMEDIATE: _client_context_no_verify(SSLCipherList.INTERMEDIATE),
SSLCipherList.MODERN: _client_context_no_verify(SSLCipherList.MODERN),
SSLCipherList.INSECURE: _client_context_no_verify(SSLCipherList.INSECURE),
}
_SSL_CONTEXTS = {
SSLCipherList.INTERMEDIATE: _client_context(SSLCipherList.INTERMEDIATE),
SSLCipherList.MODERN: _client_context(SSLCipherList.MODERN),
SSLCipherList.INSECURE: _client_context(SSLCipherList.INSECURE),
}
# Pre-warm the cache for ALL SSL context configurations at module load time.
# This is critical because creating SSL contexts loads certificates from disk,
# which is blocking I/O that must not happen in the event loop.
_SSL_ALPN_PROTOCOLS = (SSL_ALPN_NONE, SSL_ALPN_HTTP11, SSL_ALPN_HTTP11_HTTP2)
for _cipher in SSLCipherList:
for _alpn in _SSL_ALPN_PROTOCOLS:
_client_context(_cipher, _alpn)
_client_context_no_verify(_cipher, _alpn)
def get_default_context() -> ssl.SSLContext:
"""Return the default SSL context."""
return _DEFAULT_SSL_CONTEXT
return _client_context(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11)
def get_default_no_verify_context() -> ssl.SSLContext:
"""Return the default SSL context that does not verify the server certificate."""
return _DEFAULT_NO_VERIFY_SSL_CONTEXT
return _client_context_no_verify(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11)
def client_context_no_verify(
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_NONE,
) -> ssl.SSLContext:
"""Return a SSL context with no verification with a specific ssl cipher."""
return _NO_VERIFY_SSL_CONTEXTS.get(ssl_cipher_list, _DEFAULT_NO_VERIFY_SSL_CONTEXT)
return _client_context_no_verify(ssl_cipher_list, alpn_protocols)
def client_context(
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_NONE,
) -> ssl.SSLContext:
"""Return an SSL context for making requests."""
return _SSL_CONTEXTS.get(ssl_cipher_list, _DEFAULT_SSL_CONTEXT)
return _client_context(ssl_cipher_list, alpn_protocols)
def create_client_context(
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_NONE,
) -> ssl.SSLContext:
"""Return an independent SSL context for making requests."""
# This explicitly uses the non-cached version to create a client context
return _create_client_context(ssl_cipher_list)
return _create_client_context(ssl_cipher_list, alpn_protocols)
def create_no_verify_ssl_context(
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
alpn_protocols: SSLALPNProtocols = SSL_ALPN_NONE,
) -> ssl.SSLContext:
"""Return an SSL context that does not verify the server certificate."""
return _client_context_no_verify(ssl_cipher_list)
return _client_context_no_verify(ssl_cipher_list, alpn_protocols)
def server_context_modern() -> ssl.SSLContext:

View File

@@ -188,6 +188,52 @@ class BaseUnitConverter:
return (from_unit in cls._UNIT_INVERSES) != (to_unit in cls._UNIT_INVERSES)
class ApparentPowerConverter(BaseUnitConverter):
"""Utility to convert apparent power values."""
UNIT_CLASS = "apparent_power"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfApparentPower.MILLIVOLT_AMPERE: 1 * 1000,
UnitOfApparentPower.VOLT_AMPERE: 1,
UnitOfApparentPower.KILO_VOLT_AMPERE: 1 / 1000,
}
VALID_UNITS = {
UnitOfApparentPower.MILLIVOLT_AMPERE,
UnitOfApparentPower.VOLT_AMPERE,
UnitOfApparentPower.KILO_VOLT_AMPERE,
}
class AreaConverter(BaseUnitConverter):
"""Utility to convert area values."""
UNIT_CLASS = "area"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfArea.SQUARE_METERS: 1,
UnitOfArea.SQUARE_CENTIMETERS: 1 / _CM2_TO_M2,
UnitOfArea.SQUARE_MILLIMETERS: 1 / _MM2_TO_M2,
UnitOfArea.SQUARE_KILOMETERS: 1 / _KM2_TO_M2,
UnitOfArea.SQUARE_INCHES: 1 / _IN2_TO_M2,
UnitOfArea.SQUARE_FEET: 1 / _FT2_TO_M2,
UnitOfArea.SQUARE_YARDS: 1 / _YD2_TO_M2,
UnitOfArea.SQUARE_MILES: 1 / _MI2_TO_M2,
UnitOfArea.ACRES: 1 / _ACRE_TO_M2,
UnitOfArea.HECTARES: 1 / _HECTARE_TO_M2,
}
VALID_UNITS = set(UnitOfArea)
class BloodGlucoseConcentrationConverter(BaseUnitConverter):
"""Utility to convert blood glucose concentration values."""
UNIT_CLASS = "blood_glucose_concentration"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER: 18,
UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER: 1,
}
VALID_UNITS = set(UnitOfBloodGlucoseConcentration)
class CarbonMonoxideConcentrationConverter(BaseUnitConverter):
"""Convert carbon monoxide ratio to mass per volume.
@@ -213,36 +259,16 @@ class CarbonMonoxideConcentrationConverter(BaseUnitConverter):
}
class NitrogenDioxideConcentrationConverter(BaseUnitConverter):
"""Convert nitrogen dioxide ratio to mass per volume."""
class ConductivityConverter(BaseUnitConverter):
"""Utility to convert electric current values."""
UNIT_CLASS = "nitrogen_dioxide"
UNIT_CLASS = "conductivity"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
_NITROGEN_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
),
}
VALID_UNITS = {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
}
class SulphurDioxideConcentrationConverter(BaseUnitConverter):
"""Convert sulphur dioxide ratio to mass per volume."""
UNIT_CLASS = "sulphur_dioxide"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
_SULPHUR_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
),
}
VALID_UNITS = {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
UnitOfConductivity.MICROSIEMENS_PER_CM: 1,
UnitOfConductivity.MILLISIEMENS_PER_CM: 1e-3,
UnitOfConductivity.SIEMENS_PER_CM: 1e-6,
}
VALID_UNITS = set(UnitOfConductivity)
class DataRateConverter(BaseUnitConverter):
@@ -266,25 +292,6 @@ class DataRateConverter(BaseUnitConverter):
VALID_UNITS = set(UnitOfDataRate)
class AreaConverter(BaseUnitConverter):
"""Utility to convert area values."""
UNIT_CLASS = "area"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfArea.SQUARE_METERS: 1,
UnitOfArea.SQUARE_CENTIMETERS: 1 / _CM2_TO_M2,
UnitOfArea.SQUARE_MILLIMETERS: 1 / _MM2_TO_M2,
UnitOfArea.SQUARE_KILOMETERS: 1 / _KM2_TO_M2,
UnitOfArea.SQUARE_INCHES: 1 / _IN2_TO_M2,
UnitOfArea.SQUARE_FEET: 1 / _FT2_TO_M2,
UnitOfArea.SQUARE_YARDS: 1 / _YD2_TO_M2,
UnitOfArea.SQUARE_MILES: 1 / _MI2_TO_M2,
UnitOfArea.ACRES: 1 / _ACRE_TO_M2,
UnitOfArea.HECTARES: 1 / _HECTARE_TO_M2,
}
VALID_UNITS = set(UnitOfArea)
class DistanceConverter(BaseUnitConverter):
"""Utility to convert distance values."""
@@ -313,27 +320,28 @@ class DistanceConverter(BaseUnitConverter):
}
class BloodGlucoseConcentrationConverter(BaseUnitConverter):
"""Utility to convert blood glucose concentration values."""
class DurationConverter(BaseUnitConverter):
"""Utility to convert duration values."""
UNIT_CLASS = "blood_glucose_concentration"
UNIT_CLASS = "duration"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER: 18,
UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER: 1,
UnitOfTime.MICROSECONDS: 1000000,
UnitOfTime.MILLISECONDS: 1000,
UnitOfTime.SECONDS: 1,
UnitOfTime.MINUTES: 1 / _MIN_TO_SEC,
UnitOfTime.HOURS: 1 / _HRS_TO_SECS,
UnitOfTime.DAYS: 1 / _DAYS_TO_SECS,
UnitOfTime.WEEKS: 1 / (7 * _DAYS_TO_SECS),
}
VALID_UNITS = set(UnitOfBloodGlucoseConcentration)
class ConductivityConverter(BaseUnitConverter):
"""Utility to convert electric current values."""
UNIT_CLASS = "conductivity"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfConductivity.MICROSIEMENS_PER_CM: 1,
UnitOfConductivity.MILLISIEMENS_PER_CM: 1e-3,
UnitOfConductivity.SIEMENS_PER_CM: 1e-6,
VALID_UNITS = {
UnitOfTime.MICROSECONDS,
UnitOfTime.MILLISECONDS,
UnitOfTime.SECONDS,
UnitOfTime.MINUTES,
UnitOfTime.HOURS,
UnitOfTime.DAYS,
UnitOfTime.WEEKS,
}
VALID_UNITS = set(UnitOfConductivity)
class ElectricCurrentConverter(BaseUnitConverter):
@@ -462,19 +470,51 @@ class MassConverter(BaseUnitConverter):
}
class ApparentPowerConverter(BaseUnitConverter):
"""Utility to convert apparent power values."""
class MassVolumeConcentrationConverter(BaseUnitConverter):
"""Utility to convert mass volume concentration values."""
UNIT_CLASS = "apparent_power"
UNIT_CLASS = "concentration"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfApparentPower.MILLIVOLT_AMPERE: 1 * 1000,
UnitOfApparentPower.VOLT_AMPERE: 1,
UnitOfApparentPower.KILO_VOLT_AMPERE: 1 / 1000,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1000000.0, # 1000 µg/m³ = 1 mg/m³
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1000.0, # 1000 mg/m³ = 1 g/m³
CONCENTRATION_GRAMS_PER_CUBIC_METER: 1.0,
}
VALID_UNITS = {
UnitOfApparentPower.MILLIVOLT_AMPERE,
UnitOfApparentPower.VOLT_AMPERE,
UnitOfApparentPower.KILO_VOLT_AMPERE,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_GRAMS_PER_CUBIC_METER,
}
class NitrogenDioxideConcentrationConverter(BaseUnitConverter):
"""Convert nitrogen dioxide ratio to mass per volume."""
UNIT_CLASS = "nitrogen_dioxide"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
_NITROGEN_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
),
}
VALID_UNITS = {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
}
class OzoneConcentrationConverter(BaseUnitConverter):
"""Convert ozone ratio to mass per volume."""
UNIT_CLASS = "ozone"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
_OZONE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
),
}
VALID_UNITS = {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
}
@@ -563,22 +603,6 @@ class ReactivePowerConverter(BaseUnitConverter):
}
class OzoneConcentrationConverter(BaseUnitConverter):
"""Convert ozone ratio to mass per volume."""
UNIT_CLASS = "ozone"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
_OZONE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
),
}
VALID_UNITS = {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
}
class SpeedConverter(BaseUnitConverter):
"""Utility to convert speed values."""
@@ -679,6 +703,22 @@ class SpeedConverter(BaseUnitConverter):
return float(0.836 * beaufort ** (3 / 2))
class SulphurDioxideConcentrationConverter(BaseUnitConverter):
"""Convert sulphur dioxide ratio to mass per volume."""
UNIT_CLASS = "sulphur_dioxide"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_PARTS_PER_BILLION: 1e9,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
_SULPHUR_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
),
}
VALID_UNITS = {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
}
class TemperatureConverter(BaseUnitConverter):
"""Utility to convert temperature values."""
@@ -849,22 +889,6 @@ class UnitlessRatioConverter(BaseUnitConverter):
}
class MassVolumeConcentrationConverter(BaseUnitConverter):
"""Utility to convert mass volume concentration values."""
UNIT_CLASS = "concentration"
_UNIT_CONVERSION: dict[str | None, float] = {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1000000.0, # 1000 µg/m³ = 1 mg/m³
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1000.0, # 1000 mg/m³ = 1 g/m³
CONCENTRATION_GRAMS_PER_CUBIC_METER: 1.0,
}
VALID_UNITS = {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_GRAMS_PER_CUBIC_METER,
}
class VolumeConverter(BaseUnitConverter):
"""Utility to convert volume values."""
@@ -927,27 +951,3 @@ class VolumeFlowRateConverter(BaseUnitConverter):
UnitOfVolumeFlowRate.GALLONS_PER_DAY,
UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND,
}
class DurationConverter(BaseUnitConverter):
"""Utility to convert duration values."""
UNIT_CLASS = "duration"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfTime.MICROSECONDS: 1000000,
UnitOfTime.MILLISECONDS: 1000,
UnitOfTime.SECONDS: 1,
UnitOfTime.MINUTES: 1 / _MIN_TO_SEC,
UnitOfTime.HOURS: 1 / _HRS_TO_SECS,
UnitOfTime.DAYS: 1 / _DAYS_TO_SECS,
UnitOfTime.WEEKS: 1 / (7 * _DAYS_TO_SECS),
}
VALID_UNITS = {
UnitOfTime.MICROSECONDS,
UnitOfTime.MILLISECONDS,
UnitOfTime.SECONDS,
UnitOfTime.MINUTES,
UnitOfTime.HOURS,
UnitOfTime.DAYS,
UnitOfTime.WEEKS,
}

View File

@@ -676,7 +676,7 @@ exclude_lines = [
]
[tool.ruff]
required-version = ">=0.13.0"
required-version = ">=0.14.13"
[tool.ruff.lint]
select = [

6
requirements_all.txt generated
View File

@@ -1184,7 +1184,7 @@ hassil==3.5.0
hdate[astral]==1.1.2
# homeassistant.components.hdfury
hdfury==1.3.1
hdfury==1.4.2
# homeassistant.components.heatmiser
heatmiserV3==2.0.4
@@ -1909,7 +1909,7 @@ pyatag==0.3.5.3
pyatmo==9.2.3
# homeassistant.components.apple_tv
pyatv==0.16.1;python_version<'3.14'
pyatv==0.17.0
# homeassistant.components.aussie_broadband
pyaussiebb==0.1.5
@@ -3080,7 +3080,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
uiprotect==10.0.0
uiprotect==10.0.1
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7

View File

@@ -1051,7 +1051,7 @@ hassil==3.5.0
hdate[astral]==1.1.2
# homeassistant.components.hdfury
hdfury==1.3.1
hdfury==1.4.2
# homeassistant.components.here_travel_time
here-routing==1.2.0
@@ -1637,7 +1637,7 @@ pyatag==0.3.5.3
pyatmo==9.2.3
# homeassistant.components.apple_tv
pyatv==0.16.1;python_version<'3.14'
pyatv==0.17.0
# homeassistant.components.aussie_broadband
pyaussiebb==0.1.5
@@ -2577,7 +2577,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
uiprotect==10.0.0
uiprotect==10.0.1
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7

View File

@@ -1,5 +1,5 @@
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
codespell==2.4.1
ruff==0.13.0
ruff==0.14.13
yamllint==1.37.1

View File

@@ -26,7 +26,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.9.17,source=/uv,target=/bin/uv \
-r /usr/src/homeassistant/requirements.txt \
pipdeptree==2.26.1 \
tqdm==4.67.1 \
ruff==0.13.0
ruff==0.14.13
LABEL "name"="hassfest"
LABEL "maintainer"="Home Assistant <hello@home-assistant.io>"

View File

@@ -117,7 +117,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
"airthings": {"airthings-cloud": {"async-timeout"}},
"ampio": {"asmog": {"async-timeout"}},
"apache_kafka": {"aiokafka": {"async-timeout"}},
"apple_tv": {"pyatv": {"async-timeout"}},
"blackbird": {
# https://github.com/koolsb/pyblackbird/issues/12
# pyblackbird > pyserial-asyncio

View File

@@ -1,9 +1,6 @@
"""Tests for Apple TV."""
import sys
import pytest
if sys.version_info < (3, 14):
# Make asserts in the common module display differences
pytest.register_assert_rewrite("tests.components.apple_tv.common")
# Make asserts in the common module display differences
pytest.register_assert_rewrite("tests.components.apple_tv.common")

View File

@@ -1,20 +1,14 @@
"""Fixtures for component."""
from collections.abc import Generator
import sys
from unittest.mock import AsyncMock, MagicMock, patch
from pyatv import conf
from pyatv.const import PairingRequirement, Protocol
from pyatv.support import http
import pytest
if sys.version_info < (3, 14):
from pyatv import conf
from pyatv.const import PairingRequirement, Protocol
from pyatv.support import http
from .common import MockPairingHandler, airplay_service, create_conf, mrp_service
if sys.version_info >= (3, 14):
collect_ignore_glob = ["test_*.py"]
from .common import MockPairingHandler, airplay_service, create_conf, mrp_service
@pytest.fixture(autouse=True, name="mock_scan")

View File

@@ -27,7 +27,10 @@ def mock_setup_entry() -> Generator[AsyncMock]:
def mock_config_entry(hass: HomeAssistant, mock_arve: MagicMock) -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="Arve", domain=DOMAIN, data=USER_INPUT, unique_id=mock_arve.customer_id
title="Arve",
domain=DOMAIN,
data=USER_INPUT,
unique_id=str(mock_arve.customer_id),
)

View File

@@ -34,7 +34,7 @@ async def test_correct_flow(
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["data"] == USER_INPUT
assert len(mock_setup_entry.mock_calls) == 1
assert result2["result"].unique_id == 12345
assert result2["result"].unique_id == "12345"
async def test_form_cannot_connect(

View File

@@ -0,0 +1,26 @@
"""Tests for the Arve component."""
from unittest.mock import patch
from homeassistant.components.arve.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_SECRET
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None:
"""Test migrating a 1.1 config entry to 1.2."""
with patch("homeassistant.components.arve.async_setup_entry", return_value=True):
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_ACCESS_TOKEN: "mock", CONF_CLIENT_SECRET: "mock"},
version=1,
minor_version=1,
unique_id=12345,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
assert entry.version == 1
assert entry.minor_version == 2
assert entry.unique_id == "12345"

View File

@@ -1,156 +0,0 @@
"""Test device tracker conditions."""
from typing import Any
import pytest
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant
from tests.components import (
ConditionStateDescription,
assert_condition_gated_by_labs_flag,
create_target_condition,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@pytest.fixture
async def target_device_trackers(hass: HomeAssistant) -> list[str]:
"""Create multiple device tracker entities associated with different targets."""
return (await target_entities(hass, "device_tracker"))["included"]
@pytest.mark.parametrize(
"condition",
[
"device_tracker.is_home",
"device_tracker.is_not_home",
],
)
async def test_device_tracker_conditions_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
) -> None:
"""Test the device tracker conditions are gated by the labs flag."""
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("device_tracker"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_condition_states_any(
condition="device_tracker.is_home",
target_states=[STATE_HOME],
other_states=[STATE_NOT_HOME],
),
*parametrize_condition_states_any(
condition="device_tracker.is_not_home",
target_states=[STATE_NOT_HOME],
other_states=[STATE_HOME],
),
],
)
async def test_device_tracker_state_condition_behavior_any(
hass: HomeAssistant,
target_device_trackers: list[str],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the device tracker state condition with the 'any' behavior."""
other_entity_ids = set(target_device_trackers) - {entity_id}
# Set all device trackers, including the tested one, to the initial state
for eid in target_device_trackers:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
condition=condition,
target=condition_target_config,
behavior="any",
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other device trackers also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("device_tracker"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_condition_states_all(
condition="device_tracker.is_home",
target_states=[STATE_HOME],
other_states=[STATE_NOT_HOME],
),
*parametrize_condition_states_all(
condition="device_tracker.is_not_home",
target_states=[STATE_NOT_HOME],
other_states=[STATE_HOME],
),
],
)
async def test_device_tracker_state_condition_behavior_all(
hass: HomeAssistant,
target_device_trackers: list[str],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the device tracker state condition with the 'all' behavior."""
other_entity_ids = set(target_device_trackers) - {entity_id}
# Set all device trackers, including the tested one, to the initial state
for eid in target_device_trackers:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
condition=condition,
target=condition_target_config,
behavior="all",
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -1 +1,13 @@
"""Tests for the GitHub integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Method for setting up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -1,53 +0,0 @@
"""Common helpers for GitHub integration tests."""
from __future__ import annotations
import json
from homeassistant.components.github.const import CONF_REPOSITORIES, DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, async_load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
MOCK_ACCESS_TOKEN = "gho_16C7e42F292c6912E7710c838347Ae178B4a"
TEST_REPOSITORY = "octocat/Hello-World"
async def setup_github_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
add_entry_to_hass: bool = True,
) -> None:
"""Mock setting up the integration."""
headers = json.loads(await async_load_fixture(hass, "base_headers.json", DOMAIN))
for idx, repository in enumerate(mock_config_entry.options[CONF_REPOSITORIES]):
aioclient_mock.get(
f"https://api.github.com/repos/{repository}",
json={
**json.loads(await async_load_fixture(hass, "repository.json", DOMAIN)),
"full_name": repository,
"id": idx,
},
headers=headers,
)
aioclient_mock.get(
f"https://api.github.com/repos/{repository}/events",
json=[],
headers=headers,
)
aioclient_mock.post(
"https://api.github.com/graphql",
json=json.loads(await async_load_fixture(hass, "graphql.json", DOMAIN)),
headers=headers,
)
if add_entry_to_hass:
mock_config_entry.add_to_hass(hass)
setup_result = await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert setup_result
assert mock_config_entry.state is ConfigEntryState.LOADED

View File

@@ -1,18 +1,27 @@
"""conftest for the GitHub integration."""
import asyncio
from collections.abc import Generator
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock, patch
from aiogithubapi import (
GitHubLoginDeviceModel,
GitHubLoginOauthModel,
GitHubRateLimitModel,
)
import pytest
from homeassistant.components.github.const import CONF_REPOSITORIES, DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from .common import MOCK_ACCESS_TOKEN, TEST_REPOSITORY, setup_github_integration
from .const import MOCK_ACCESS_TOKEN, TEST_REPOSITORY
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.common import (
MockConfigEntry,
async_load_json_object_fixture,
load_json_object_fixture,
)
@pytest.fixture
@@ -34,11 +43,93 @@ def mock_setup_entry() -> Generator[None]:
@pytest.fixture
async def init_integration(
def device_activation_event() -> asyncio.Event:
"""Fixture to provide an asyncio event for device activation."""
return asyncio.Event()
@pytest.fixture
def github_device_client(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
) -> MockConfigEntry:
"""Set up the GitHub integration for testing."""
await setup_github_integration(hass, mock_config_entry, aioclient_mock)
return mock_config_entry
device_activation_event: asyncio.Event,
) -> Generator[AsyncMock]:
"""Mock GitHub device client."""
with patch(
"homeassistant.components.github.config_flow.GitHubDeviceAPI",
autospec=True,
) as github_client_mock:
client = github_client_mock.return_value
register_object = AsyncMock()
register_object.data = GitHubLoginDeviceModel(
load_json_object_fixture("device_register.json", DOMAIN)
)
client.register.return_value = register_object
async def mock_api_device_activation(device_code) -> AsyncMock:
# Simulate the device activation process
await device_activation_event.wait()
activate_object = AsyncMock()
activate_object.data = GitHubLoginOauthModel(
await async_load_json_object_fixture(
hass, "device_activate.json", DOMAIN
)
)
return activate_object
client.activation = mock_api_device_activation
yield client
@pytest.fixture
def github_client(hass: HomeAssistant) -> Generator[AsyncMock]:
"""Mock GitHub device client."""
with (
patch(
"homeassistant.components.github.config_flow.GitHubAPI",
autospec=True,
) as github_client_mock,
patch("homeassistant.components.github.GitHubAPI", new=github_client_mock),
patch(
"homeassistant.components.github.diagnostics.GitHubAPI",
new=github_client_mock,
),
):
client = github_client_mock.return_value
client.user.starred = AsyncMock(
side_effect=[
MagicMock(
is_last_page=False,
next_page_number=2,
last_page_number=2,
data=[MagicMock(full_name="home-assistant/core")],
),
MagicMock(
is_last_page=True,
data=[MagicMock(full_name="home-assistant/frontend")],
),
]
)
client.user.repos = AsyncMock(
side_effect=[
MagicMock(
is_last_page=False,
next_page_number=2,
last_page_number=2,
data=[MagicMock(full_name="home-assistant/operating-system")],
),
MagicMock(
is_last_page=True,
data=[MagicMock(full_name="esphome/esphome")],
),
]
)
rate_limit_mock = AsyncMock()
rate_limit_mock.data = GitHubRateLimitModel(
load_json_object_fixture("rate_limit.json", DOMAIN)
)
client.rate_limit.return_value = rate_limit_mock
graphql_mock = AsyncMock()
graphql_mock.data = load_json_object_fixture("graphql.json", DOMAIN)
client.graphql.return_value = graphql_mock
client.repos.events.subscribe = AsyncMock()
yield client

View File

@@ -0,0 +1,4 @@
"""Constants for GitHub integration tests."""
MOCK_ACCESS_TOKEN = "gho_16C7e42F292c6912E7710c838347Ae178B4a"
TEST_REPOSITORY = "octocat/Hello-World"

View File

@@ -1,29 +0,0 @@
{
"Server": "GitHub.com",
"Date": "Mon, 1 Jan 1970 00:00:00 GMT",
"Content-Type": "application/json; charset=utf-8",
"Transfer-Encoding": "chunked",
"Cache-Control": "private, max-age=60, s-maxage=60",
"Vary": "Accept, Authorization, Cookie, X-GitHub-OTP",
"Etag": "W/\"1234567890abcdefghijklmnopqrstuvwxyz\"",
"X-OAuth-Scopes": "",
"X-Accepted-OAuth-Scopes": "",
"github-authentication-token-expiration": "1970-01-01 01:00:00 UTC",
"X-GitHub-Media-Type": "github.v3; param=raw; format=json",
"X-RateLimit-Limit": "5000",
"X-RateLimit-Remaining": "4999",
"X-RateLimit-Reset": "1",
"X-RateLimit-Used": "1",
"X-RateLimit-Resource": "core",
"Access-Control-Expose-Headers": "ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, Deprecation, Sunset",
"Access-Control-Allow-Origin": "*",
"Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload",
"X-Frame-Options": "deny",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "0",
"Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin",
"Content-Security-Policy": "default-src 'none'",
"Content-Encoding": "gzip",
"Permissions-Policy": "",
"X-GitHub-Request-Id": "12A3:45BC:6D7890:12EF34:5678G901"
}

View File

@@ -0,0 +1,5 @@
{
"access_token": "gho_16C7e42F292c6912E7710c838347Ae178B4a",
"token_type": "bearer",
"scope": ""
}

View File

@@ -0,0 +1,7 @@
{
"device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5",
"user_code": "WDJB-MJHT",
"verification_uri": "https://github.com/login/device",
"expires_in": 900,
"interval": 5
}

View File

@@ -0,0 +1 @@
{ "resources": { "core": { "remaining": 100, "limit": 100 } } }

View File

@@ -1,146 +1,100 @@
"""Test the GitHub config flow."""
from unittest.mock import AsyncMock, MagicMock, patch
import asyncio
from unittest.mock import AsyncMock, MagicMock
from aiogithubapi import GitHubException
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant import config_entries
from homeassistant.components.github.config_flow import get_repositories
from homeassistant.components.github.const import (
CONF_REPOSITORIES,
DEFAULT_REPOSITORIES,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType, UnknownFlow
from .common import MOCK_ACCESS_TOKEN
from .const import MOCK_ACCESS_TOKEN
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_full_user_flow_implementation(
hass: HomeAssistant,
mock_setup_entry: None,
aioclient_mock: AiohttpClientMocker,
freezer: FrozenDateTimeFactory,
github_device_client: AsyncMock,
github_client: AsyncMock,
device_activation_event: asyncio.Event,
) -> None:
"""Test the full manual user flow from start to finish."""
aioclient_mock.post(
"https://github.com/login/device/code",
json={
"device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5",
"user_code": "WDJB-MJHT",
"verification_uri": "https://github.com/login/device",
"expires_in": 900,
"interval": 5,
},
headers={"Content-Type": "application/json"},
)
# User has not yet entered the code
aioclient_mock.post(
"https://github.com/login/oauth/access_token",
json={"error": "authorization_pending"},
headers={"Content-Type": "application/json"},
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
DOMAIN, context={"source": SOURCE_USER}
)
assert result["step_id"] == "device"
assert result["type"] is FlowResultType.SHOW_PROGRESS
# User enters the code
aioclient_mock.clear_requests()
aioclient_mock.post(
"https://github.com/login/oauth/access_token",
json={
CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN,
"token_type": "bearer",
"scope": "",
},
headers={"Content-Type": "application/json"},
)
freezer.tick(10)
device_activation_event.set()
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["step_id"] == "repositories"
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
schema = result["data_schema"]
repositories = schema.schema[CONF_REPOSITORIES].options
assert len(repositories) == 4
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_REPOSITORIES: DEFAULT_REPOSITORIES,
},
result["flow_id"], user_input={CONF_REPOSITORIES: DEFAULT_REPOSITORIES}
)
assert result["title"] == ""
assert result["type"] is FlowResultType.CREATE_ENTRY
assert "data" in result
assert result["data"][CONF_ACCESS_TOKEN] == MOCK_ACCESS_TOKEN
assert "options" in result
assert result["options"][CONF_REPOSITORIES] == DEFAULT_REPOSITORIES
assert result["data"] == {CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN}
assert result["options"] == {CONF_REPOSITORIES: DEFAULT_REPOSITORIES}
async def test_flow_with_registration_failure(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
github_device_client: AsyncMock,
) -> None:
"""Test flow with registration failure of the device."""
aioclient_mock.post(
"https://github.com/login/device/code",
exc=GitHubException("Registration failed"),
)
github_device_client.register.side_effect = GitHubException("Registration failed")
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result.get("reason") == "could_not_register"
assert result["reason"] == "could_not_register"
async def test_flow_with_activation_failure(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
freezer: FrozenDateTimeFactory,
github_device_client: AsyncMock,
device_activation_event: asyncio.Event,
) -> None:
"""Test flow with activation failure of the device."""
aioclient_mock.post(
"https://github.com/login/device/code",
json={
"device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5",
"user_code": "WDJB-MJHT",
"verification_uri": "https://github.com/login/device",
"expires_in": 900,
"interval": 5,
},
headers={"Content-Type": "application/json"},
)
# User has not yet entered the code
aioclient_mock.post(
"https://github.com/login/oauth/access_token",
json={"error": "authorization_pending"},
headers={"Content-Type": "application/json"},
)
async def mock_api_device_activation(device_code) -> None:
# Simulate the device activation process
await device_activation_event.wait()
raise GitHubException("Activation failed")
github_device_client.activation = mock_api_device_activation
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
DOMAIN, context={"source": SOURCE_USER}
)
assert result["step_id"] == "device"
assert result["type"] is FlowResultType.SHOW_PROGRESS
# Activation fails
aioclient_mock.clear_requests()
aioclient_mock.post(
"https://github.com/login/oauth/access_token",
exc=GitHubException("Activation failed"),
)
freezer.tick(10)
device_activation_event.set()
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
@@ -149,30 +103,14 @@ async def test_flow_with_activation_failure(
async def test_flow_with_remove_while_activating(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass: HomeAssistant, github_device_client: AsyncMock
) -> None:
"""Test flow with user canceling while activating."""
aioclient_mock.post(
"https://github.com/login/device/code",
json={
"device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5",
"user_code": "WDJB-MJHT",
"verification_uri": "https://github.com/login/device",
"expires_in": 900,
"interval": 5,
},
headers={"Content-Type": "application/json"},
)
aioclient_mock.post(
"https://github.com/login/oauth/access_token",
json={"error": "authorization_pending"},
headers={"Content-Type": "application/json"},
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
DOMAIN, context={"source": SOURCE_USER}
)
assert result["step_id"] == "device"
assert result["type"] is FlowResultType.SHOW_PROGRESS
@@ -194,84 +132,88 @@ async def test_already_configured(
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result.get("reason") == "already_configured"
assert result["reason"] == "already_configured"
async def test_starred_pagination_with_paginated_result(hass: HomeAssistant) -> None:
"""Test pagination of starred repositories with paginated result."""
with patch(
"homeassistant.components.github.config_flow.GitHubAPI",
return_value=MagicMock(
user=MagicMock(
starred=AsyncMock(
return_value=MagicMock(
is_last_page=False,
next_page_number=2,
last_page_number=2,
data=[MagicMock(full_name="home-assistant/core")],
)
),
repos=AsyncMock(
return_value=MagicMock(
is_last_page=False,
next_page_number=2,
last_page_number=2,
data=[MagicMock(full_name="awesome/reposiotry")],
)
),
)
),
):
repos = await get_repositories(hass, MOCK_ACCESS_TOKEN)
async def test_no_repositories(
hass: HomeAssistant,
mock_setup_entry: None,
github_device_client: AsyncMock,
github_client: AsyncMock,
device_activation_event: asyncio.Event,
) -> None:
"""Test the full manual user flow from start to finish."""
assert len(repos) == 2
assert repos[-1] == DEFAULT_REPOSITORIES[0]
github_client.user.repos.side_effect = [MagicMock(is_last_page=True, data=[])]
github_client.user.starred.side_effect = [MagicMock(is_last_page=True, data=[])]
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["step_id"] == "device"
assert result["type"] is FlowResultType.SHOW_PROGRESS
device_activation_event.set()
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["step_id"] == "repositories"
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
schema = result["data_schema"]
repositories = schema.schema[CONF_REPOSITORIES].options
assert len(repositories) == 2
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_REPOSITORIES: DEFAULT_REPOSITORIES}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_starred_pagination_with_no_starred(hass: HomeAssistant) -> None:
"""Test pagination of starred repositories with no starred."""
with patch(
"homeassistant.components.github.config_flow.GitHubAPI",
return_value=MagicMock(
user=MagicMock(
starred=AsyncMock(
return_value=MagicMock(
is_last_page=True,
data=[],
)
),
repos=AsyncMock(
return_value=MagicMock(
is_last_page=True,
data=[],
)
),
)
),
):
repos = await get_repositories(hass, MOCK_ACCESS_TOKEN)
async def test_exception_during_repository_fetch(
hass: HomeAssistant,
mock_setup_entry: None,
github_device_client: AsyncMock,
github_client: AsyncMock,
device_activation_event: asyncio.Event,
) -> None:
"""Test the full manual user flow from start to finish."""
assert len(repos) == 2
assert repos == DEFAULT_REPOSITORIES
github_client.user.repos.side_effect = GitHubException()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
async def test_starred_pagination_with_exception(hass: HomeAssistant) -> None:
"""Test pagination of starred repositories with exception."""
with patch(
"homeassistant.components.github.config_flow.GitHubAPI",
return_value=MagicMock(
user=MagicMock(starred=AsyncMock(side_effect=GitHubException("Error")))
),
):
repos = await get_repositories(hass, MOCK_ACCESS_TOKEN)
assert result["step_id"] == "device"
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert len(repos) == 2
assert repos == DEFAULT_REPOSITORIES
device_activation_event.set()
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["step_id"] == "repositories"
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
schema = result["data_schema"]
repositories = schema.schema[CONF_REPOSITORIES].options
assert len(repositories) == 2
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_REPOSITORIES: DEFAULT_REPOSITORIES}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_options_flow(

View File

@@ -1,89 +1,56 @@
"""Test GitHub diagnostics."""
import json
from unittest.mock import AsyncMock
from aiogithubapi import GitHubException
import pytest
from homeassistant.components.github.const import CONF_REPOSITORIES, DOMAIN
from homeassistant.core import HomeAssistant
from .common import setup_github_integration
from . import setup_integration
from tests.common import MockConfigEntry, async_load_fixture
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
# This tests needs to be adjusted to remove lingering tasks
@pytest.mark.parametrize("expected_lingering_tasks", [True])
async def test_entry_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
github_client: AsyncMock,
) -> None:
"""Test config entry diagnostics."""
mock_config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(
mock_config_entry,
options={CONF_REPOSITORIES: ["home-assistant/core"]},
)
response_json = json.loads(await async_load_fixture(hass, "graphql.json", DOMAIN))
response_json["data"]["repository"]["full_name"] = "home-assistant/core"
aioclient_mock.post(
"https://api.github.com/graphql",
json=response_json,
headers=json.loads(await async_load_fixture(hass, "base_headers.json", DOMAIN)),
)
aioclient_mock.get(
"https://api.github.com/rate_limit",
json={"resources": {"core": {"remaining": 100, "limit": 100}}},
headers={"Content-Type": "application/json"},
)
await setup_github_integration(
hass, mock_config_entry, aioclient_mock, add_entry_to_hass=False
)
await setup_integration(hass, mock_config_entry)
result = await get_diagnostics_for_config_entry(
hass,
hass_client,
mock_config_entry,
)
assert result["options"]["repositories"] == ["home-assistant/core"]
assert result["options"]["repositories"] == ["octocat/Hello-World"]
assert result["rate_limit"] == {
"resources": {"core": {"remaining": 100, "limit": 100}}
}
assert (
result["repositories"]["home-assistant/core"]["full_name"]
== "home-assistant/core"
result["repositories"]["octocat/Hello-World"]["full_name"]
== "octocat/Hello-World"
)
# This tests needs to be adjusted to remove lingering tasks
@pytest.mark.parametrize("expected_lingering_tasks", [True])
async def test_entry_diagnostics_exception(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
init_integration: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
mock_config_entry: MockConfigEntry,
github_client: AsyncMock,
) -> None:
"""Test config entry diagnostics with exception for ratelimit."""
aioclient_mock.get(
"https://api.github.com/rate_limit",
exc=GitHubException("error"),
)
await setup_integration(hass, mock_config_entry)
github_client.rate_limit.side_effect = GitHubException("error")
result = await get_diagnostics_for_config_entry(
hass,
hass_client,
init_integration,
mock_config_entry,
)
assert (
result["rate_limit"]["error"]
== "Unexpected exception for 'https://api.github.com/rate_limit' with - error"
)
assert result["rate_limit"]["error"] == "error"

View File

@@ -1,24 +1,23 @@
"""Test the GitHub init file."""
from unittest.mock import AsyncMock
import pytest
from homeassistant.components.github import CONF_REPOSITORIES
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er, icon
from .common import setup_github_integration
from . import setup_integration
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
# This tests needs to be adjusted to remove lingering tasks
@pytest.mark.parametrize("expected_lingering_tasks", [True])
async def test_device_registry_cleanup(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
github_client: AsyncMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that we remove untracked repositories from the device registry."""
@@ -27,9 +26,7 @@ async def test_device_registry_cleanup(
mock_config_entry,
options={CONF_REPOSITORIES: ["home-assistant/core"]},
)
await setup_github_integration(
hass, mock_config_entry, aioclient_mock, add_entry_to_hass=False
)
await setup_integration(hass, mock_config_entry)
devices = dr.async_entries_for_config_entry(
registry=device_registry,
@@ -58,12 +55,10 @@ async def test_device_registry_cleanup(
assert len(devices) == 0
# This tests needs to be adjusted to remove lingering tasks
@pytest.mark.parametrize("expected_lingering_tasks", [True])
async def test_subscription_setup(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
github_client: AsyncMock,
) -> None:
"""Test that we setup event subscription."""
mock_config_entry.add_to_hass(hass)
@@ -72,21 +67,14 @@ async def test_subscription_setup(
options={CONF_REPOSITORIES: ["home-assistant/core"]},
pref_disable_polling=False,
)
await setup_github_integration(
hass, mock_config_entry, aioclient_mock, add_entry_to_hass=False
)
assert (
"https://api.github.com/repos/home-assistant/core/events" in x[1]
for x in aioclient_mock.mock_calls
)
await setup_integration(hass, mock_config_entry)
github_client.repos.events.subscribe.assert_called_once()
# This tests needs to be adjusted to remove lingering tasks
@pytest.mark.parametrize("expected_lingering_tasks", [True])
async def test_subscription_setup_polling_disabled(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
github_client: AsyncMock,
) -> None:
"""Test that we do not setup event subscription if polling is disabled."""
mock_config_entry.add_to_hass(hass)
@@ -95,13 +83,8 @@ async def test_subscription_setup_polling_disabled(
options={CONF_REPOSITORIES: ["home-assistant/core"]},
pref_disable_polling=True,
)
await setup_github_integration(
hass, mock_config_entry, aioclient_mock, add_entry_to_hass=False
)
assert (
"https://api.github.com/repos/home-assistant/core/events" not in x[1]
for x in aioclient_mock.mock_calls
)
await setup_integration(hass, mock_config_entry)
github_client.repos.events.subscribe.assert_not_called()
# Prove that we subscribed if the user enabled polling again
hass.config_entries.async_update_entry(
@@ -109,23 +92,20 @@ async def test_subscription_setup_polling_disabled(
)
assert await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert (
"https://api.github.com/repos/home-assistant/core/events" in x[1]
for x in aioclient_mock.mock_calls
)
github_client.repos.events.subscribe.assert_called_once()
# This tests needs to be adjusted to remove lingering tasks
@pytest.mark.parametrize("expected_lingering_tasks", [True])
async def test_sensor_icons(
hass: HomeAssistant,
init_integration: MockConfigEntry,
github_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test to ensure that all sensor entities have an icon definition."""
await setup_integration(hass, mock_config_entry)
entities = er.async_entries_for_config_entry(
entity_registry,
config_entry_id=init_integration.entry_id,
config_entry_id=mock_config_entry.entry_id,
)
icons = await icon.async_get_icons(hass, "entity", integrations=["github"])

View File

@@ -1,50 +1,36 @@
"""Test GitHub sensor."""
import json
from unittest.mock import AsyncMock
import pytest
from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.github.const import DOMAIN, FALLBACK_UPDATE_INTERVAL
from homeassistant.components.github.const import FALLBACK_UPDATE_INTERVAL
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from .common import TEST_REPOSITORY
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, async_load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.common import MockConfigEntry, async_fire_time_changed
TEST_SENSOR_ENTITY = "sensor.octocat_hello_world_latest_release"
# This tests needs to be adjusted to remove lingering tasks
@pytest.mark.parametrize("expected_lingering_tasks", [True])
async def test_sensor_updates_with_empty_release_array(
hass: HomeAssistant,
init_integration: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
github_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the sensor updates by default GitHub sensors."""
await setup_integration(hass, mock_config_entry)
state = hass.states.get(TEST_SENSOR_ENTITY)
assert state.state == "v1.0.0"
response_json = json.loads(await async_load_fixture(hass, "graphql.json", DOMAIN))
response_json["data"]["repository"]["release"] = None
headers = json.loads(await async_load_fixture(hass, "base_headers.json", DOMAIN))
github_client.graphql.return_value.data["data"]["repository"]["release"] = None
aioclient_mock.clear_requests()
aioclient_mock.get(
f"https://api.github.com/repos/{TEST_REPOSITORY}/events",
json=[],
headers=headers,
)
aioclient_mock.post(
"https://api.github.com/graphql",
json=response_json,
headers=headers,
)
async_fire_time_changed(hass, dt_util.utcnow() + FALLBACK_UPDATE_INTERVAL)
freezer.tick(FALLBACK_UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
new_state = hass.states.get(TEST_SENSOR_ENTITY)
assert new_state.state == "unavailable"
assert new_state.state == STATE_UNAVAILABLE

View File

@@ -484,14 +484,14 @@
'object_id_base': 'Ozone',
'options': dict({
}),
'original_device_class': None,
'original_device_class': <SensorDeviceClass.OZONE: 'ozone'>,
'original_icon': None,
'original_name': 'Ozone',
'platform': 'google_air_quality',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'ozone',
'translation_key': None,
'unique_id': 'o3_10.1_20.1',
'unit_of_measurement': 'ppb',
})
@@ -500,6 +500,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by Google Air Quality',
'device_class': 'ozone',
'friendly_name': 'Home Ozone',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'ppb',

View File

@@ -59,7 +59,7 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
return MockConfigEntry(
domain=DOMAIN,
title=TITLE,
unique_id=54321,
unique_id="54321",
data={
"auth_implementation": DOMAIN,
"token": {

View File

@@ -74,7 +74,7 @@ async def test_full_flow(
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test@microbees.com"
assert "result" in result
assert result["result"].unique_id == 54321
assert result["result"].unique_id == "54321"
assert "token" in result["result"].data
assert result["result"].data["token"]["access_token"] == "mock-access-token"
assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token"
@@ -197,7 +197,7 @@ async def test_config_reauth_wrong_account(
) -> None:
"""Test reauth with wrong account."""
await setup_integration(hass, config_entry)
microbees.return_value.getMyProfile.return_value.id = 12345
microbees.return_value.getMyProfile.return_value.id = "12345"
result = await config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"

View File

@@ -0,0 +1,35 @@
"""Tests for the microBees component."""
from unittest.mock import patch
from homeassistant.components.microbees.const import DOMAIN
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None:
"""Test migrating a 1.1 config entry to 1.2."""
with patch(
"homeassistant.components.microbees.async_setup_entry", return_value=True
):
entry = MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": DOMAIN,
"token": {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
},
version=1,
minor_version=1,
unique_id=54321,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
assert entry.version == 1
assert entry.minor_version == 2
assert entry.unique_id == "54321"

View File

@@ -244,7 +244,7 @@ async def test_config_reauth_wrong_account(
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
"user_id": 12346,
"user_id": "12346",
},
)

View File

@@ -1,7 +1,7 @@
"""Tests for component initialisation."""
from datetime import timedelta
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from monzopy import AuthorisationExpiredError
@@ -35,3 +35,29 @@ async def test_api_can_trigger_reauth(
assert flow["step_id"] == "reauth_confirm"
assert flow["handler"] == DOMAIN
assert flow["context"]["source"] == SOURCE_REAUTH
async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None:
"""Test migrating a 1.1 config entry to 1.2."""
with patch("homeassistant.components.monzo.async_setup_entry", return_value=True):
entry = MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": DOMAIN,
"token": {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
"user_id": "600",
},
},
version=1,
minor_version=1,
unique_id=600,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
assert entry.version == 1
assert entry.minor_version == 2
assert entry.unique_id == "600"

View File

@@ -57,6 +57,7 @@ def mock_aiontfy() -> Generator[AsyncMock]:
actions=[],
attachment=None,
content_type=None,
sequence_id="Mc3otamDNcpJ",
)
resp.to_dict.return_value = {
@@ -74,6 +75,7 @@ def mock_aiontfy() -> Generator[AsyncMock]:
"actions": [],
"attachment": None,
"content_type": None,
"sequence_id": "Mc3otamDNcpJ",
}
async def mock_ws(

View File

@@ -59,6 +59,7 @@
'id': 'h6Y2hKA5sy0U',
'message': 'Hello',
'priority': 3,
'sequence_id': 'Mc3otamDNcpJ',
'tags': list([
'octopus',
]),

View File

@@ -101,6 +101,7 @@ async def test_event(
"time": datetime(2025, 3, 28, 17, 58, 46, tzinfo=UTC),
"title": "Title",
"topic": "mytopic",
"sequence_id": "Mc3otamDNcpJ",
}

View File

@@ -22,6 +22,7 @@ from homeassistant.components.ntfy.notify import (
ATTR_ICON,
ATTR_MARKDOWN,
ATTR_PRIORITY,
ATTR_SEQUENCE_ID,
ATTR_TAGS,
SERVICE_PUBLISH,
)
@@ -60,6 +61,7 @@ async def test_ntfy_publish(
ATTR_MARKDOWN: True,
ATTR_PRIORITY: "5",
ATTR_TAGS: ["partying_face", "grin"],
ATTR_SEQUENCE_ID: "Mc3otamDNcpJ",
},
blocking=True,
)
@@ -76,6 +78,7 @@ async def test_ntfy_publish(
markdown=True,
icon=URL("https://example.org/logo.png"),
delay="86430.0s",
sequence_id="Mc3otamDNcpJ",
)
)

View File

@@ -52,10 +52,13 @@ def make_test_trigger(*entities: str) -> dict:
async def async_trigger(
hass: HomeAssistant, entity_id: str, state: str | None = None
hass: HomeAssistant,
entity_id: str,
state: str | None = None,
attributes: dict | None = None,
) -> None:
"""Trigger a state change."""
hass.states.async_set(entity_id, state)
hass.states.async_set(entity_id, state, attributes)
await hass.async_block_till_done()

View File

@@ -236,8 +236,8 @@ BINARY_SENSOR_OPTIONS = {
"on",
{"one": "on", "two": "off"},
{},
{"options": "{{ ['off', 'on', 'auto'] }}"},
{"options": "{{ ['off', 'on', 'auto'] }}"},
{"options": "{{ ['off', 'on', 'auto'] }}", "select_option": []},
{"options": "{{ ['off', 'on', 'auto'] }}", "select_option": []},
{},
),
(
@@ -458,8 +458,8 @@ async def test_config_flow(
(
"select",
{"state": "{{ states('select.one') }}"},
{"options": "{{ ['off', 'on', 'auto'] }}"},
{"options": "{{ ['off', 'on', 'auto'] }}"},
{"options": "{{ ['off', 'on', 'auto'] }}", "select_option": []},
{"options": "{{ ['off', 'on', 'auto'] }}", "select_option": []},
),
(
"update",
@@ -734,8 +734,8 @@ async def test_config_flow_device(
{"state": "{{ states('select.two') }}"},
["on", "off"],
{"one": "on", "two": "off"},
{"options": "{{ ['off', 'on', 'auto'] }}"},
{"options": "{{ ['off', 'on', 'auto'] }}"},
{"options": "{{ ['off', 'on', 'auto'] }}", "select_option": []},
{"options": "{{ ['off', 'on', 'auto'] }}", "select_option": []},
"state",
),
(
@@ -1606,8 +1606,8 @@ async def test_option_flow_sensor_preview_config_entry_removed(
(
"select",
{"state": "{{ states('select.one') }}"},
{"options": "{{ ['off', 'on', 'auto'] }}"},
{"options": "{{ ['off', 'on', 'auto'] }}"},
{"options": "{{ ['off', 'on', 'auto'] }}", "select_option": []},
{"options": "{{ ['off', 'on', 'auto'] }}", "select_option": []},
),
(
"switch",

View File

@@ -405,10 +405,12 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None:
"name": "My template",
"state": "{{ 'on' }}",
"options": "{{ ['off', 'on', 'auto'] }}",
"select_option": [],
},
{
"state": "{{ 'on' }}",
"options": "{{ ['off', 'on', 'auto'] }}",
"select_option": [],
},
),
(

View File

@@ -5,13 +5,7 @@ from typing import Any
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant import setup
from homeassistant.components import number, template
from homeassistant.components.input_number import (
ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE,
DOMAIN as INPUT_NUMBER_DOMAIN,
SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE,
)
from homeassistant.components import number
from homeassistant.components.number import (
ATTR_MAX,
ATTR_MIN,
@@ -32,104 +26,60 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import Context, HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from .conftest import ConfigurationStyle, async_get_flow_preview_state
from .conftest import (
ConfigurationStyle,
TemplatePlatformSetup,
async_get_flow_preview_state,
async_trigger,
make_test_trigger,
setup_and_test_nested_unique_id,
setup_and_test_unique_id,
setup_entity,
)
from tests.common import MockConfigEntry, assert_setup_component, async_capture_events
from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
_TEST_OBJECT_ID = "template_number"
_TEST_NUMBER = f"number.{_TEST_OBJECT_ID}"
# Represent for number's value
_VALUE_INPUT_NUMBER = "input_number.value"
# Represent for number's minimum
_MINIMUM_INPUT_NUMBER = "input_number.minimum"
# Represent for number's maximum
_MAXIMUM_INPUT_NUMBER = "input_number.maximum"
# Represent for number's step
_STEP_INPUT_NUMBER = "input_number.step"
# Config for `_VALUE_INPUT_NUMBER`
_VALUE_INPUT_NUMBER_CONFIG = {
"value": {
"min": 0.0,
"max": 100.0,
"name": "Value",
"step": 1.0,
"mode": "slider",
}
}
TEST_STATE_ENTITY_ID = "number.test_state"
TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.test_availability"
TEST_STATE_TRIGGER = {
"trigger": {
"trigger": "state",
"entity_id": [TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY_ID],
TEST_MAXIMUM_ENTITY_ID = "sensor.maximum"
TEST_MINIMUM_ENTITY_ID = "sensor.minimum"
TEST_STATE_ENTITY_ID = "number.test_state"
TEST_STEP_ENTITY_ID = "sensor.step"
TEST_NUMBER = TemplatePlatformSetup(
number.DOMAIN,
None,
"template_number",
make_test_trigger(
TEST_AVAILABILITY_ENTITY_ID,
TEST_MAXIMUM_ENTITY_ID,
TEST_MINIMUM_ENTITY_ID,
TEST_STATE_ENTITY_ID,
TEST_STEP_ENTITY_ID,
),
)
TEST_SET_VALUE_ACTION = {
"action": "test.automation",
"data": {
"action": "set_value",
"caller": "{{ this.entity_id }}",
"value": "{{ value }}",
},
"variables": {"triggering_entity": "{{ trigger.entity_id }}"},
"action": [
{"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}}
],
}
TEST_REQUIRED = {"state": "0", "step": "1", "set_value": []}
async def async_setup_modern_format(
hass: HomeAssistant, count: int, number_config: dict[str, Any]
) -> None:
"""Do setup of number integration via new format."""
config = {"template": {"number": number_config}}
with assert_setup_component(count, template.DOMAIN):
assert await async_setup_component(
hass,
template.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
async def async_setup_trigger_format(
hass: HomeAssistant, count: int, number_config: dict[str, Any]
) -> None:
"""Do setup of number integration via trigger format."""
config = {"template": {**TEST_STATE_TRIGGER, "number": number_config}}
with assert_setup_component(count, template.DOMAIN):
assert await async_setup_component(
hass,
template.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
@pytest.fixture
async def setup_number(
hass: HomeAssistant,
count: int,
style: ConfigurationStyle,
number_config: dict[str, Any],
config: dict[str, Any],
) -> None:
"""Do setup of number integration."""
if style == ConfigurationStyle.MODERN:
await async_setup_modern_format(
hass, count, {"name": _TEST_OBJECT_ID, **number_config}
)
if style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass, count, {"name": _TEST_OBJECT_ID, **number_config}
)
await setup_entity(hass, TEST_NUMBER, style, count, config)
async def test_setup_config_entry(
@@ -166,294 +116,135 @@ async def test_setup_config_entry(
assert state == snapshot
async def test_missing_optional_config(hass: HomeAssistant) -> None:
"""Test: missing optional template is ok."""
with assert_setup_component(1, "template"):
assert await setup.async_setup_component(
hass,
"template",
@pytest.mark.parametrize(
("count", "config"),
[
(
1,
{
"template": {
"number": {
"state": "{{ 4 }}",
"set_value": {"service": "script.set_value"},
"step": "{{ 1 }}",
}
}
"state": "{{ 4 }}",
"set_value": {"service": "script.set_value"},
"step": "{{ 1 }}",
},
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
],
)
@pytest.mark.parametrize(
"style",
[ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_number")
async def test_missing_optional_config(hass: HomeAssistant) -> None:
"""Test: missing optional template is ok."""
await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything")
_verify(hass, 4, 1, 0.0, 100.0, None)
async def test_missing_required_keys(hass: HomeAssistant) -> None:
"""Test: missing required fields will fail."""
with assert_setup_component(0, "template"):
assert await setup.async_setup_component(
hass,
"template",
@pytest.mark.parametrize(
("count", "config"),
[
(
0,
{
"template": {
"number": {
"state": "{{ 4 }}",
}
}
"state": "{{ 4 }}",
},
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
],
)
@pytest.mark.parametrize(
"style",
[ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_number")
async def test_missing_required_keys(hass: HomeAssistant) -> None:
"""Test: missing required fields will fail."""
assert hass.states.async_all("number") == []
async def test_all_optional_config(hass: HomeAssistant) -> None:
"""Test: including all optional templates is ok."""
with assert_setup_component(1, "template"):
assert await setup.async_setup_component(
hass,
"template",
@pytest.mark.parametrize(
("count", "config"),
[
(
1,
{
"template": {
"number": {
"state": "{{ 4 }}",
"set_value": {"service": "script.set_value"},
"min": "{{ 3 }}",
"max": "{{ 5 }}",
"step": "{{ 1 }}",
"unit_of_measurement": "beer",
}
}
"state": "{{ 4 }}",
"set_value": {"service": "script.set_value"},
"min": "{{ 3 }}",
"max": "{{ 5 }}",
"step": "{{ 1 }}",
"unit_of_measurement": "beer",
},
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
],
)
@pytest.mark.parametrize(
"style",
[ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_number")
async def test_all_optional_config(hass: HomeAssistant) -> None:
"""Test: including all optional templates is ok."""
await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything")
_verify(hass, 4, 1, 3, 5, "beer")
async def test_templates_with_entities(
@pytest.mark.parametrize(
("count", "config"),
[
(
1,
{
"state": f"{{{{ states('{TEST_STATE_ENTITY_ID}') | float(1.0) }}}}",
"step": f"{{{{ states('{TEST_STEP_ENTITY_ID}') | float(5.0) }}}}",
"min": f"{{{{ states('{TEST_MINIMUM_ENTITY_ID}') | float(0.0) }}}}",
"max": f"{{{{ states('{TEST_MAXIMUM_ENTITY_ID}') | float(100.0) }}}}",
"set_value": [TEST_SET_VALUE_ACTION],
},
)
],
)
@pytest.mark.parametrize(
"style",
[ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_number")
async def test_template_number(
hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall]
) -> None:
"""Test templates with values from other entities."""
with assert_setup_component(4, "input_number"):
assert await setup.async_setup_component(
hass,
"input_number",
{
"input_number": {
**_VALUE_INPUT_NUMBER_CONFIG,
"step": {
"min": 0.0,
"max": 100.0,
"name": "Step",
"step": 1.0,
"mode": "slider",
},
"minimum": {
"min": 0.0,
"max": 100.0,
"name": "Minimum",
"step": 1.0,
"mode": "slider",
},
"maximum": {
"min": 0.0,
"max": 100.0,
"name": "Maximum",
"step": 1.0,
"mode": "slider",
},
}
},
)
with assert_setup_component(1, "template"):
assert await setup.async_setup_component(
hass,
"template",
{
"template": {
"unique_id": "b",
"number": {
"state": f"{{{{ states('{_VALUE_INPUT_NUMBER}') }}}}",
"step": f"{{{{ states('{_STEP_INPUT_NUMBER}') }}}}",
"min": f"{{{{ states('{_MINIMUM_INPUT_NUMBER}') }}}}",
"max": f"{{{{ states('{_MAXIMUM_INPUT_NUMBER}') }}}}",
"set_value": [
{
"service": "input_number.set_value",
"data_template": {
"entity_id": _VALUE_INPUT_NUMBER,
"value": "{{ value }}",
},
},
{
"service": "test.automation",
"data_template": {
"action": "set_value",
"caller": "{{ this.entity_id }}",
"value": "{{ value }}",
},
},
],
"optimistic": True,
"unique_id": "a",
},
}
},
)
hass.states.async_set(_VALUE_INPUT_NUMBER, 4)
hass.states.async_set(_STEP_INPUT_NUMBER, 1)
hass.states.async_set(_MINIMUM_INPUT_NUMBER, 3)
hass.states.async_set(_MAXIMUM_INPUT_NUMBER, 5)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
entry = entity_registry.async_get(_TEST_NUMBER)
assert entry
assert entry.unique_id == "b-a"
await async_trigger(hass, TEST_STATE_ENTITY_ID, 4)
await async_trigger(hass, TEST_STEP_ENTITY_ID, 1)
await async_trigger(hass, TEST_MINIMUM_ENTITY_ID, 3)
await async_trigger(hass, TEST_MAXIMUM_ENTITY_ID, 5)
_verify(hass, 4, 1, 3, 5, None)
await hass.services.async_call(
INPUT_NUMBER_DOMAIN,
INPUT_NUMBER_SERVICE_SET_VALUE,
{CONF_ENTITY_ID: _VALUE_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 5},
blocking=True,
)
await hass.async_block_till_done()
await async_trigger(hass, TEST_STATE_ENTITY_ID, 5)
_verify(hass, 5, 1, 3, 5, None)
await hass.services.async_call(
INPUT_NUMBER_DOMAIN,
INPUT_NUMBER_SERVICE_SET_VALUE,
{CONF_ENTITY_ID: _STEP_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 2},
blocking=True,
)
await hass.async_block_till_done()
await async_trigger(hass, TEST_STEP_ENTITY_ID, 2)
_verify(hass, 5, 2, 3, 5, None)
await hass.services.async_call(
INPUT_NUMBER_DOMAIN,
INPUT_NUMBER_SERVICE_SET_VALUE,
{CONF_ENTITY_ID: _MINIMUM_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 2},
blocking=True,
)
await hass.async_block_till_done()
await async_trigger(hass, TEST_MINIMUM_ENTITY_ID, 2)
_verify(hass, 5, 2, 2, 5, None)
await hass.services.async_call(
INPUT_NUMBER_DOMAIN,
INPUT_NUMBER_SERVICE_SET_VALUE,
{CONF_ENTITY_ID: _MAXIMUM_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 6},
blocking=True,
)
await hass.async_block_till_done()
await async_trigger(hass, TEST_MAXIMUM_ENTITY_ID, 6)
_verify(hass, 5, 2, 2, 6, None)
await hass.services.async_call(
NUMBER_DOMAIN,
NUMBER_SERVICE_SET_VALUE,
{CONF_ENTITY_ID: _TEST_NUMBER, NUMBER_ATTR_VALUE: 2},
{CONF_ENTITY_ID: TEST_NUMBER.entity_id, NUMBER_ATTR_VALUE: 2},
blocking=True,
)
_verify(hass, 2, 2, 2, 6, None)
# Check this variable can be used in set_value script
assert len(calls) == 1
assert calls[-1].data["action"] == "set_value"
assert calls[-1].data["caller"] == _TEST_NUMBER
assert calls[-1].data["caller"] == TEST_NUMBER.entity_id
assert calls[-1].data["value"] == 2
async def test_trigger_number(hass: HomeAssistant) -> None:
"""Test trigger based template number."""
events = async_capture_events(hass, "test_number_event")
assert await setup.async_setup_component(
hass,
"template",
{
"template": [
{"invalid": "config"},
# Config after invalid should still be set up
{
"unique_id": "listening-test-event",
"trigger": {"platform": "event", "event_type": "test_event"},
"number": [
{
"name": "Hello Name",
"unique_id": "hello_name-id",
"state": "{{ trigger.event.data.beers_drank }}",
"min": "{{ trigger.event.data.min_beers }}",
"max": "{{ trigger.event.data.max_beers }}",
"step": "{{ trigger.event.data.step }}",
"unit_of_measurement": "beer",
"set_value": {
"event": "test_number_event",
"event_data": {"entity_id": "{{ this.entity_id }}"},
},
"optimistic": True,
},
],
},
],
},
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
state = hass.states.get("number.hello_name")
assert state is not None
assert state.state == STATE_UNKNOWN
assert state.attributes["min"] == 0.0
assert state.attributes["max"] == 100.0
assert state.attributes["step"] == 1.0
assert state.attributes["unit_of_measurement"] == "beer"
context = Context()
hass.bus.async_fire(
"test_event",
{
"beers_drank": 3,
"min_beers": 1.0,
"max_beers": 5.0,
"step": 0.5,
},
context=context,
)
await hass.async_block_till_done()
state = hass.states.get("number.hello_name")
assert state is not None
assert state.state == "3.0"
assert state.attributes["min"] == 1.0
assert state.attributes["max"] == 5.0
assert state.attributes["step"] == 0.5
await hass.services.async_call(
NUMBER_DOMAIN,
NUMBER_SERVICE_SET_VALUE,
{CONF_ENTITY_ID: "number.hello_name", NUMBER_ATTR_VALUE: 2},
blocking=True,
)
assert len(events) == 1
assert events[0].event_type == "test_number_event"
entity_id = events[0].data.get("entity_id")
assert entity_id is not None
assert entity_id == "number.hello_name"
await async_trigger(hass, TEST_STATE_ENTITY_ID, 2)
_verify(hass, 2, 2, 2, 6, None)
def _verify(
@@ -465,7 +256,7 @@ def _verify(
expected_unit_of_measurement: str | None,
) -> None:
"""Verify number's state."""
state = hass.states.get(_TEST_NUMBER)
state = hass.states.get(TEST_NUMBER.entity_id)
attributes = state.attributes
assert state.state == str(float(expected_value))
assert attributes.get(ATTR_STEP) == float(expected_step)
@@ -480,7 +271,7 @@ def _verify(
[(ConfigurationStyle.MODERN, ""), (ConfigurationStyle.TRIGGER, None)],
)
@pytest.mark.parametrize(
("number_config", "attribute", "expected"),
("config", "attribute", "expected"),
[
(
{
@@ -508,13 +299,12 @@ async def test_templated_optional_config(
initial_expected_state: str | None,
) -> None:
"""Test optional config templates."""
state = hass.states.get(_TEST_NUMBER)
state = hass.states.get(TEST_NUMBER.entity_id)
assert state.attributes.get(attribute) == initial_expected_state
state = hass.states.async_set(TEST_STATE_ENTITY_ID, "1")
await hass.async_block_till_done()
await async_trigger(hass, TEST_STATE_ENTITY_ID, "1")
state = hass.states.get(_TEST_NUMBER)
state = hass.states.get(TEST_NUMBER.entity_id)
assert state.attributes[attribute] == expected
@@ -567,7 +357,7 @@ async def test_device_id(
@pytest.mark.parametrize(
("count", "number_config"),
("count", "config"),
[
(
1,
@@ -587,26 +377,26 @@ async def test_optimistic(hass: HomeAssistant) -> None:
await hass.services.async_call(
number.DOMAIN,
number.SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: _TEST_NUMBER, "value": 4},
{ATTR_ENTITY_ID: TEST_NUMBER.entity_id, "value": 4},
blocking=True,
)
state = hass.states.get(_TEST_NUMBER)
state = hass.states.get(TEST_NUMBER.entity_id)
assert float(state.state) == 4
await hass.services.async_call(
number.DOMAIN,
number.SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: _TEST_NUMBER, "value": 2},
{ATTR_ENTITY_ID: TEST_NUMBER.entity_id, "value": 2},
blocking=True,
)
state = hass.states.get(_TEST_NUMBER)
state = hass.states.get(TEST_NUMBER.entity_id)
assert float(state.state) == 2
@pytest.mark.parametrize(
("count", "number_config"),
("count", "config"),
[
(
1,
@@ -628,16 +418,16 @@ async def test_not_optimistic(hass: HomeAssistant) -> None:
await hass.services.async_call(
number.DOMAIN,
number.SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: _TEST_NUMBER, "value": 4},
{ATTR_ENTITY_ID: TEST_NUMBER.entity_id, "value": 4},
blocking=True,
)
state = hass.states.get(_TEST_NUMBER)
state = hass.states.get(TEST_NUMBER.entity_id)
assert state.state == STATE_UNKNOWN
@pytest.mark.parametrize(
("count", "number_config"),
("count", "config"),
[
(
1,
@@ -656,34 +446,29 @@ async def test_not_optimistic(hass: HomeAssistant) -> None:
async def test_availability(hass: HomeAssistant) -> None:
"""Test configuration with optimistic state."""
hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on")
hass.states.async_set(TEST_STATE_ENTITY_ID, "4.0")
await hass.async_block_till_done()
state = hass.states.get(_TEST_NUMBER)
await async_trigger(hass, TEST_STATE_ENTITY_ID, "4.0")
await async_trigger(hass, TEST_AVAILABILITY_ENTITY_ID, "on")
state = hass.states.get(TEST_NUMBER.entity_id)
assert float(state.state) == 4
hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "off")
await hass.async_block_till_done()
await async_trigger(hass, TEST_AVAILABILITY_ENTITY_ID, "off")
state = hass.states.get(_TEST_NUMBER)
state = hass.states.get(TEST_NUMBER.entity_id)
assert state.state == STATE_UNAVAILABLE
hass.states.async_set(TEST_STATE_ENTITY_ID, "2.0")
await hass.async_block_till_done()
await async_trigger(hass, TEST_STATE_ENTITY_ID, "2.0")
state = hass.states.get(_TEST_NUMBER)
state = hass.states.get(TEST_NUMBER.entity_id)
assert state.state == STATE_UNAVAILABLE
hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on")
await hass.async_block_till_done()
await async_trigger(hass, TEST_AVAILABILITY_ENTITY_ID, "on")
state = hass.states.get(_TEST_NUMBER)
state = hass.states.get(TEST_NUMBER.entity_id)
assert float(state.state) == 2
@pytest.mark.parametrize(
("count", "number_config"),
("count", "config"),
[
(
1,
@@ -702,16 +487,17 @@ async def test_availability(hass: HomeAssistant) -> None:
ConfigurationStyle.MODERN,
],
)
async def test_empty_action_config(hass: HomeAssistant, setup_number) -> None:
@pytest.mark.usefixtures("setup_number")
async def test_empty_action_config(hass: HomeAssistant) -> None:
"""Test configuration with empty script."""
await hass.services.async_call(
number.DOMAIN,
number.SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: _TEST_NUMBER, "value": 4},
{ATTR_ENTITY_ID: TEST_NUMBER.entity_id, "value": 4},
blocking=True,
)
state = hass.states.get(_TEST_NUMBER)
state = hass.states.get(TEST_NUMBER.entity_id)
assert float(state.state) == 4
@@ -734,3 +520,29 @@ async def test_flow_preview(
)
assert state["state"] == "0.0"
@pytest.mark.parametrize(
"style",
[ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
async def test_unique_id(
hass: HomeAssistant,
style: ConfigurationStyle,
) -> None:
"""Test unique_id option only creates one vacuum per id."""
await setup_and_test_unique_id(hass, TEST_NUMBER, style, TEST_REQUIRED, "{{ 0 }}")
@pytest.mark.parametrize(
"style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER]
)
async def test_nested_unique_id(
hass: HomeAssistant,
style: ConfigurationStyle,
entity_registry: er.EntityRegistry,
) -> None:
"""Test a template unique_id propagates to vacuum unique_ids."""
await setup_and_test_nested_unique_id(
hass, TEST_NUMBER, style, entity_registry, TEST_REQUIRED, "{{ 0 }}"
)

View File

@@ -6,14 +6,7 @@ import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant import setup
from homeassistant.components import select, template
from homeassistant.components.input_select import (
ATTR_OPTION as INPUT_SELECT_ATTR_OPTION,
ATTR_OPTIONS as INPUT_SELECT_ATTR_OPTIONS,
DOMAIN as INPUT_SELECT_DOMAIN,
SERVICE_SELECT_OPTION as INPUT_SELECT_SERVICE_SELECT_OPTION,
SERVICE_SET_OPTIONS,
)
from homeassistant.components import select
from homeassistant.components.select import (
ATTR_OPTION as SELECT_ATTR_OPTION,
ATTR_OPTIONS as SELECT_ATTR_OPTIONS,
@@ -31,77 +24,46 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import Context, HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from .conftest import ConfigurationStyle, async_get_flow_preview_state
from .conftest import (
ConfigurationStyle,
TemplatePlatformSetup,
async_get_flow_preview_state,
async_trigger,
make_test_trigger,
setup_and_test_nested_unique_id,
setup_and_test_unique_id,
setup_entity,
)
from tests.common import MockConfigEntry, assert_setup_component, async_capture_events
from tests.common import MockConfigEntry, assert_setup_component
from tests.conftest import WebSocketGenerator
_TEST_OBJECT_ID = "template_select"
_TEST_SELECT = f"select.{_TEST_OBJECT_ID}"
# Represent for select's current_option
_OPTION_INPUT_SELECT = "input_select.option"
TEST_STATE_ENTITY_ID = "select.test_state"
TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.test_availability"
TEST_STATE_TRIGGER = {
"trigger": {
"trigger": "state",
"entity_id": [
_OPTION_INPUT_SELECT,
TEST_STATE_ENTITY_ID,
TEST_AVAILABILITY_ENTITY_ID,
],
},
"variables": {"triggering_entity": "{{ trigger.entity_id }}"},
"action": [
{"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}}
],
}
TEST_OPTIONS = {
"state": "test",
TEST_SELECT = TemplatePlatformSetup(
select.DOMAIN,
None,
"template_select",
make_test_trigger(TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY_ID),
)
TEST_OPTIONS_WITHOUT_STATE = {
"options": "{{ ['test', 'yes', 'no'] }}",
"select_option": [],
}
async def async_setup_modern_format(
hass: HomeAssistant, count: int, select_config: dict[str, Any]
) -> None:
"""Do setup of select integration via new format."""
config = {"template": {"select": select_config}}
with assert_setup_component(count, template.DOMAIN):
assert await async_setup_component(
hass,
template.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
async def async_setup_trigger_format(
hass: HomeAssistant, count: int, select_config: dict[str, Any]
) -> None:
"""Do setup of select integration via trigger format."""
config = {"template": {**TEST_STATE_TRIGGER, "select": select_config}}
with assert_setup_component(count, template.DOMAIN):
assert await async_setup_component(
hass,
template.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
TEST_OPTIONS = {"state": "test", **TEST_OPTIONS_WITHOUT_STATE}
TEST_OPTION_ACTION = {
"action": "test.automation",
"data": {
"action": "select_option",
"caller": "{{ this.entity_id }}",
"option": "{{ option }}",
},
}
@pytest.fixture
@@ -109,17 +71,10 @@ async def setup_select(
hass: HomeAssistant,
count: int,
style: ConfigurationStyle,
select_config: dict[str, Any],
config: dict[str, Any],
) -> None:
"""Do setup of select integration."""
if style == ConfigurationStyle.MODERN:
await async_setup_modern_format(
hass, count, {"name": _TEST_OBJECT_ID, **select_config}
)
if style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass, count, {"name": _TEST_OBJECT_ID, **select_config}
)
await setup_entity(hass, TEST_SELECT, style, count, config)
async def test_setup_config_entry(
@@ -136,6 +91,7 @@ async def test_setup_config_entry(
"template_type": "select",
"state": "{{ 'on' }}",
"options": "{{ ['off', 'on', 'auto'] }}",
"select_option": [],
},
title="My template",
)
@@ -149,27 +105,24 @@ async def test_setup_config_entry(
assert state == snapshot
@pytest.mark.parametrize("count", [1])
@pytest.mark.parametrize(
"config",
[
{
"state": "{{ 'a' }}",
"select_option": {"service": "script.select_option"},
"options": "{{ ['a', 'b'] }}",
},
],
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER]
)
@pytest.mark.usefixtures("setup_select")
async def test_missing_optional_config(hass: HomeAssistant) -> None:
"""Test: missing optional template is ok."""
with assert_setup_component(1, "template"):
assert await setup.async_setup_component(
hass,
"template",
{
"template": {
"select": {
"state": "{{ 'a' }}",
"select_option": {"service": "script.select_option"},
"options": "{{ ['a', 'b'] }}",
}
}
},
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything")
_verify(hass, "a", ["a", "b"])
@@ -202,231 +155,85 @@ async def test_multiple_configs(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
_verify(hass, "a", ["a", "b"])
_verify(hass, "a", ["a", "b"], f"{_TEST_SELECT}_2")
_verify(hass, "a", ["a", "b"], f"{TEST_SELECT.entity_id}_2")
@pytest.mark.parametrize("count", [0])
@pytest.mark.parametrize(
"config",
[
{
"state": "{{ 'a' }}",
"select_option": {"service": "script.select_option"},
},
{
"state": "{{ 'a' }}",
"options": "{{ ['a', 'b'] }}",
},
],
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER]
)
@pytest.mark.usefixtures("setup_select")
async def test_missing_required_keys(hass: HomeAssistant) -> None:
"""Test: missing required fields will fail."""
with assert_setup_component(0, "select"):
assert await setup.async_setup_component(
hass,
"select",
{
"template": {
"select": {
"state": "{{ 'a' }}",
"select_option": {"service": "script.select_option"},
}
}
},
)
with assert_setup_component(0, "select"):
assert await setup.async_setup_component(
hass,
"select",
{
"template": {
"select": {
"state": "{{ 'a' }}",
"options": "{{ ['a', 'b'] }}",
}
}
},
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
assert hass.states.async_all("select") == []
async def test_templates_with_entities(
hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall]
) -> None:
@pytest.mark.parametrize(
("count", "config"),
[
(
1,
{
"options": "{{ state_attr('select.test_state', 'options') or [] }}",
"select_option": [TEST_OPTION_ACTION],
"state": "{{ states('select.test_state') }}",
},
)
],
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER]
)
@pytest.mark.usefixtures("setup_select")
async def test_template_select(hass: HomeAssistant, calls: list[ServiceCall]) -> None:
"""Test templates with values from other entities."""
with assert_setup_component(1, "input_select"):
assert await setup.async_setup_component(
hass,
"input_select",
{
"input_select": {
"option": {
"options": ["a", "b"],
"initial": "a",
"name": "Option",
},
}
},
)
with assert_setup_component(1, "template"):
assert await setup.async_setup_component(
hass,
"template",
{
"template": {
"unique_id": "b",
"select": {
"state": f"{{{{ states('{_OPTION_INPUT_SELECT}') }}}}",
"options": f"{{{{ state_attr('{_OPTION_INPUT_SELECT}', '{INPUT_SELECT_ATTR_OPTIONS}') }}}}",
"select_option": [
{
"service": "input_select.select_option",
"data_template": {
"entity_id": _OPTION_INPUT_SELECT,
"option": "{{ option }}",
},
},
{
"service": "test.automation",
"data_template": {
"action": "select_option",
"caller": "{{ this.entity_id }}",
"option": "{{ option }}",
},
},
],
"optimistic": True,
"unique_id": "a",
},
}
},
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
entry = entity_registry.async_get(_TEST_SELECT)
assert entry
assert entry.unique_id == "b-a"
attributes = {"options": ["a", "b"]}
await async_trigger(hass, TEST_STATE_ENTITY_ID, "a", attributes)
_verify(hass, "a", ["a", "b"])
await hass.services.async_call(
INPUT_SELECT_DOMAIN,
INPUT_SELECT_SERVICE_SELECT_OPTION,
{CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "b"},
blocking=True,
)
await hass.async_block_till_done()
await async_trigger(hass, TEST_STATE_ENTITY_ID, "b", attributes)
_verify(hass, "b", ["a", "b"])
await hass.services.async_call(
INPUT_SELECT_DOMAIN,
SERVICE_SET_OPTIONS,
{
CONF_ENTITY_ID: _OPTION_INPUT_SELECT,
INPUT_SELECT_ATTR_OPTIONS: ["a", "b", "c"],
},
blocking=True,
)
await hass.async_block_till_done()
attributes = {"options": ["a", "b", "c"]}
await async_trigger(hass, TEST_STATE_ENTITY_ID, "b", attributes)
_verify(hass, "b", ["a", "b", "c"])
await hass.services.async_call(
SELECT_DOMAIN,
SELECT_SERVICE_SELECT_OPTION,
{CONF_ENTITY_ID: _TEST_SELECT, SELECT_ATTR_OPTION: "c"},
{CONF_ENTITY_ID: TEST_SELECT.entity_id, SELECT_ATTR_OPTION: "c"},
blocking=True,
)
_verify(hass, "c", ["a", "b", "c"])
# Check this variable can be used in set_value script
assert len(calls) == 1
assert calls[-1].data["action"] == "select_option"
assert calls[-1].data["caller"] == _TEST_SELECT
assert calls[-1].data["caller"] == TEST_SELECT.entity_id
assert calls[-1].data["option"] == "c"
async def test_trigger_select(hass: HomeAssistant) -> None:
"""Test trigger based template select."""
events = async_capture_events(hass, "test_number_event")
action_events = async_capture_events(hass, "action_event")
assert await setup.async_setup_component(
hass,
"template",
{
"template": [
{"invalid": "config"},
# Config after invalid should still be set up
{
"unique_id": "listening-test-event",
"trigger": {"platform": "event", "event_type": "test_event"},
"variables": {"beer": "{{ trigger.event.data.beer }}"},
"action": [
{"event": "action_event", "event_data": {"beer": "{{ beer }}"}}
],
"select": [
{
"name": "Hello Name",
"unique_id": "hello_name-id",
"state": "{{ trigger.event.data.beer }}",
"options": "{{ trigger.event.data.beers }}",
"select_option": {
"event": "test_number_event",
"event_data": {
"entity_id": "{{ this.entity_id }}",
"beer": "{{ beer }}",
},
},
"optimistic": True,
},
],
},
],
},
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
state = hass.states.get("select.hello_name")
assert state is not None
assert state.state == STATE_UNKNOWN
context = Context()
hass.bus.async_fire(
"test_event", {"beer": "duff", "beers": ["duff", "alamo"]}, context=context
)
await hass.async_block_till_done()
state = hass.states.get("select.hello_name")
assert state is not None
assert state.state == "duff"
assert state.attributes["options"] == ["duff", "alamo"]
assert len(action_events) == 1
assert action_events[0].event_type == "action_event"
beer = action_events[0].data.get("beer")
assert beer is not None
assert beer == "duff"
await hass.services.async_call(
SELECT_DOMAIN,
SELECT_SERVICE_SELECT_OPTION,
{CONF_ENTITY_ID: "select.hello_name", SELECT_ATTR_OPTION: "alamo"},
blocking=True,
)
assert len(events) == 1
assert events[0].event_type == "test_number_event"
entity_id = events[0].data.get("entity_id")
assert entity_id is not None
assert entity_id == "select.hello_name"
beer = events[0].data.get("beer")
assert beer is not None
assert beer == "duff"
await async_trigger(hass, TEST_STATE_ENTITY_ID, "c", attributes)
_verify(hass, "c", ["a", "b", "c"])
def _verify(
hass: HomeAssistant,
expected_current_option: str,
expected_options: list[str],
entity_name: str = _TEST_SELECT,
entity_name: str = TEST_SELECT.entity_id,
) -> None:
"""Verify select's state."""
state = hass.states.get(entity_name)
@@ -441,7 +248,7 @@ def _verify(
[(ConfigurationStyle.MODERN, ""), (ConfigurationStyle.TRIGGER, None)],
)
@pytest.mark.parametrize(
("select_config", "attribute", "expected"),
("config", "attribute", "expected"),
[
(
{
@@ -469,13 +276,13 @@ async def test_templated_optional_config(
initial_expected_state: str | None,
) -> None:
"""Test optional config templates."""
state = hass.states.get(_TEST_SELECT)
state = hass.states.get(TEST_SELECT.entity_id)
assert state.attributes.get(attribute) == initial_expected_state
state = hass.states.async_set(TEST_STATE_ENTITY_ID, "yes")
await hass.async_block_till_done()
state = hass.states.get(_TEST_SELECT)
state = hass.states.get(TEST_SELECT.entity_id)
assert state.attributes[attribute] == expected
@@ -506,6 +313,7 @@ async def test_device_id(
"template_type": "select",
"state": "{{ 'on' }}",
"options": "{{ ['off', 'on', 'auto'] }}",
"select_option": [],
"device_id": device_entry.id,
},
title="My template",
@@ -521,7 +329,7 @@ async def test_device_id(
@pytest.mark.parametrize(
("count", "select_config"),
("count", "config"),
[
(
1,
@@ -540,21 +348,22 @@ async def test_device_id(
ConfigurationStyle.MODERN,
],
)
async def test_empty_action_config(hass: HomeAssistant, setup_select) -> None:
@pytest.mark.usefixtures("setup_select")
async def test_empty_action_config(hass: HomeAssistant) -> None:
"""Test configuration with empty script."""
await hass.services.async_call(
select.DOMAIN,
select.SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: _TEST_SELECT, "option": "a"},
{ATTR_ENTITY_ID: TEST_SELECT.entity_id, "option": "a"},
blocking=True,
)
state = hass.states.get(_TEST_SELECT)
state = hass.states.get(TEST_SELECT.entity_id)
assert state.state == "a"
@pytest.mark.parametrize(
("count", "select_config"),
("count", "config"),
[
(
1,
@@ -573,7 +382,7 @@ async def test_empty_action_config(hass: HomeAssistant, setup_select) -> None:
async def test_optimistic(hass: HomeAssistant) -> None:
"""Test configuration with optimistic state."""
state = hass.states.get(_TEST_SELECT)
state = hass.states.get(TEST_SELECT.entity_id)
assert state.state == STATE_UNKNOWN
# Ensure Trigger template entities update.
@@ -583,26 +392,26 @@ async def test_optimistic(hass: HomeAssistant) -> None:
await hass.services.async_call(
select.DOMAIN,
select.SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: _TEST_SELECT, "option": "test"},
{ATTR_ENTITY_ID: TEST_SELECT.entity_id, "option": "test"},
blocking=True,
)
state = hass.states.get(_TEST_SELECT)
state = hass.states.get(TEST_SELECT.entity_id)
assert state.state == "test"
await hass.services.async_call(
select.DOMAIN,
select.SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: _TEST_SELECT, "option": "yes"},
{ATTR_ENTITY_ID: TEST_SELECT.entity_id, "option": "yes"},
blocking=True,
)
state = hass.states.get(_TEST_SELECT)
state = hass.states.get(TEST_SELECT.entity_id)
assert state.state == "yes"
@pytest.mark.parametrize(
("count", "select_config"),
("count", "config"),
[
(
1,
@@ -629,16 +438,16 @@ async def test_not_optimistic(hass: HomeAssistant) -> None:
await hass.services.async_call(
select.DOMAIN,
select.SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: _TEST_SELECT, "option": "test"},
{ATTR_ENTITY_ID: TEST_SELECT.entity_id, "option": "test"},
blocking=True,
)
state = hass.states.get(_TEST_SELECT)
state = hass.states.get(TEST_SELECT.entity_id)
assert state.state == STATE_UNKNOWN
@pytest.mark.parametrize(
("count", "select_config"),
("count", "config"),
[
(
1,
@@ -662,25 +471,25 @@ async def test_availability(hass: HomeAssistant) -> None:
hass.states.async_set(TEST_STATE_ENTITY_ID, "test")
await hass.async_block_till_done()
state = hass.states.get(_TEST_SELECT)
state = hass.states.get(TEST_SELECT.entity_id)
assert state.state == "test"
hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "off")
await hass.async_block_till_done()
state = hass.states.get(_TEST_SELECT)
state = hass.states.get(TEST_SELECT.entity_id)
assert state.state == STATE_UNAVAILABLE
hass.states.async_set(TEST_STATE_ENTITY_ID, "yes")
await hass.async_block_till_done()
state = hass.states.get(_TEST_SELECT)
state = hass.states.get(TEST_SELECT.entity_id)
assert state.state == STATE_UNAVAILABLE
hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on")
await hass.async_block_till_done()
state = hass.states.get(_TEST_SELECT)
state = hass.states.get(TEST_SELECT.entity_id)
assert state.state == "yes"
@@ -698,3 +507,36 @@ async def test_flow_preview(
)
assert state["state"] == "test"
@pytest.mark.parametrize(
"style",
[ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
async def test_unique_id(
hass: HomeAssistant,
style: ConfigurationStyle,
) -> None:
"""Test unique_id option only creates one vacuum per id."""
await setup_and_test_unique_id(
hass, TEST_SELECT, style, TEST_OPTIONS_WITHOUT_STATE, "{{ 'test' }}"
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER]
)
async def test_nested_unique_id(
hass: HomeAssistant,
style: ConfigurationStyle,
entity_registry: er.EntityRegistry,
) -> None:
"""Test a template unique_id propagates to vacuum unique_ids."""
await setup_and_test_nested_unique_id(
hass,
TEST_SELECT,
style,
entity_registry,
TEST_OPTIONS_WITHOUT_STATE,
"{{ 'test' }}",
)

View File

@@ -213,7 +213,7 @@ async def test_agreement_already_set_up(
) -> None:
"""Test showing display form again if display already exists."""
await setup_component(hass)
MockConfigEntry(domain=DOMAIN, unique_id=123).add_to_hass(hass)
MockConfigEntry(domain=DOMAIN, unique_id="123").add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
@@ -312,7 +312,7 @@ async def test_import_migration(
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test if importing step with migration works."""
old_entry = MockConfigEntry(domain=DOMAIN, unique_id=123, version=1)
old_entry = MockConfigEntry(domain=DOMAIN, unique_id="123", version=1)
old_entry.add_to_hass(hass)
await setup_component(hass)

View File

@@ -40,3 +40,29 @@ async def test_oauth_implementation_not_available(
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_migrate_entry_minor_version_2_2(hass: HomeAssistant) -> None:
"""Test migrating a 2.1 config entry to 2.2."""
with patch("homeassistant.components.toon.async_setup_entry", return_value=True):
entry = MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": DOMAIN,
"token": {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
"agreement_id": 123,
},
version=2,
minor_version=1,
unique_id=123,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
assert entry.version == 2
assert entry.minor_version == 2
assert entry.unique_id == "123"

View File

@@ -721,3 +721,179 @@ async def test_binary_sensor_person_detected(
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
assert len(state_changes) == 2
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_binary_sensor_simultaneous_person_and_vehicle_detection(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Test that when an event is updated with additional detection types, both trigger.
This is a regression test for https://github.com/home-assistant/core/issues/152133
where an event starting with vehicle detection gets updated to also include person
detection (e.g., someone getting out of a car). Both sensors should be ON
simultaneously, not queued.
"""
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.BINARY_SENSOR, 15, 15)
doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PERSON)
doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.VEHICLE)
# Get entity IDs for both person and vehicle detection
_, person_entity_id = await ids_from_device_description(
hass,
Platform.BINARY_SENSOR,
doorbell,
EVENT_SENSORS[3], # person detected
)
_, vehicle_entity_id = await ids_from_device_description(
hass,
Platform.BINARY_SENSOR,
doorbell,
EVENT_SENSORS[4], # vehicle detected
)
# Step 1: Initial event with only VEHICLE detection (car arriving)
event = Event(
model=ModelType.EVENT,
id="combined_event_id",
type=EventType.SMART_DETECT,
start=fixed_now - timedelta(seconds=5),
end=None, # Event is ongoing
score=90,
smart_detect_types=[SmartDetectObjectType.VEHICLE],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
)
new_camera = doorbell.model_copy()
new_camera.is_smart_detected = True
new_camera.last_smart_detect_event_ids[SmartDetectObjectType.VEHICLE] = event.id
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {event.id: event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = event
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
# Vehicle sensor should be ON
vehicle_state = hass.states.get(vehicle_entity_id)
assert vehicle_state
assert vehicle_state.state == STATE_ON, "Vehicle detection should be ON"
# Person sensor should still be OFF (no person detected yet)
person_state = hass.states.get(person_entity_id)
assert person_state
assert person_state.state == STATE_OFF, "Person detection should be OFF initially"
# Step 2: Same event gets updated to include PERSON detection
# (someone gets out of the car - Protect adds PERSON to the same event)
#
# BUG SCENARIO: UniFi Protect updates the event to include PERSON in
# smart_detect_types, BUT does NOT update last_smart_detect_event_ids[PERSON]
# until the event ends. This is the core issue reported in #152133.
updated_event = Event(
model=ModelType.EVENT,
id="combined_event_id", # Same event ID!
type=EventType.SMART_DETECT,
start=fixed_now - timedelta(seconds=5),
end=None, # Event still ongoing
score=90,
smart_detect_types=[
SmartDetectObjectType.VEHICLE,
SmartDetectObjectType.PERSON,
],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
)
# IMPORTANT: The camera's last_smart_detect_event_ids is NOT updated for PERSON!
# This simulates the real bug where UniFi Protect doesn't immediately update
# the camera's last_smart_detect_event_ids when a new detection type is added
# to an ongoing event.
new_camera = doorbell.model_copy()
new_camera.is_smart_detected = True
# Only VEHICLE has the event ID - PERSON does not (simulating the bug)
new_camera.last_smart_detect_event_ids[SmartDetectObjectType.VEHICLE] = (
updated_event.id
)
# NOTE: We're NOT setting last_smart_detect_event_ids[PERSON] to simulate the bug!
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
ufp.api.bootstrap.events = {updated_event.id: updated_event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = updated_event
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
# CRITICAL: Both sensors should now be ON simultaneously
vehicle_state = hass.states.get(vehicle_entity_id)
assert vehicle_state
assert vehicle_state.state == STATE_ON, (
"Vehicle detection should still be ON after event update"
)
person_state = hass.states.get(person_entity_id)
assert person_state
assert person_state.state == STATE_ON, (
"Person detection should be ON immediately when added to event, "
"not waiting for vehicle detection to end"
)
# Verify both have correct attributes
assert vehicle_state.attributes[ATTR_EVENT_SCORE] == 90
assert person_state.attributes[ATTR_EVENT_SCORE] == 90
# Step 3: Event ends - both sensors should turn OFF
ended_event = Event(
model=ModelType.EVENT,
id="combined_event_id",
type=EventType.SMART_DETECT,
start=fixed_now - timedelta(seconds=5),
end=fixed_now, # Event ended now
score=90,
smart_detect_types=[
SmartDetectObjectType.VEHICLE,
SmartDetectObjectType.PERSON,
],
smart_detect_event_ids=[],
camera_id=doorbell.id,
api=ufp.api,
)
ufp.api.bootstrap.events = {ended_event.id: ended_event}
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = ended_event
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
# Both should be OFF now
vehicle_state = hass.states.get(vehicle_entity_id)
assert vehicle_state
assert vehicle_state.state == STATE_OFF, (
"Vehicle detection should be OFF after event ends"
)
person_state = hass.states.get(person_entity_id)
assert person_state
assert person_state.state == STATE_OFF, (
"Person detection should be OFF after event ends"
)

View File

@@ -22,6 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client as client
from homeassistant.util import ssl as ssl_util
from homeassistant.util.color import RGBColor
from homeassistant.util.ssl import SSLCipherList
@@ -413,3 +414,29 @@ async def test_resolver_is_singleton(hass: HomeAssistant) -> None:
assert isinstance(session3._connector, aiohttp.TCPConnector)
assert session._connector._resolver is session2._connector._resolver
assert session._connector._resolver is session3._connector._resolver
async def test_connector_uses_http11_alpn(hass: HomeAssistant) -> None:
"""Test that connector uses HTTP/1.1 ALPN protocols."""
with patch.object(
ssl_util, "client_context", wraps=ssl_util.client_context
) as mock_client_context:
client.async_get_clientsession(hass)
# Verify client_context was called with HTTP/1.1 ALPN
mock_client_context.assert_called_once_with(
SSLCipherList.PYTHON_DEFAULT, ssl_util.SSL_ALPN_HTTP11
)
async def test_connector_no_verify_uses_http11_alpn(hass: HomeAssistant) -> None:
"""Test that connector without SSL verification uses HTTP/1.1 ALPN protocols."""
with patch.object(
ssl_util, "client_context_no_verify", wraps=ssl_util.client_context_no_verify
) as mock_client_context_no_verify:
client.async_get_clientsession(hass, verify_ssl=False)
# Verify client_context_no_verify was called with HTTP/1.1 ALPN
mock_client_context_no_verify.assert_called_once_with(
SSLCipherList.PYTHON_DEFAULT, ssl_util.SSL_ALPN_HTTP11
)

View File

@@ -8,6 +8,7 @@ import pytest
from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import httpx_client as client
from homeassistant.util.ssl import SSL_ALPN_HTTP11, SSL_ALPN_HTTP11_HTTP2
from tests.common import MockModule, extract_stack_to_frame, mock_integration
@@ -16,14 +17,20 @@ async def test_get_async_client_with_ssl(hass: HomeAssistant) -> None:
"""Test init async client with ssl."""
client.get_async_client(hass)
assert isinstance(hass.data[client.DATA_ASYNC_CLIENT], httpx.AsyncClient)
assert isinstance(
hass.data[client.DATA_ASYNC_CLIENT][(True, SSL_ALPN_HTTP11)],
httpx.AsyncClient,
)
async def test_get_async_client_without_ssl(hass: HomeAssistant) -> None:
"""Test init async client without ssl."""
client.get_async_client(hass, verify_ssl=False)
assert isinstance(hass.data[client.DATA_ASYNC_CLIENT_NOVERIFY], httpx.AsyncClient)
assert isinstance(
hass.data[client.DATA_ASYNC_CLIENT][(False, SSL_ALPN_HTTP11)],
httpx.AsyncClient,
)
async def test_create_async_httpx_client_with_ssl_and_cookies(
@@ -34,7 +41,7 @@ async def test_create_async_httpx_client_with_ssl_and_cookies(
httpx_client = client.create_async_httpx_client(hass, cookies={"bla": True})
assert isinstance(httpx_client, httpx.AsyncClient)
assert hass.data[client.DATA_ASYNC_CLIENT] != httpx_client
assert hass.data[client.DATA_ASYNC_CLIENT][(True, SSL_ALPN_HTTP11)] != httpx_client
async def test_create_async_httpx_client_without_ssl_and_cookies(
@@ -47,31 +54,37 @@ async def test_create_async_httpx_client_without_ssl_and_cookies(
hass, verify_ssl=False, cookies={"bla": True}
)
assert isinstance(httpx_client, httpx.AsyncClient)
assert hass.data[client.DATA_ASYNC_CLIENT_NOVERIFY] != httpx_client
assert hass.data[client.DATA_ASYNC_CLIENT][(False, SSL_ALPN_HTTP11)] != httpx_client
async def test_get_async_client_cleanup(hass: HomeAssistant) -> None:
"""Test init async client with ssl."""
client.get_async_client(hass)
assert isinstance(hass.data[client.DATA_ASYNC_CLIENT], httpx.AsyncClient)
assert isinstance(
hass.data[client.DATA_ASYNC_CLIENT][(True, SSL_ALPN_HTTP11)],
httpx.AsyncClient,
)
hass.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE)
await hass.async_block_till_done()
assert hass.data[client.DATA_ASYNC_CLIENT].is_closed
assert hass.data[client.DATA_ASYNC_CLIENT][(True, SSL_ALPN_HTTP11)].is_closed
async def test_get_async_client_cleanup_without_ssl(hass: HomeAssistant) -> None:
"""Test init async client without ssl."""
client.get_async_client(hass, verify_ssl=False)
assert isinstance(hass.data[client.DATA_ASYNC_CLIENT_NOVERIFY], httpx.AsyncClient)
assert isinstance(
hass.data[client.DATA_ASYNC_CLIENT][(False, SSL_ALPN_HTTP11)],
httpx.AsyncClient,
)
hass.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE)
await hass.async_block_till_done()
assert hass.data[client.DATA_ASYNC_CLIENT_NOVERIFY].is_closed
assert hass.data[client.DATA_ASYNC_CLIENT][(False, SSL_ALPN_HTTP11)].is_closed
async def test_get_async_client_patched_close(hass: HomeAssistant) -> None:
@@ -79,7 +92,10 @@ async def test_get_async_client_patched_close(hass: HomeAssistant) -> None:
with patch("httpx.AsyncClient.aclose") as mock_aclose:
httpx_session = client.get_async_client(hass)
assert isinstance(hass.data[client.DATA_ASYNC_CLIENT], httpx.AsyncClient)
assert isinstance(
hass.data[client.DATA_ASYNC_CLIENT][(True, SSL_ALPN_HTTP11)],
httpx.AsyncClient,
)
with pytest.raises(RuntimeError):
await httpx_session.aclose()
@@ -92,7 +108,10 @@ async def test_get_async_client_context_manager(hass: HomeAssistant) -> None:
with patch("httpx.AsyncClient.aclose") as mock_aclose:
httpx_session = client.get_async_client(hass)
assert isinstance(hass.data[client.DATA_ASYNC_CLIENT], httpx.AsyncClient)
assert isinstance(
hass.data[client.DATA_ASYNC_CLIENT][(True, SSL_ALPN_HTTP11)],
httpx.AsyncClient,
)
async with httpx_session:
pass
@@ -100,6 +119,80 @@ async def test_get_async_client_context_manager(hass: HomeAssistant) -> None:
assert mock_aclose.call_count == 0
async def test_get_async_client_http2(hass: HomeAssistant) -> None:
"""Test init async client with HTTP/2 support."""
http1_client = client.get_async_client(hass)
http2_client = client.get_async_client(hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2)
# HTTP/1.1 and HTTP/2 clients should be different (different SSL contexts)
assert http1_client is not http2_client
assert isinstance(
hass.data[client.DATA_ASYNC_CLIENT][(True, SSL_ALPN_HTTP11)],
httpx.AsyncClient,
)
assert isinstance(
hass.data[client.DATA_ASYNC_CLIENT][(True, SSL_ALPN_HTTP11_HTTP2)],
httpx.AsyncClient,
)
# Same parameters should return cached client
assert client.get_async_client(hass) is http1_client
assert (
client.get_async_client(hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2)
is http2_client
)
async def test_get_async_client_http2_cleanup(hass: HomeAssistant) -> None:
"""Test cleanup of HTTP/2 async client."""
client.get_async_client(hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2)
assert isinstance(
hass.data[client.DATA_ASYNC_CLIENT][(True, SSL_ALPN_HTTP11_HTTP2)],
httpx.AsyncClient,
)
hass.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE)
await hass.async_block_till_done()
assert hass.data[client.DATA_ASYNC_CLIENT][(True, SSL_ALPN_HTTP11_HTTP2)].is_closed
async def test_get_async_client_http2_without_ssl(hass: HomeAssistant) -> None:
"""Test init async client with HTTP/2 and without SSL."""
http2_client = client.get_async_client(
hass, verify_ssl=False, alpn_protocols=SSL_ALPN_HTTP11_HTTP2
)
assert isinstance(
hass.data[client.DATA_ASYNC_CLIENT][(False, SSL_ALPN_HTTP11_HTTP2)],
httpx.AsyncClient,
)
# Same parameters should return cached client
assert (
client.get_async_client(
hass, verify_ssl=False, alpn_protocols=SSL_ALPN_HTTP11_HTTP2
)
is http2_client
)
async def test_create_async_httpx_client_http2(hass: HomeAssistant) -> None:
"""Test create async client with HTTP/2 uses correct ALPN protocols."""
http1_client = client.create_async_httpx_client(hass)
http2_client = client.create_async_httpx_client(
hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2
)
# Different clients (not cached)
assert http1_client is not http2_client
# Both should be valid clients
assert isinstance(http1_client, httpx.AsyncClient)
assert isinstance(http2_client, httpx.AsyncClient)
async def test_warning_close_session_integration(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:

View File

@@ -1,78 +1,58 @@
"""Test Home Assistant ssl utility functions."""
from unittest.mock import MagicMock, Mock, patch
import pytest
from homeassistant.util.ssl import (
SSL_ALPN_HTTP11,
SSL_ALPN_HTTP11_HTTP2,
SSL_ALPN_NONE,
SSLCipherList,
client_context,
client_context_no_verify,
create_client_context,
create_no_verify_ssl_context,
get_default_context,
get_default_no_verify_context,
)
@pytest.fixture
def mock_sslcontext():
"""Mock the ssl lib."""
return MagicMock(set_ciphers=Mock(return_value=True))
def test_client_context(mock_sslcontext) -> None:
"""Test client context."""
with patch("homeassistant.util.ssl.ssl.SSLContext", return_value=mock_sslcontext):
client_context()
mock_sslcontext.set_ciphers.assert_not_called()
client_context(SSLCipherList.MODERN)
mock_sslcontext.set_ciphers.assert_not_called()
client_context(SSLCipherList.INTERMEDIATE)
mock_sslcontext.set_ciphers.assert_not_called()
client_context(SSLCipherList.INSECURE)
mock_sslcontext.set_ciphers.assert_not_called()
def test_no_verify_ssl_context(mock_sslcontext) -> None:
"""Test no verify ssl context."""
with patch("homeassistant.util.ssl.ssl.SSLContext", return_value=mock_sslcontext):
create_no_verify_ssl_context()
mock_sslcontext.set_ciphers.assert_not_called()
create_no_verify_ssl_context(SSLCipherList.MODERN)
mock_sslcontext.set_ciphers.assert_not_called()
create_no_verify_ssl_context(SSLCipherList.INTERMEDIATE)
mock_sslcontext.set_ciphers.assert_not_called()
create_no_verify_ssl_context(SSLCipherList.INSECURE)
mock_sslcontext.set_ciphers.assert_not_called()
def test_ssl_context_caching() -> None:
"""Test that SSLContext instances are cached correctly."""
assert client_context() is client_context(SSLCipherList.PYTHON_DEFAULT)
assert create_no_verify_ssl_context() is create_no_verify_ssl_context(
SSLCipherList.PYTHON_DEFAULT
)
def test_create_client_context(mock_sslcontext) -> None:
"""Test create client context."""
with patch("homeassistant.util.ssl.ssl.SSLContext", return_value=mock_sslcontext):
client_context()
mock_sslcontext.set_ciphers.assert_not_called()
def test_ssl_context_cipher_bucketing() -> None:
"""Test that SSL contexts are bucketed by cipher list."""
default_ctx = client_context(SSLCipherList.PYTHON_DEFAULT)
modern_ctx = client_context(SSLCipherList.MODERN)
intermediate_ctx = client_context(SSLCipherList.INTERMEDIATE)
insecure_ctx = client_context(SSLCipherList.INSECURE)
client_context(SSLCipherList.MODERN)
mock_sslcontext.set_ciphers.assert_not_called()
# Different cipher lists should return different contexts
assert default_ctx is not modern_ctx
assert default_ctx is not intermediate_ctx
assert default_ctx is not insecure_ctx
assert modern_ctx is not intermediate_ctx
assert modern_ctx is not insecure_ctx
assert intermediate_ctx is not insecure_ctx
client_context(SSLCipherList.INTERMEDIATE)
mock_sslcontext.set_ciphers.assert_not_called()
# Same parameters should return cached context
assert client_context(SSLCipherList.PYTHON_DEFAULT) is default_ctx
assert client_context(SSLCipherList.MODERN) is modern_ctx
client_context(SSLCipherList.INSECURE)
mock_sslcontext.set_ciphers.assert_not_called()
def test_no_verify_ssl_context_cipher_bucketing() -> None:
"""Test that no-verify SSL contexts are bucketed by cipher list."""
default_ctx = create_no_verify_ssl_context(SSLCipherList.PYTHON_DEFAULT)
modern_ctx = create_no_verify_ssl_context(SSLCipherList.MODERN)
# Different cipher lists should return different contexts
assert default_ctx is not modern_ctx
# Same parameters should return cached context
assert create_no_verify_ssl_context(SSLCipherList.PYTHON_DEFAULT) is default_ctx
assert create_no_verify_ssl_context(SSLCipherList.MODERN) is modern_ctx
def test_create_client_context_independent() -> None:
@@ -82,3 +62,129 @@ def test_create_client_context_independent() -> None:
independent_context_2 = create_client_context()
assert shared_context is not independent_context_1
assert independent_context_1 is not independent_context_2
def test_ssl_context_alpn_bucketing() -> None:
"""Test that SSL contexts are bucketed by ALPN protocols.
Different ALPN protocol configurations should return different cached contexts
to prevent downstream libraries (e.g., httpx/httpcore) from mutating shared
contexts with incompatible settings.
"""
# HTTP/1.1, HTTP/2, and no-ALPN contexts should all be different
http1_context = client_context(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11)
http2_context = client_context(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11_HTTP2)
no_alpn_context = client_context(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_NONE)
assert http1_context is not http2_context
assert http1_context is not no_alpn_context
assert http2_context is not no_alpn_context
# Same parameters should return cached context
assert (
client_context(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11) is http1_context
)
assert (
client_context(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11_HTTP2)
is http2_context
)
assert (
client_context(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_NONE) is no_alpn_context
)
# No-verify contexts should also be bucketed by ALPN
http1_no_verify = client_context_no_verify(
SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11
)
http2_no_verify = client_context_no_verify(
SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11_HTTP2
)
no_alpn_no_verify = client_context_no_verify(
SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_NONE
)
assert http1_no_verify is not http2_no_verify
assert http1_no_verify is not no_alpn_no_verify
assert http2_no_verify is not no_alpn_no_verify
# create_no_verify_ssl_context should also work with ALPN
assert (
create_no_verify_ssl_context(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11)
is http1_no_verify
)
assert (
create_no_verify_ssl_context(
SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11_HTTP2
)
is http2_no_verify
)
assert (
create_no_verify_ssl_context(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_NONE)
is no_alpn_no_verify
)
def test_ssl_context_insecure_alpn_bucketing() -> None:
"""Test that INSECURE cipher list SSL contexts are bucketed by ALPN protocols.
INSECURE cipher list is used by some integrations that need to connect to
devices with outdated TLS implementations.
"""
# HTTP/1.1, HTTP/2, and no-ALPN contexts should all be different
http1_context = client_context(SSLCipherList.INSECURE, SSL_ALPN_HTTP11)
http2_context = client_context(SSLCipherList.INSECURE, SSL_ALPN_HTTP11_HTTP2)
no_alpn_context = client_context(SSLCipherList.INSECURE, SSL_ALPN_NONE)
assert http1_context is not http2_context
assert http1_context is not no_alpn_context
assert http2_context is not no_alpn_context
# Same parameters should return cached context
assert client_context(SSLCipherList.INSECURE, SSL_ALPN_HTTP11) is http1_context
assert (
client_context(SSLCipherList.INSECURE, SSL_ALPN_HTTP11_HTTP2) is http2_context
)
assert client_context(SSLCipherList.INSECURE, SSL_ALPN_NONE) is no_alpn_context
# No-verify contexts should also be bucketed by ALPN
http1_no_verify = client_context_no_verify(SSLCipherList.INSECURE, SSL_ALPN_HTTP11)
http2_no_verify = client_context_no_verify(
SSLCipherList.INSECURE, SSL_ALPN_HTTP11_HTTP2
)
no_alpn_no_verify = client_context_no_verify(SSLCipherList.INSECURE, SSL_ALPN_NONE)
assert http1_no_verify is not http2_no_verify
assert http1_no_verify is not no_alpn_no_verify
assert http2_no_verify is not no_alpn_no_verify
# create_no_verify_ssl_context should also work with ALPN
assert (
create_no_verify_ssl_context(SSLCipherList.INSECURE, SSL_ALPN_HTTP11)
is http1_no_verify
)
assert (
create_no_verify_ssl_context(SSLCipherList.INSECURE, SSL_ALPN_HTTP11_HTTP2)
is http2_no_verify
)
assert (
create_no_verify_ssl_context(SSLCipherList.INSECURE, SSL_ALPN_NONE)
is no_alpn_no_verify
)
def test_get_default_context_uses_http1_alpn() -> None:
"""Test that get_default_context returns context with HTTP1 ALPN."""
default_ctx = get_default_context()
default_no_verify_ctx = get_default_no_verify_context()
# Default contexts should be the same as explicitly requesting HTTP1 ALPN
assert default_ctx is client_context(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11)
assert default_no_verify_ctx is client_context_no_verify(
SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11
)
def test_client_context_default_no_alpn() -> None:
"""Test that client_context defaults to no ALPN for backward compatibility."""
# Default (no ALPN) should be different from HTTP1 ALPN
default_ctx = client_context()
http1_ctx = client_context(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_HTTP11)
assert default_ctx is not http1_ctx
assert default_ctx is client_context(SSLCipherList.PYTHON_DEFAULT, SSL_ALPN_NONE)