Compare commits

..

1 Commits

Author SHA1 Message Date
Erik 4f202c10b6 Avoid flooding the recorder when priming condition history 2026-06-16 15:24:04 +02:00
44 changed files with 294 additions and 774 deletions
+2 -2
View File
@@ -193,7 +193,7 @@ jobs:
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
- name: Build base image
uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
with:
arch: ${{ matrix.arch }}
build-args: |
@@ -264,7 +264,7 @@ jobs:
fi
- name: Build machine image
uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
with:
arch: ${{ matrix.arch }}
build-args: |
+1 -1
View File
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairq"],
"requirements": ["aioairq==0.4.8"],
"requirements": ["aioairq==0.4.7"],
"zeroconf": [
{
"properties": {
@@ -12,7 +12,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_MAC, CONF_MODEL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -87,12 +87,9 @@ class AnthemAVR(MediaPlayerEntity):
via_device=(DOMAIN, mac_address),
)
else:
# Zone 1 is the physical receiver that owns the network MAC; higher
# zones are via_device children and carry no connection.
self._attr_unique_id = mac_address
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac_address)},
connections={(CONNECTION_NETWORK_MAC, mac_address)},
name=name,
manufacturer=MANUFACTURER,
model=model,
@@ -52,7 +52,10 @@ rules:
status: exempt
comment: |
Service integration, no discovery.
docs-data-update: done
docs-data-update:
status: exempt
comment: |
No data updates.
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
@@ -193,7 +193,6 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
device_info = DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
connections={(dr.CONNECTION_NETWORK_MAC, data[Attribute.MAC_ADDRESS])},
name=self.create_device_name(data),
manufacturer="Aprilaire",
)
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/aten_pe",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["atenpdu==0.3.6"]
"requirements": ["atenpdu==0.3.2"]
}
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["webexpythonsdk"],
"quality_scale": "legacy",
"requirements": ["webexpythonsdk==2.0.6"]
"requirements": ["webexpythonsdk==2.0.1"]
}
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["doorbirdpy"],
"requirements": ["DoorBirdPy==3.0.12"],
"requirements": ["DoorBirdPy==3.0.11"],
"zeroconf": [
{
"properties": {
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pdunehd"],
"requirements": ["pdunehd==1.3.3"]
"requirements": ["pdunehd==1.3.2"]
}
@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["pyhomematic"],
"quality_scale": "legacy",
"requirements": ["pyhomematic==0.1.78"]
"requirements": ["pyhomematic==0.1.77"]
}
@@ -6,7 +6,7 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -41,7 +41,6 @@ class IAlarmPanel(
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.mac)},
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)},
manufacturer="Antifurto365 - Meian",
name="iAlarm",
)
@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/influxdb",
"iot_class": "local_push",
"loggers": ["influxdb", "influxdb_client"],
"requirements": ["influxdb==5.3.2", "influxdb-client==1.50.0"],
"requirements": ["influxdb==5.3.1", "influxdb-client==1.50.0"],
"single_config_entry": true
}
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["messagebird"],
"quality_scale": "legacy",
"requirements": ["messagebird==1.2.1"]
"requirements": ["messagebird==1.2.0"]
}
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["config", "omnilogic"],
"requirements": ["omnilogic==0.4.9"],
"requirements": ["omnilogic==0.4.5"],
"single_config_entry": true
}
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["hole"],
"requirements": ["hole==0.9.2"]
"requirements": ["hole==0.9.0"]
}
@@ -352,8 +352,6 @@ class PS4Device(MediaPlayerEntity):
for device in d_registry.devices.get_devices_for_config_entry_id(
self._entry_id
):
# Rebuilt from the existing device entry, which already carries
# the network MAC connection added by the live-status branch.
self._attr_device_info = DeviceInfo(
identifiers=device.identifiers,
manufacturer=device.manufacturer,
@@ -367,9 +365,7 @@ class PS4Device(MediaPlayerEntity):
_sw_version = status["system-version"]
_sw_version = _sw_version[1:4]
sw_version = f"{_sw_version[0]}.{_sw_version[1:]}"
# status["host-id"] is the console's network MAC address.
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, status["host-id"])},
identifiers={(DOMAIN, status["host-id"])},
manufacturer="Sony Interactive Entertainment Inc.",
model="PlayStation 4",
+1 -2
View File
@@ -6,7 +6,7 @@ from typing import Any
from rabbitair import Model
from homeassistant.const import CONF_MAC
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -36,7 +36,6 @@ class RabbitAirBaseEntity(CoordinatorEntity[RabbitAirDataUpdateCoordinator]):
self._attr_unique_id = entry.unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.data[CONF_MAC])},
connections={(CONNECTION_NETWORK_MAC, entry.data[CONF_MAC])},
manufacturer="Rabbit Air",
model=MODELS.get(coordinator.data.model),
name=entry.title,
@@ -13,10 +13,9 @@ from pyrainbird.async_client import (
)
from pyrainbird.data import ModelAndVersion, Schedule
from homeassistant.const import CONF_MAC
from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS
@@ -105,18 +104,13 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
"""Return information about the device."""
if self._unique_id is None:
return None
device_info = DeviceInfo(
return DeviceInfo(
name=self.device_name,
identifiers={(DOMAIN, self._unique_id)},
manufacturer=MANUFACTURER,
model=self._model_info.model_name,
sw_version=f"{self._model_info.major}.{self._model_info.minor}",
)
# The unique id is the formatted MAC for current config entries, but was
# historically the serial number, so derive the connection from the MAC.
if mac_address := self.config_entry.data.get(CONF_MAC):
device_info["connections"] = {(CONNECTION_NETWORK_MAC, mac_address)}
return device_info
async def _async_update_data(self) -> RainbirdDeviceState:
"""Fetch data from Rain Bird device."""
@@ -25,7 +25,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["paho_mqtt", "roombapy"],
"requirements": ["roombapy==1.9.1"],
"requirements": ["roombapy==1.9.0"],
"zeroconf": [
{
"name": "irobot-*",
@@ -6,5 +6,5 @@
"iot_class": "assumed_state",
"loggers": ["tellcore"],
"quality_scale": "legacy",
"requirements": ["tellcore-net==0.4", "tellcore-py==1.1.3"]
"requirements": ["tellcore-net==0.4", "tellcore-py==1.1.2"]
}
@@ -160,11 +160,7 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN):
data=merged_input,
)
name = (
discovery_info.get("name")
or discovery_info.get("hostname")
or discovery_info.get("product_name")
)
name = discovery_info.get("hostname") or discovery_info.get("platform")
if not name:
short_mac = discovery_info["hw_addr"].replace(":", "").upper()[-6:]
name = f"Access {short_mac}"
@@ -11,7 +11,6 @@
"protect_api_key": "This API key is associated with UniFi Protect, not UniFi Access. Please generate a new API key from the UniFi Access application settings.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{name} ({ip_address})",
"step": {
"discovery_confirm": {
"data": {
@@ -1,33 +0,0 @@
"""Diagnostics support for the Yoto integration."""
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import YotoConfigEntry
TO_REDACT = {
"access_token",
"refresh_token",
"mac",
"network_ssid",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: YotoConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
"players": async_redact_data(
{
player_id: asdict(player)
for player_id, player in coordinator.data.items()
},
TO_REDACT,
),
}
+1 -1
View File
@@ -10,5 +10,5 @@
"iot_class": "cloud_push",
"loggers": ["yoto_api"],
"quality_scale": "bronze",
"requirements": ["yoto-api==4.3.0"]
"requirements": ["yoto-api==4.2.1"]
}
@@ -45,7 +45,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: The integration supports local DHCP discovery (via hostname pattern), but does not implement a separate discovery update handling flow.
+104 -21
View File
@@ -115,6 +115,9 @@ from .trace import (
)
from .typing import UNDEFINED, ConfigType, TemplateVarsType, UndefinedType
if TYPE_CHECKING:
from homeassistant.components.recorder import Recorder
ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config"
FROM_CONFIG_FORMAT = "{}_from_config"
VALIDATE_CONFIG_FORMAT = "{}_validate_config"
@@ -201,6 +204,7 @@ async def async_setup(hass: HomeAssistant) -> None:
hass.data[CONDITION_DISABLED_CONDITIONS] = set()
hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS] = []
hass.data[CONDITIONS] = {}
hass.data[_DATA_HISTORY_PRIMING_MANAGER] = _HistoryPrimingManager(hass)
async def new_triggers_conditions_listener(
_event_data: labs.EventLabsUpdatedData,
@@ -469,6 +473,79 @@ ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL = vol.Schema(
)
_DATA_HISTORY_PRIMING_MANAGER: HassKey[_HistoryPrimingManager] = HassKey(
"condition_history_priming_manager"
)
class _HistoryPrimingManager:
"""Serialize and coalesce the recorder reads that prime condition durations.
At startup many conditions may prime at once. Letting each hit the recorder
independently would force a separate commit per condition and run every read
on the shared DB executor in parallel — a flood. So the reads run one at a
time, and a single commit flush is shared by each "generation" of conditions
that arrive while the previous flush is running.
The flush a condition relies on must begin after that condition started
tracking its entities, or the read could miss a change still queued in the
recorder and compute too generous an anchor. A condition therefore never
rides a flush that was already running when it arrived (the lobby); it waits
that one out and joins the next.
"""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the manager."""
self._hass = hass
self._flush_condition = asyncio.Condition()
self._flushing = False
self._query_lock = asyncio.Lock()
async def async_prime[_T](
self, job: Callable[[Recorder], Coroutine[Any, Any, _T]]
) -> _T:
"""Flush the recorder, then run `job`, coordinated with other primings."""
await self._async_flush()
async with self._query_lock:
return await job(get_instance(self._hass))
async def _async_flush(self) -> None:
"""Return once a recorder flush that began no earlier than this call ends.
The first condition of a generation performs the flush; the rest ride it.
"""
async with self._flush_condition:
# Lobby: a flush already running began before we arrived, so it may
# not capture our entity's queued changes. Wait it out, don't ride it.
if self._flushing:
await self._flush_condition.wait()
do_flush = False
while True:
async with self._flush_condition:
if not self._flushing:
# First past the lobby this generation: we run the flush.
self._flushing = True
do_flush = True
break
# A peer began a fresh flush after we cleared the lobby; it
# covers us too, so wait for it and ride it.
await self._flush_condition.wait()
break
if not do_flush:
return
instance = get_instance(self._hass)
try:
if (commit_future := instance.async_get_commit_future()) is not None:
await commit_future
finally:
async with self._flush_condition:
self._flushing = False
self._flush_condition.notify_all()
class EntityConditionBase(Condition):
"""Base class for entity conditions."""
@@ -668,28 +745,34 @@ class EntityConditionBase(Condition):
assert self._duration is not None
lookback = min(self._duration, MAX_HISTORY_PRIMING_LOOKBACK)
start_time = dt_util.utcnow() - lookback
instance = get_instance(self._hass)
try:
async with asyncio.timeout(HISTORY_PRIMING_TIMEOUT):
# The history query only sees committed rows. Wait for the
# recorder to flush its queue first.
if (commit_future := instance.async_get_commit_future()) is not None:
await commit_future
historical_states = await instance.async_add_executor_job(
ft.partial(
history.get_significant_states,
self._hass,
start_time,
entity_ids=list(anchors),
include_start_time_state=True,
# Mandatory: the default (True) drops attribute-only
# changes for entities outside SIGNIFICANT_DOMAINS, which
# are exactly the transitions attribute-based conditions
# depend on.
significant_changes_only=False,
minimal_response=False,
)
async def _read_history(
instance: Recorder,
) -> dict[str, list[State | dict[str, Any]]]:
# The history query only sees committed rows; the priming manager
# flushes the recorder queue before running this.
return await instance.async_add_executor_job(
ft.partial(
history.get_significant_states,
self._hass,
start_time,
entity_ids=list(anchors),
include_start_time_state=True,
# Mandatory: the default (True) drops attribute-only changes
# for entities outside SIGNIFICANT_DOMAINS, which are exactly
# the transitions attribute-based conditions depend on.
significant_changes_only=False,
minimal_response=False,
)
)
manager = self._hass.data[_DATA_HISTORY_PRIMING_MANAGER]
try:
# The timeout also covers waiting for our turn, so under a flood of
# primings a condition falls back to its conservative anchor rather
# than blocking on the queue indefinitely.
async with asyncio.timeout(HISTORY_PRIMING_TIMEOUT):
historical_states = await manager.async_prime(_read_history)
except (SQLAlchemyError, TimeoutError) as err:
# Best effort: keep the conservative anchors rather than failing.
_LOGGER.debug("Error priming condition durations from history: %s", err)
+13 -13
View File
@@ -13,7 +13,7 @@ AIOSomecomfort==0.0.35
Adax-local==0.3.0
# homeassistant.components.doorbird
DoorBirdPy==3.0.12
DoorBirdPy==3.0.11
# homeassistant.components.homekit
HAP-python==5.0.0
@@ -181,7 +181,7 @@ aio-ownet==0.0.5
aioacaia==0.1.18
# homeassistant.components.airq
aioairq==0.4.8
aioairq==0.4.7
# homeassistant.components.airzone_cloud
aioairzone-cloud==0.7.2
@@ -584,7 +584,7 @@ asyncsleepiq==1.7.1
asyncssh==2.21.0
# homeassistant.components.aten_pe
# atenpdu==0.3.6
# atenpdu==0.3.2
# homeassistant.components.aurora
auroranoaa==0.0.5
@@ -1268,7 +1268,7 @@ hko==0.3.2
hlk-sw16==0.0.9
# homeassistant.components.pi_hole
hole==0.9.2
hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
@@ -1365,7 +1365,7 @@ indevolt-api==1.8.5
influxdb-client==1.50.0
# homeassistant.components.influxdb
influxdb==5.3.2
influxdb==5.3.1
# homeassistant.components.infrared
infrared-protocols==6.0.1
@@ -1559,7 +1559,7 @@ medcom-ble==0.1.1
melnor-bluetooth==0.0.25
# homeassistant.components.message_bird
messagebird==1.2.1
messagebird==1.2.0
# homeassistant.components.meteo_lt
meteo-lt-pkg==0.2.4
@@ -1739,7 +1739,7 @@ ohme==1.9.1
ollama==0.6.2
# homeassistant.components.omnilogic
omnilogic==0.4.9
omnilogic==0.4.5
# homeassistant.components.ondilo_ico
ondilo==0.5.0
@@ -1824,7 +1824,7 @@ panacotta==0.2
panasonic-viera==0.4.4
# homeassistant.components.dunehd
pdunehd==1.3.3
pdunehd==1.3.2
# homeassistant.components.peblar
peblar==0.5.1
@@ -2229,7 +2229,7 @@ pyheos==1.0.6
pyhive-integration==1.0.9
# homeassistant.components.homematic
pyhomematic==0.1.78
pyhomematic==0.1.77
# homeassistant.components.homeworks
pyhomeworks==1.1.2
@@ -2932,7 +2932,7 @@ rokuecp==0.19.5
romy==0.0.10
# homeassistant.components.roomba
roombapy==1.9.1
roombapy==1.9.0
# homeassistant.components.roon
roonapi==0.1.6
@@ -3142,7 +3142,7 @@ tapsaff==0.2.1
tellcore-net==0.4
# homeassistant.components.tellstick
tellcore-py==1.1.3
tellcore-py==1.1.2
# homeassistant.components.tellduslive
tellduslive==0.10.12
@@ -3360,7 +3360,7 @@ watergate-local-api==2025.1.0
weatherflow4py==1.5.4
# homeassistant.components.cisco_webex_teams
webexpythonsdk==2.0.6
webexpythonsdk==2.0.1
# homeassistant.components.nasweb
webio-api==0.1.12
@@ -3439,7 +3439,7 @@ yeelightsunflower==0.0.10
yolink-api==0.6.5
# homeassistant.components.yoto
yoto-api==4.3.0
yoto-api==4.2.1
# homeassistant.components.youless
youless-api==2.2.0
@@ -1,36 +0,0 @@
# serializer version: 1
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'00:00:00:00:00:01',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'anthemav',
'00:00:00:00:00:01',
),
}),
'labels': set({
}),
'manufacturer': 'Anthem',
'model': 'MRX 520',
'model_id': None,
'name': 'Anthem AV',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
-16
View File
@@ -5,13 +5,10 @@ from unittest.mock import ANY, AsyncMock, patch
from anthemav.device_error import DeviceError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.anthemav.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
@@ -37,19 +34,6 @@ async def test_load_unload_config_entry(
mock_anthemav.close.assert_called_once()
async def test_device_registry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
init_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device registry entry, including the network MAC connection."""
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "00:00:00:00:00:01")}
)
assert device_entry == snapshot
@pytest.mark.parametrize("error", [OSError, DeviceError])
async def test_config_entry_not_ready_when_oserror(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, error: Exception
@@ -1,36 +0,0 @@
# serializer version: 1
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'12:34:56:78:90:ab',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': 'Rev. B',
'id': <ANY>,
'identifiers': set({
tuple(
'aprilaire',
'12:34:56:78:90:ab',
),
}),
'labels': set({
}),
'manufacturer': 'Aprilaire',
'model': '8476W',
'model_id': None,
'name': 'Aprilaire',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': '1.05',
'via_device_id': None,
})
# ---
-52
View File
@@ -1,52 +0,0 @@
"""Tests for the Aprilaire integration setup."""
from unittest.mock import AsyncMock, patch
from pyaprilaire.const import Attribute
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.aprilaire.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
async def test_device_registry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device registry entry, including the network MAC connection."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="12:34:56:78:90:ab",
data={CONF_HOST: "localhost", CONF_PORT: 7000},
)
config_entry.add_to_hass(hass)
client = AsyncMock()
client.data = {
Attribute.MAC_ADDRESS: "1234567890ab",
Attribute.NAME: "Aprilaire",
Attribute.MODEL_NUMBER: 0,
Attribute.HARDWARE_REVISION: ord("B"),
Attribute.FIRMWARE_MAJOR_REVISION: 1,
Attribute.FIRMWARE_MINOR_REVISION: 5,
Attribute.THERMOSTAT_MODES: 0,
Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_STATUS: 0,
Attribute.CONNECTED: True,
}
with patch(
"homeassistant.components.aprilaire.coordinator.pyaprilaire.client.AprilaireClient",
return_value=client,
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "12:34:56:78:90:ab")}
)
assert device_entry == snapshot
@@ -1,36 +0,0 @@
# serializer version: 1
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'00:00:54:12:34:56',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'ialarm',
'00:00:54:12:34:56',
),
}),
'labels': set({
}),
'manufacturer': 'Antifurto365 - Meian',
'model': None,
'model_id': None,
'name': 'iAlarm',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
+1 -23
View File
@@ -1,16 +1,14 @@
"""Test the Antifurto365 iAlarm init."""
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import Mock, patch
from uuid import uuid4
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.ialarm.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
@@ -56,26 +54,6 @@ async def test_setup_not_ready(
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_device_registry(
hass: HomeAssistant,
ialarm_api: MagicMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device registry entry, including the network MAC connection."""
ialarm_api.return_value.get_mac = Mock(return_value="00:00:54:12:34:56")
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "00:00:54:12:34:56")}
)
assert device_entry == snapshot
async def test_unload_entry(hass: HomeAssistant, ialarm_api, mock_config_entry) -> None:
"""Test being able to unload an entry."""
ialarm_api.return_value.get_mac = Mock(return_value="00:00:54:12:34:56")
+1 -1
View File
@@ -232,7 +232,7 @@ def _create_mocked_hole(
incorrect_app_password or password not in ["newkey", "apikey"]
):
raise HoleError("Authentication failed: Invalid password")
raise HoleConnectionError("Connection error")
raise HoleConnectionError
async def get_data_side_effect(*_args, **_kwargs):
"""Return data based on the mocked Hole instance state."""
@@ -1,36 +0,0 @@
# serializer version: 1
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'a0:00:0a:0a:a0:00',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'ps4',
'A0000A0AA000',
),
}),
'labels': set({
}),
'manufacturer': 'Sony Interactive Entertainment Inc.',
'model': 'PlayStation 4',
'model_id': None,
'name': 'Fake PS4',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': '9.87',
'via_device_id': None,
})
# ---
-19
View File
@@ -6,7 +6,6 @@ from unittest.mock import MagicMock, patch
from pyps4_2ndscreen.credential import get_ddp_message
from pyps4_2ndscreen.ddp import DEFAULT_UDP_PORT
from pyps4_2ndscreen.media_art import TYPE_APP as PS_TYPE_APP
from syrupy.assertion import SnapshotAssertion
from homeassistant.components import ps4
from homeassistant.components.media_player import (
@@ -324,24 +323,6 @@ async def test_device_info_is_set_from_status_correctly(
assert mock_entry.identifiers == {(DOMAIN, MOCK_HOST_ID)}
async def test_device_registry(
hass: HomeAssistant,
patch_get_status: MagicMock,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device registry entry, including the network MAC connection."""
patch_get_status.return_value = MOCK_STATUS_STANDBY
await setup_mock_component(hass)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, MOCK_HOST_ID)}
)
assert device_entry == snapshot
async def test_device_info_is_assummed(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
@@ -1,36 +0,0 @@
# serializer version: 1
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'01:23:45:67:89:ab',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': '1.0.0.4',
'id': <ANY>,
'identifiers': set({
tuple(
'rabbitair',
'01:23:45:67:89:AB',
),
}),
'labels': set({
}),
'manufacturer': 'Rabbit Air',
'model': 'A3',
'model_id': None,
'name': 'Rabbit Air',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': '2.3.17',
'via_device_id': None,
})
# ---
-73
View File
@@ -1,73 +0,0 @@
"""Test Rabbit Air integration setup."""
from collections.abc import Generator
from unittest.mock import MagicMock, Mock, patch
import pytest
from rabbitair import Mode, Model, Speed
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.rabbitair.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import format_mac
from tests.common import MockConfigEntry
TEST_HOST = "1.1.1.1"
TEST_TOKEN = "0123456789abcdef0123456789abcdef"
TEST_MAC = "01:23:45:67:89:AB"
TEST_FIRMWARE = "2.3.17"
TEST_HARDWARE = "1.0.0.4"
TEST_TITLE = "Rabbit Air"
@pytest.fixture(autouse=True)
def use_mocked_zeroconf(mock_async_zeroconf: MagicMock) -> None:
"""Mock zeroconf in all tests."""
def get_mock_state() -> Mock:
"""Return a mock device state instance."""
mock_state = Mock()
mock_state.model = Model.A3
mock_state.main_firmware = TEST_HARDWARE
mock_state.power = True
mock_state.mode = Mode.Auto
mock_state.speed = Speed.Low
mock_state.wifi_firmware = TEST_FIRMWARE
return mock_state
@pytest.fixture
def rabbitair_connect() -> Generator[None]:
"""Mock connection."""
with patch("rabbitair.UdpClient.get_state", return_value=get_mock_state()):
yield
@pytest.mark.usefixtures("rabbitair_connect")
async def test_device_registry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device registry entry, including the network MAC connection."""
entry = MockConfigEntry(
domain=DOMAIN,
title=TEST_TITLE,
unique_id=format_mac(TEST_MAC),
data={
CONF_HOST: TEST_HOST,
CONF_ACCESS_TOKEN: TEST_TOKEN,
CONF_MAC: TEST_MAC,
},
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC)})
assert device_entry == snapshot
@@ -1,36 +0,0 @@
# serializer version: 1
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'4c:a1:61:00:11:22',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'rainbird',
'4c:a1:61:00:11:22',
),
}),
'labels': set({
}),
'manufacturer': 'Rain Bird',
'model': 'ESP-TM2',
'model_id': None,
'name': 'Rain Bird Controller',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': '9.12',
'via_device_id': None,
})
# ---
+1 -21
View File
@@ -2,14 +2,12 @@
from http import HTTPStatus
from typing import Any
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.rainbird.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_MAC, Platform
from homeassistant.const import CONF_MAC
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -44,24 +42,6 @@ async def test_init_success(
assert config_entry.state is ConfigEntryState.NOT_LOADED
async def test_device_registry(
hass: HomeAssistant,
config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the controller device registry entry, including the network MAC connection."""
# Load a platform so the controller device is registered.
with patch("homeassistant.components.rainbird.PLATFORMS", [Platform.SENSOR]):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, MAC_ADDRESS_UNIQUE_ID)}
)
assert device_entry == snapshot
@pytest.mark.parametrize(
("config_entry_data", "responses", "config_entry_state", "config_flow_steps"),
[
@@ -820,40 +820,3 @@ async def test_discovery_fallback_name_from_mac(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
assert result["description_placeholders"]["name"] == "Access DDEEFF"
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize(
("extra_info", "expected_name"),
[
(
{"name": "Front Gate", "hostname": "unvr", "product_name": "UNVR"},
"Front Gate",
),
({"hostname": "unvr", "product_name": "UNVR"}, "unvr"),
({"product_name": "UniFi Dream Machine"}, "UniFi Dream Machine"),
],
ids=["console-name", "hostname", "product-name"],
)
async def test_discovery_name_resolution(
hass: HomeAssistant,
mock_client: MagicMock,
extra_info: dict[str, str],
expected_name: str,
) -> None:
"""Test the discovered-device name prefers the console name over raw codes."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data={
"source_ip": "10.0.0.5",
"hw_addr": "aa:bb:cc:dd:ee:ff",
"services": {"Access": True},
"direct_connect_domain": "x.ui.direct",
**extra_info,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
assert result["description_placeholders"]["name"] == expected_name
@@ -1,176 +0,0 @@
# serializer version: 1
# name: test_entry_diagnostics
dict({
'entry': dict({
'data': dict({
'auth_implementation': 'yoto',
'token': dict({
'access_token': '**REDACTED**',
'expires_in': 3600,
'refresh_token': '**REDACTED**',
'scope': 'offline_access family:view family:devices:view family:devices:control family:devices:manage family:library:view user:content:view user:icons:manage',
'token_type': 'Bearer',
}),
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'yoto',
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
]),
'title': 'Yoto',
'unique_id': 'auth0|user-test',
'version': 1,
}),
'players': dict({
'player-test': dict({
'device': dict({
'description': None,
'device_family': 'v3',
'device_group': None,
'device_id': 'player-test',
'device_type': 'v3',
'form_factor': None,
'generation': 'gen3',
'has_user_given_name': False,
'name': 'Nursery Yoto',
'release_channel': None,
}),
'devices_refreshed_at': '2026-05-08T12:00:00+00:00',
'extended_status': dict({
'active_card': None,
'ambient_light_sensor_reading': None,
'average_download_speed_bytes_second': None,
'battery_level_percentage': None,
'battery_level_raw': None,
'battery_profile': None,
'battery_temperature': None,
'battery_voltage_mv': None,
'card_insertion_state': None,
'current_display_brightness': None,
'day_mode': None,
'free_disk_space_bytes': None,
'is_audio_device_connected': None,
'is_background_download_active': None,
'is_bluetooth_audio_connected': None,
'is_charging': None,
'network_ssid': None,
'nightlight_mode': None,
'power_source': None,
'system_volume_percentage': None,
'temperature_celcius': None,
'total_disk_space_bytes': None,
'updated_at': None,
'uptime': None,
'user_volume_percentage': None,
'utc_offset_seconds': None,
'utc_time': None,
'wifi_strength': None,
}),
'info': dict({
'activation_pop_code': None,
'config': dict({
'alarms': list([
]),
'bluetooth_enabled': None,
'bt_headphones_enabled': None,
'clock_face': None,
'day_ambient_colour': None,
'day_display_brightness': None,
'day_display_brightness_auto': None,
'day_max_volume_limit': None,
'day_sounds_off': None,
'day_time': dict({
'__type': "<class 'datetime.time'>",
'isoformat': '07:00:00',
}),
'day_yoto_daily': None,
'day_yoto_radio': None,
'display_dim_brightness': None,
'display_dim_timeout': None,
'headphones_volume_limited': None,
'hour_format': None,
'locale': None,
'log_level': None,
'night_ambient_colour': None,
'night_display_brightness': None,
'night_display_brightness_auto': None,
'night_max_volume_limit': None,
'night_sounds_off': None,
'night_time': dict({
'__type': "<class 'datetime.time'>",
'isoformat': '19:00:00',
}),
'night_yoto_daily': None,
'night_yoto_radio': None,
'pause_power_button': None,
'pause_volume_down': None,
'repeat_all': None,
'show_diagnostics': None,
'shutdown_timeout': None,
'system_volume': None,
'timezone': None,
'volume_level': None,
}),
'device_family': None,
'device_group': None,
'device_type': None,
'error_code': None,
'firmware_version': 'v2.17.5',
'geo_timezone': None,
'mac': '**REDACTED**',
'name': None,
'pop_code': None,
'release_channel_id': None,
}),
'info_refreshed_at': '2026-05-08T12:00:00+00:00',
'is_online': True,
'last_event': dict({
'card_id': 'card-test',
'chapter_key': '01',
'chapter_title': 'Chapter 1',
'event_utc': None,
'playback_status': 'playing',
'playback_wait': None,
'player_id': 'player-test',
'position': 120,
'repeat_all': None,
'request_id': None,
'sleep_timer_active': None,
'sleep_timer_seconds': None,
'source': None,
'streaming': None,
'track_key': '01-INT',
'track_length': 300,
'track_title': 'Introduction',
'volume': 8,
'volume_max': 16,
}),
'last_event_received_at': '2026-05-08T12:00:00+00:00',
'online_refreshed_at': None,
'status': dict({
'active_card': None,
'ambient_light_sensor_reading': None,
'battery_level_percentage': 75,
'card_insertion_state': 1,
'current_display_brightness': None,
'day_mode': 1,
'free_disk_space_bytes': None,
'is_audio_device_connected': False,
'is_bluetooth_audio_connected': False,
'is_charging': True,
'nightlight_mode': None,
'system_volume_percentage': None,
'updated_at': None,
'user_volume_percentage': None,
}),
}),
}),
})
# ---
-29
View File
@@ -1,29 +0,0 @@
"""Tests for the diagnostics data provided by the Yoto integration."""
import pytest
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
pytestmark = pytest.mark.usefixtures("setup_credentials", "mock_yoto_client")
async def test_entry_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test config entry diagnostics."""
await setup_integration(hass, mock_config_entry)
assert await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
) == snapshot(exclude=props("entry_id", "created_at", "modified_at", "expires_at"))
+148
View File
@@ -59,6 +59,7 @@ from homeassistant.helpers.automation import (
move_top_level_schema_fields_to_options,
)
from homeassistant.helpers.condition import (
_DATA_HISTORY_PRIMING_MANAGER,
ATTR_BEHAVIOR,
BEHAVIOR_ALL,
BEHAVIOR_ANY,
@@ -69,6 +70,7 @@ from homeassistant.helpers.condition import (
EntityConditionBase,
EntityNumericalConditionWithUnitBase,
_async_get_condition_platform,
_HistoryPrimingManager,
async_validate_condition_config,
make_entity_numerical_condition,
make_entity_numerical_condition_with_unit,
@@ -5862,6 +5864,152 @@ async def test_state_condition_attr_duration_history_flushes_before_query(
assert call_order == ["flush", "query"]
async def test_async_setup_creates_history_priming_manager(
hass: HomeAssistant,
) -> None:
"""The priming manager is created during condition setup, not on demand."""
# condition.async_setup runs as part of the test hass fixture.
assert isinstance(hass.data[_DATA_HISTORY_PRIMING_MANAGER], _HistoryPrimingManager)
async def test_history_priming_manager_serializes_queries(
recorder_mock: Recorder, hass: HomeAssistant
) -> None:
"""Queries run one at a time even when many conditions prime together."""
manager = _HistoryPrimingManager(hass)
instance = get_instance(hass)
running = 0
max_running = 0
release = asyncio.Event()
started = asyncio.Event()
async def _job(_recorder: Recorder) -> str:
nonlocal running, max_running
running += 1
max_running = max(max_running, running)
started.set()
await release.wait()
running -= 1
return "ok"
# No pending commit, so flushing is instant and only query serialization
# is exercised.
with patch.object(instance, "async_get_commit_future", return_value=None):
tasks = [asyncio.create_task(manager.async_prime(_job)) for _ in range(5)]
# The first job holds the query lock; the rest must queue behind it.
await started.wait()
await asyncio.sleep(0)
assert running == 1
release.set()
results = await asyncio.gather(*tasks)
assert results == ["ok"] * 5
assert max_running == 1
async def test_history_priming_manager_does_not_ride_in_flight_flush(
recorder_mock: Recorder, hass: HomeAssistant
) -> None:
"""A priming never rides a flush that began before it arrived (the lobby).
The flush commits changes still queued in the recorder so the history read
sees them. A condition that started tracking after an in-flight flush began
could miss its own just-queued change if it rode that flush, so it waits the
flush out and a fresh one is performed for it. Without the lobby step this
test fails: the late arrivals would ride the first flush (one flush total)
instead of sharing a second, fresh one.
"""
manager = _HistoryPrimingManager(hass)
instance = get_instance(hass)
flush_futures: list[asyncio.Future[None]] = []
def _spy_commit_future() -> asyncio.Future[None]:
fut = hass.loop.create_future()
flush_futures.append(fut)
return fut
async def _job(_recorder: Recorder) -> str:
return "done"
with patch.object(instance, "async_get_commit_future", _spy_commit_future):
# C0 claims the flush and is mid-flush (its commit future is pending).
c0 = asyncio.create_task(manager.async_prime(_job))
for _ in range(10):
await asyncio.sleep(0)
if flush_futures:
break
assert len(flush_futures) == 1
# Two conditions arrive while C0's flush runs; they must not ride it.
c1 = asyncio.create_task(manager.async_prime(_job))
c2 = asyncio.create_task(manager.async_prime(_job))
for _ in range(5):
await asyncio.sleep(0)
# Parked in the lobby: no new flush yet, none finished.
assert len(flush_futures) == 1
assert not c1.done()
assert not c2.done()
# C0's flush completes; C1 now performs a fresh flush and C2 rides it.
flush_futures[0].set_result(None)
assert await c0 == "done"
for _ in range(10):
await asyncio.sleep(0)
# Exactly one fresh flush is shared by C1 and C2, not one each: this is
# the assertion that fails without the lobby (it would stay 1).
assert len(flush_futures) == 2
flush_futures[1].set_result(None)
assert await asyncio.gather(c1, c2) == ["done", "done"]
async def test_history_priming_manager_cancelled_lobby_waiter(
recorder_mock: Recorder, hass: HomeAssistant
) -> None:
"""A priming cancelled while waiting in the lobby doesn't wedge later ones.
A condition whose timeout fires while it waits for an in-flight flush is
cancelled. That must leave the manager able to flush for the next priming.
"""
manager = _HistoryPrimingManager(hass)
instance = get_instance(hass)
flush_futures: list[asyncio.Future[None]] = []
def _spy_commit_future() -> asyncio.Future[None]:
fut = hass.loop.create_future()
flush_futures.append(fut)
return fut
async def _job(_recorder: Recorder) -> str:
return "done"
with patch.object(instance, "async_get_commit_future", _spy_commit_future):
c0 = asyncio.create_task(manager.async_prime(_job))
for _ in range(10):
await asyncio.sleep(0)
if flush_futures:
break
# A second priming parks in the lobby, then its timeout cancels it.
waiter = asyncio.create_task(manager.async_prime(_job))
for _ in range(3):
await asyncio.sleep(0)
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
# C0 finishes; a later priming still flushes and completes normally.
flush_futures[0].set_result(None)
assert await c0 == "done"
later = asyncio.create_task(manager.async_prime(_job))
for _ in range(10):
await asyncio.sleep(0)
assert len(flush_futures) == 2
flush_futures[1].set_result(None)
assert await later == "done"
async def test_state_condition_multi_state_duration_uses_history(
recorder_mock: Recorder, hass: HomeAssistant
) -> None: