mirror of
https://github.com/home-assistant/core.git
synced 2026-06-18 09:52:57 +02:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2f11d25e1 | |||
| 90fe38c0f2 | |||
| 65c2aaf22f | |||
| a691de352c | |||
| 4203781fa5 | |||
| e6b3a97162 | |||
| 6ad8ad5715 | |||
| a45867b896 | |||
| 000e075a8e | |||
| 0899d016b9 | |||
| 3375f2ed76 | |||
| 3f5778e71b | |||
| 86c39694d3 | |||
| a53a6644c0 | |||
| 18fdfacf45 | |||
| bd9bd29f2c | |||
| 334c6614cc | |||
| aa772f6ecd | |||
| 87169921ae | |||
| 16338b8b6b | |||
| 519da3c9c9 | |||
| 6f34718c1f | |||
| e4287bb43c | |||
| d724ebac2a | |||
| dc480051db | |||
| 63b6ced9c4 | |||
| 34e9b3ff1e | |||
| 210746525e | |||
| 0134e99366 | |||
| 06de89d6a3 | |||
| 4c267617f8 | |||
| a82f1a7a1d | |||
| d234f65dd9 | |||
| 30148980e1 | |||
| 1fa9a3353c | |||
| 2dbbd70085 | |||
| 73903b0bfc | |||
| b09f54ce3b | |||
| 6d9e41da07 |
+5
-4
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"328ce915f8b178bbcfcb1b69f397ac996456a4ab50490805b5b3bdd26cbf58fe","body_hash":"0665c72fe4b0a14b3a0b396dc293ce78235771fe15ebc894662fece33b189135","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"7b142e96e0f8b454cdcc9c0c25070cf9a52c44d83a6b1fbc3ad6725b6567337c","body_hash":"3894ded07d5934ac5f29d160ffb1f9115cf72b6da8a7e453a4d4f69e8641a48e","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"v0.79.6","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
|
||||
# ___ _ _
|
||||
# / _ \ | | (_)
|
||||
@@ -1500,11 +1500,12 @@ jobs:
|
||||
echo "Artifact has no head_sha; running the agent."
|
||||
exit 0
|
||||
fi
|
||||
# Recover the commit recorded in the most recent requirements-check comment.
|
||||
# Recover the commit recorded in the most recent requirements-check
|
||||
# comment from the "Checked at commit" link
|
||||
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
|
||||
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
|
||||
| grep -oE '<!-- requirements-check-sha: [0-9a-f]{7,40} -->' \
|
||||
| grep -oE '[0-9a-f]{7,40}' | tail -1 || true)
|
||||
| grep -oiE '/commit/[0-9a-f]{40}' \
|
||||
| grep -oiE '[0-9a-f]{40}' | tail -1 || true)
|
||||
if [ -z "${PRIOR}" ]; then
|
||||
echo "No previous comment with a recorded commit; running the agent."
|
||||
exit 0
|
||||
|
||||
@@ -51,11 +51,12 @@ jobs:
|
||||
echo "Artifact has no head_sha; running the agent."
|
||||
exit 0
|
||||
fi
|
||||
# Recover the commit recorded in the most recent requirements-check comment.
|
||||
# Recover the commit recorded in the most recent requirements-check
|
||||
# comment from the "Checked at commit" link
|
||||
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
|
||||
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
|
||||
| grep -oE '<!-- requirements-check-sha: [0-9a-f]{7,40} -->' \
|
||||
| grep -oE '[0-9a-f]{7,40}' | tail -1 || true)
|
||||
| grep -oiE '/commit/[0-9a-f]{40}' \
|
||||
| grep -oiE '[0-9a-f]{40}' | tail -1 || true)
|
||||
if [ -z "${PRIOR}" ]; then
|
||||
echo "No previous comment with a recorded commit; running the agent."
|
||||
exit 0
|
||||
@@ -188,10 +189,8 @@ Then stop. Do not improvise a verdict.
|
||||
|
||||
Replace every placeholder with the resolved value and emit
|
||||
`rendered_comment` via `add_comment`. Preserve the leading
|
||||
`<!-- requirements-check -->` marker and the
|
||||
`<!-- requirements-check-sha: … -->` marker that follows it — the next
|
||||
run reads the recorded commit from it to decide whether anything changed.
|
||||
The PR target is already wired; do not pass `item_number`.
|
||||
`<!-- requirements-check -->` marker. The PR target is already wired;
|
||||
do not pass `item_number`.
|
||||
|
||||
## Check instructions
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ repos:
|
||||
pass_filenames: false
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
|
||||
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|homeassistant/components/sensor/const\.py|requirements.+\.txt)$
|
||||
- id: hassfest-metadata
|
||||
name: hassfest-metadata
|
||||
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"title": "Re-authenticate AirVisual"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"type": "Integration type"
|
||||
},
|
||||
"description": "Pick what type of AirVisual data you want to monitor.",
|
||||
"title": "Configure AirVisual"
|
||||
}
|
||||
|
||||
@@ -59,7 +59,9 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._get_reconfigure_entry(), data_updates=user_input
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title="Aqvify", data=user_input)
|
||||
return self.async_create_entry(
|
||||
title=account_data.name or "Aqvify", data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
|
||||
@@ -152,3 +152,10 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
|
||||
devices=devices,
|
||||
device_data=device_data,
|
||||
)
|
||||
|
||||
def async_add_devices(self, added_devices: set[str]) -> tuple[set[str], set[str]]:
|
||||
"""Return newly discovered device keys and the full current device set."""
|
||||
|
||||
current_devices = set(self.data.devices.devices)
|
||||
new_devices: set[str] = current_devices - added_devices
|
||||
return (new_devices, current_devices)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyaqvify"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyaqvify==0.0.10"]
|
||||
"requirements": ["pyaqvify==0.0.11"]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
)
|
||||
from homeassistant.const import UnitOfLength
|
||||
from homeassistant.const import UnitOfLength, UnitOfTemperature, UnitOfVolume
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -50,6 +50,23 @@ ENTITIES: tuple[AqvifySensorEntityDescription, ...] = (
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda value: value.water_level,
|
||||
),
|
||||
AqvifySensorEntityDescription(
|
||||
key="volume",
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLUME_STORAGE,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda value: value.volume,
|
||||
),
|
||||
AqvifySensorEntityDescription(
|
||||
key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda value: value.temperature,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -59,11 +76,23 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Aqvify sensor entities from a config entry."""
|
||||
async_add_entities(
|
||||
AqvifySensor(entry.runtime_data, description, device_key)
|
||||
for description in ENTITIES
|
||||
for device_key in entry.runtime_data.data.devices.devices
|
||||
)
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
added_devices: set[str] = set()
|
||||
|
||||
def _async_add_new_devices() -> None:
|
||||
nonlocal added_devices
|
||||
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
|
||||
added_devices = current_devices
|
||||
|
||||
async_add_entities(
|
||||
AqvifySensor(coordinator, description, device_key)
|
||||
for description in ENTITIES
|
||||
for device_key in new_devices_set
|
||||
)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
|
||||
_async_add_new_devices()
|
||||
|
||||
|
||||
class AqvifySensor(AqvifyBaseEntity, SensorEntity):
|
||||
|
||||
@@ -123,7 +123,14 @@ class _BrandsBaseView(HomeAssistantView):
|
||||
)
|
||||
if not authenticated:
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
# A failed request that carried an Authorization header is a real
|
||||
# Bearer auth attempt — return 401 and let the ban middleware count
|
||||
# it as a wrong login.
|
||||
raise web.HTTPUnauthorized
|
||||
# No Authorization header: most likely a benign signed-URL / query-
|
||||
# token request whose token has expired (e.g. a browser tab left
|
||||
# open that re-fetches resources later). Return 403 so it doesn't
|
||||
# register as a wrong login and ban the user's own IP.
|
||||
raise web.HTTPForbidden
|
||||
|
||||
async def _serve_from_custom_integration(
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bryant_evolution",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["evolutionhttp==0.0.18"]
|
||||
"requirements": ["evolutionhttp==0.0.19"]
|
||||
}
|
||||
|
||||
@@ -785,7 +785,9 @@ class CameraView(HomeAssistantView):
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
|
||||
"""Start a GET request."""
|
||||
if (camera := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
raise (
|
||||
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
|
||||
)
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
@@ -793,11 +795,15 @@ class CameraView(HomeAssistantView):
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
# A failed request that carried an Authorization header is a real
|
||||
# Bearer auth attempt — return 401 and let the ban middleware count
|
||||
# it as a wrong login.
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or camera access token
|
||||
# No Authorization header: most likely a benign signed-URL / query-
|
||||
# token request whose token has expired (e.g. a browser tab left
|
||||
# open that re-fetches resources later). Return 403 so it doesn't
|
||||
# register as a wrong login and ban the user's own IP.
|
||||
raise web.HTTPForbidden
|
||||
|
||||
if not camera.is_on:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from pycasperglow import CasperGlow
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -27,7 +28,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"address": address},
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass, address.upper(), BluetoothReachabilityIntent.CONNECTION
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
glow = CasperGlow(ble_device)
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"message": "An error occurred while communicating with the Casper Glow: {error}"
|
||||
},
|
||||
"device_not_found": {
|
||||
"message": "Could not find Casper Glow device with address {address}"
|
||||
"message": "Could not find Casper Glow device with address {address}: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ async def async_setup_entry(
|
||||
class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
|
||||
"""Climate device for CCM15 coordinator."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_has_entity_name = True
|
||||
_attr_target_temperature_step = PRECISION_WHOLE
|
||||
_attr_hvac_modes = [
|
||||
@@ -93,6 +92,13 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
|
||||
"""Return device data."""
|
||||
return self.coordinator.get_ac_data(self._ac_index)
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
"""Return the unit of measurement reported by the device."""
|
||||
if (data := self.data) is not None and not data.is_celsius:
|
||||
return UnitOfTemperature.FAHRENHEIT
|
||||
return UnitOfTemperature.CELSIUS
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> int | None:
|
||||
"""Return current temperature."""
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Please enter the API key obtained from ecobee.com."
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Button platform for Edifier infrared integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel
|
||||
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
|
||||
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
|
||||
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
|
||||
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, EdifierCode
|
||||
from .entity import EdifierIrEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EdifierIrButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes Edifier IR button entity."""
|
||||
|
||||
command_code: EdifierCode
|
||||
|
||||
|
||||
COMMAND_SET_BUTTONS: dict[
|
||||
EdifierCommandSet,
|
||||
tuple[EdifierIrButtonEntityDescription, ...],
|
||||
] = {
|
||||
EdifierCommandSet.R1700BT: (
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="bluetooth",
|
||||
translation_key="bluetooth",
|
||||
command_code=EdifierR1700BTCode.BLUETOOTH,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="line_1",
|
||||
translation_key="line_1",
|
||||
command_code=EdifierR1700BTCode.LINE_1,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="line_2",
|
||||
translation_key="line_2",
|
||||
command_code=EdifierR1700BTCode.LINE_2,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="fx_on",
|
||||
translation_key="fx_on",
|
||||
command_code=EdifierR1700BTCode.FX_ON,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="fx_off",
|
||||
translation_key="fx_off",
|
||||
command_code=EdifierR1700BTCode.FX_OFF,
|
||||
),
|
||||
),
|
||||
EdifierCommandSet.R1280DB: (
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="bluetooth",
|
||||
translation_key="bluetooth",
|
||||
command_code=EdifierR1280DBCode.BLUETOOTH,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="line_1",
|
||||
translation_key="line_1",
|
||||
command_code=EdifierR1280DBCode.LINE_1,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="line_2",
|
||||
translation_key="line_2",
|
||||
command_code=EdifierR1280DBCode.LINE_2,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="optical",
|
||||
translation_key="optical",
|
||||
command_code=EdifierR1280DBCode.OPTICAL,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="coax",
|
||||
translation_key="coax",
|
||||
command_code=EdifierR1280DBCode.COAX,
|
||||
),
|
||||
),
|
||||
EdifierCommandSet.S360DB: (
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="bluetooth",
|
||||
translation_key="bluetooth",
|
||||
command_code=EdifierS360DBCode.BLUETOOTH,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="optical",
|
||||
translation_key="optical",
|
||||
command_code=EdifierS360DBCode.OPTICAL,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="coax",
|
||||
translation_key="coax",
|
||||
command_code=EdifierS360DBCode.COAX,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="pc",
|
||||
translation_key="pc",
|
||||
command_code=EdifierS360DBCode.PC,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="aux",
|
||||
translation_key="aux",
|
||||
command_code=EdifierS360DBCode.AUX,
|
||||
),
|
||||
),
|
||||
EdifierCommandSet.RC20G: (
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="bluetooth",
|
||||
translation_key="bluetooth",
|
||||
command_code=EdifierRC20GCode.BLUETOOTH,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="pc",
|
||||
translation_key="pc",
|
||||
command_code=EdifierRC20GCode.PC,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="aux",
|
||||
translation_key="aux",
|
||||
command_code=EdifierRC20GCode.AUX,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="optical",
|
||||
translation_key="optical",
|
||||
command_code=EdifierRC20GCode.OPTICAL,
|
||||
),
|
||||
EdifierIrButtonEntityDescription(
|
||||
key="coax",
|
||||
translation_key="coax",
|
||||
command_code=EdifierRC20GCode.COAX,
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Edifier IR buttons from a config entry."""
|
||||
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
|
||||
command_set = EdifierCommandSet(entry.data[CONF_COMMAND_SET])
|
||||
model = EdifierModel(entry.data[CONF_MODEL])
|
||||
async_add_entities(
|
||||
EdifierIrButton(entry, model, infrared_entity_id, description)
|
||||
for description in COMMAND_SET_BUTTONS.get(command_set, ())
|
||||
)
|
||||
|
||||
|
||||
class EdifierIrButton(EdifierIrEntity, InfraredEmitterConsumerEntity, ButtonEntity):
|
||||
"""Edifier IR button entity."""
|
||||
|
||||
entity_description: EdifierIrButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
model: EdifierModel,
|
||||
infrared_entity_id: str,
|
||||
description: EdifierIrButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize Edifier IR button."""
|
||||
super().__init__(entry, model, unique_id_suffix=description.key)
|
||||
self._infrared_emitter_entity_id = infrared_entity_id
|
||||
self.entity_description = description
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
await self._send_command(self.entity_description.command_code.to_command())
|
||||
@@ -18,5 +18,36 @@
|
||||
"title": "Set up Edifier IR speaker"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"aux": {
|
||||
"name": "AUX"
|
||||
},
|
||||
"bluetooth": {
|
||||
"name": "Bluetooth"
|
||||
},
|
||||
"coax": {
|
||||
"name": "Coaxial"
|
||||
},
|
||||
"fx_off": {
|
||||
"name": "FX off"
|
||||
},
|
||||
"fx_on": {
|
||||
"name": "FX on"
|
||||
},
|
||||
"line_1": {
|
||||
"name": "Line 1"
|
||||
},
|
||||
"line_2": {
|
||||
"name": "Line 2"
|
||||
},
|
||||
"optical": {
|
||||
"name": "Optical"
|
||||
},
|
||||
"pc": {
|
||||
"name": "PC"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
"domain": "eufylife_ble",
|
||||
"name": "EufyLife",
|
||||
"bluetooth": [
|
||||
{
|
||||
"local_name": "eufy T9120"
|
||||
},
|
||||
{
|
||||
"local_name": "eufy T9130"
|
||||
},
|
||||
{
|
||||
"local_name": "eufy T9140"
|
||||
},
|
||||
@@ -16,6 +22,9 @@
|
||||
},
|
||||
{
|
||||
"local_name": "eufy T9149"
|
||||
},
|
||||
{
|
||||
"local_name": "eufy T9150"
|
||||
}
|
||||
],
|
||||
"codeowners": ["@bdr99"],
|
||||
@@ -24,5 +33,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/eufylife_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["eufylife-ble-client==0.1.8"]
|
||||
"requirements": ["eufylife-ble-client==0.1.10"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyfireservicerota"],
|
||||
"requirements": ["pyfireservicerota==0.0.46"]
|
||||
"requirements": ["pyfireservicerota==0.0.49"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["foobot_async"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["foobot_async==1.0.0"]
|
||||
"requirements": ["foobot_async==1.0.1"]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"date_description": "The to-do's due date.",
|
||||
"date_name": "Due date",
|
||||
"developer_options_description": "Additional features available in developer mode.",
|
||||
"developer_options_name": "Advanced settings",
|
||||
"developer_options_name": "Developer options",
|
||||
"every_x_description": "The number of intervals (days, weeks, months, or years) after which the daily repeats, based on the chosen repetition interval. A value of 0 makes the daily inactive ('Grey Daily').",
|
||||
"every_x_name": "Repeat every X",
|
||||
"frequency_daily_description": "The repetition interval of a daily.",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["heatmiserV3"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["heatmiserV3==2.0.4"]
|
||||
"requirements": ["heatmiserV3==2.0.6"]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"title": "[%key:component::here_travel_time::config::step::destination_menu::title%]"
|
||||
},
|
||||
"destination_entity_id": {
|
||||
"destination_entity": {
|
||||
"data": {
|
||||
"destination_entity_id": "Destination using an entity"
|
||||
},
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"title": "[%key:component::here_travel_time::config::step::origin_menu::title%]"
|
||||
},
|
||||
"origin_entity_id": {
|
||||
"origin_entity": {
|
||||
"data": {
|
||||
"origin_entity_id": "Origin using an entity"
|
||||
},
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Hi-Link HLK-SW-16 device."
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "The Honeywell integration needs to re-authenticate your account",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["keyrings.alt", "pyicloud"],
|
||||
"requirements": ["pyicloud==2.4.1"]
|
||||
"requirements": ["pyicloud==2.6.5"]
|
||||
}
|
||||
|
||||
@@ -326,7 +326,9 @@ class ImageView(HomeAssistantView):
|
||||
) -> ImageEntity:
|
||||
"""Authenticate request and return image entity."""
|
||||
if (image_entity := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
raise (
|
||||
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
|
||||
)
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
@@ -334,11 +336,15 @@ class ImageView(HomeAssistantView):
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
# A failed request that carried an Authorization header is a real
|
||||
# Bearer auth attempt — return 401 and let the ban middleware count
|
||||
# it as a wrong login.
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or image entity access token
|
||||
# No Authorization header: most likely a benign signed-URL / query-
|
||||
# token request whose token has expired (e.g. a browser tab left
|
||||
# open that re-fetches resources later). Return 403 so it doesn't
|
||||
# register as a wrong login and ban the user's own IP.
|
||||
raise web.HTTPForbidden
|
||||
|
||||
return image_entity
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"protocol": "Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Hostname or IP address of your Iskra device."
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
"invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details."
|
||||
},
|
||||
"step": {
|
||||
"import": {
|
||||
"import_ics_file": {
|
||||
"data": {
|
||||
"ics_file": "ICS file"
|
||||
},
|
||||
"description": "You can import events in iCal format (.ics file)."
|
||||
},
|
||||
"user": {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["lunatone-rest-api-client==0.9.1"],
|
||||
"requirements": ["lunatone-rest-api-client==0.9.2"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import Any, Final, Required, TypedDict, final
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from aiohttp import hdrs, web
|
||||
from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE
|
||||
from aiohttp.typedefs import LooseHeaders
|
||||
from propcache.api import cached_property
|
||||
@@ -1271,12 +1271,9 @@ class MediaPlayerImageView(HomeAssistantView):
|
||||
) -> web.Response:
|
||||
"""Start a get request."""
|
||||
if (player := self.component.get_entity(entity_id)) is None:
|
||||
status = (
|
||||
HTTPStatus.NOT_FOUND
|
||||
if request[KEY_AUTHENTICATED]
|
||||
else HTTPStatus.UNAUTHORIZED
|
||||
raise (
|
||||
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
|
||||
)
|
||||
return web.Response(status=status)
|
||||
|
||||
assert isinstance(player, MediaPlayerEntity)
|
||||
authenticated = (
|
||||
@@ -1285,7 +1282,16 @@ class MediaPlayerImageView(HomeAssistantView):
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
return web.Response(status=HTTPStatus.UNAUTHORIZED)
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
# A failed request that carried an Authorization header is a real
|
||||
# Bearer auth attempt — return 401 and let the ban middleware count
|
||||
# it as a wrong login.
|
||||
raise web.HTTPUnauthorized
|
||||
# No Authorization header: most likely a benign signed-URL / query-
|
||||
# token request whose token has expired (e.g. a browser tab left
|
||||
# open that re-fetches resources later). Return 403 so it doesn't
|
||||
# register as a wrong login and ban the user's own IP.
|
||||
raise web.HTTPForbidden
|
||||
|
||||
if media_content_type and media_content_id:
|
||||
media_image_id = request.query.get("media_image_id")
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
"device": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call."
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"step": {
|
||||
"confirm": {
|
||||
"data": {
|
||||
"code": "Verification code (OTP)"
|
||||
"code": "Verification code (OTP)",
|
||||
"qr_code": "QR code"
|
||||
},
|
||||
"data_description": {
|
||||
"code": "The six-digit code currently displayed in your authentication app."
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pencompy"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pencompy==0.0.3"]
|
||||
"requirements": ["pencompy==0.0.4"]
|
||||
}
|
||||
|
||||
@@ -58,6 +58,38 @@
|
||||
"user": "Add sensor"
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"index": "[%key:component::scrape::config_subentries::entity::step::user::data::index%]",
|
||||
"select": "[%key:component::scrape::config_subentries::entity::step::user::data::select%]"
|
||||
},
|
||||
"data_description": {
|
||||
"index": "[%key:component::scrape::config_subentries::entity::step::user::data_description::index%]",
|
||||
"select": "[%key:component::scrape::config_subentries::entity::step::user::data_description::select%]"
|
||||
},
|
||||
"sections": {
|
||||
"advanced": {
|
||||
"data": {
|
||||
"attribute": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::attribute%]",
|
||||
"availability": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::availability%]",
|
||||
"device_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::device_class%]",
|
||||
"state_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::state_class%]",
|
||||
"unit_of_measurement": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::unit_of_measurement%]",
|
||||
"value_template": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data::value_template%]"
|
||||
},
|
||||
"data_description": {
|
||||
"attribute": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::attribute%]",
|
||||
"availability": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::availability%]",
|
||||
"device_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::device_class%]",
|
||||
"state_class": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::state_class%]",
|
||||
"unit_of_measurement": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::unit_of_measurement%]",
|
||||
"value_template": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::data_description::value_template%]"
|
||||
},
|
||||
"description": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::description%]",
|
||||
"name": "[%key:component::scrape::config_subentries::entity::step::user::sections::advanced::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"index": "Index",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pysesame2"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pysesame2==1.0.1"]
|
||||
"requirements": ["pysesame2==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"additional_account": {
|
||||
"add_account": {
|
||||
"data": {
|
||||
"account": "[%key:component::sia::config::step::user::data::account%]",
|
||||
"additional_account": "[%key:component::sia::config::step::user::data::additional_account%]",
|
||||
|
||||
@@ -29,6 +29,7 @@ VEHICLE_STATUS = "vehicle_status"
|
||||
API_GEN_1 = "g1"
|
||||
API_GEN_2 = "g2"
|
||||
API_GEN_3 = "g3"
|
||||
API_GEN_4 = "g4"
|
||||
MANUFACTURER = "Subaru"
|
||||
|
||||
PLATFORMS = [
|
||||
|
||||
@@ -24,6 +24,7 @@ from . import get_device_info
|
||||
from .const import (
|
||||
API_GEN_2,
|
||||
API_GEN_3,
|
||||
API_GEN_4,
|
||||
VEHICLE_API_GEN,
|
||||
VEHICLE_HAS_EV,
|
||||
VEHICLE_STATUS,
|
||||
@@ -153,10 +154,10 @@ def create_vehicle_sensors(
|
||||
sensor_descriptions_to_add = []
|
||||
sensor_descriptions_to_add.extend(SAFETY_SENSORS)
|
||||
|
||||
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_2, API_GEN_3]:
|
||||
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_2, API_GEN_3, API_GEN_4]:
|
||||
sensor_descriptions_to_add.extend(API_GEN_2_SENSORS)
|
||||
|
||||
if vehicle_info[VEHICLE_API_GEN] == API_GEN_3:
|
||||
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_3, API_GEN_4]:
|
||||
sensor_descriptions_to_add.extend(API_GEN_3_SENSORS)
|
||||
|
||||
if vehicle_info[VEHICLE_HAS_EV]:
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
|
||||
@@ -24,6 +24,7 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_USERNAME,
|
||||
@@ -192,7 +193,7 @@ class UnifiFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
self.context["title_placeholders"] = {
|
||||
CONF_HOST: reauth_entry.data[CONF_HOST],
|
||||
CONF_SITE_ID: reauth_entry.title,
|
||||
CONF_NAME: reauth_entry.title,
|
||||
}
|
||||
|
||||
self.reauth_schema = {
|
||||
@@ -231,8 +232,13 @@ class UnifiFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured(updates=self.config, reload_on_update=False)
|
||||
|
||||
self.context["title_placeholders"] = {
|
||||
CONF_HOST: host,
|
||||
CONF_SITE_ID: DEFAULT_SITE_ID,
|
||||
CONF_NAME: (
|
||||
discovery_info.get("name")
|
||||
or discovery_info.get("hostname")
|
||||
or discovery_info.get("product_name")
|
||||
or "UniFi Network"
|
||||
),
|
||||
CONF_HOST: source_ip,
|
||||
}
|
||||
self.context["configuration_url"] = f"https://{host}"
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"service_unavailable": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown_client_mac": "No client available on that MAC address"
|
||||
},
|
||||
"flow_title": "{site} ({host})",
|
||||
"flow_title": "{name} ({host})",
|
||||
"step": {
|
||||
"site": {
|
||||
"data": {
|
||||
|
||||
@@ -286,8 +286,9 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
form_data[CONF_API_KEY] = user_input[CONF_API_KEY]
|
||||
|
||||
placeholders = {
|
||||
"name": discovery_info["hostname"]
|
||||
or discovery_info["platform"]
|
||||
"name": discovery_info.get("name")
|
||||
or discovery_info.get("hostname")
|
||||
or discovery_info.get("product_name")
|
||||
or f"NVR {_async_short_mac(discovery_info['hw_addr'])}",
|
||||
"ip_address": discovery_info["source_ip"],
|
||||
}
|
||||
|
||||
Generated
+12
@@ -116,6 +116,14 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
|
||||
"domain": "eq3btsmart",
|
||||
"local_name": "CC-RT-BLE-EQ",
|
||||
},
|
||||
{
|
||||
"domain": "eufylife_ble",
|
||||
"local_name": "eufy T9120",
|
||||
},
|
||||
{
|
||||
"domain": "eufylife_ble",
|
||||
"local_name": "eufy T9130",
|
||||
},
|
||||
{
|
||||
"domain": "eufylife_ble",
|
||||
"local_name": "eufy T9140",
|
||||
@@ -136,6 +144,10 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
|
||||
"domain": "eufylife_ble",
|
||||
"local_name": "eufy T9149",
|
||||
},
|
||||
{
|
||||
"domain": "eufylife_ble",
|
||||
"local_name": "eufy T9150",
|
||||
},
|
||||
{
|
||||
"connectable": True,
|
||||
"domain": "eurotronic_cometblue",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"numeric_device_classes": [
|
||||
"absolute_humidity",
|
||||
"apparent_power",
|
||||
"aqi",
|
||||
"area",
|
||||
"atmospheric_pressure",
|
||||
"battery",
|
||||
"blood_glucose_concentration",
|
||||
"carbon_dioxide",
|
||||
"carbon_monoxide",
|
||||
"conductivity",
|
||||
"current",
|
||||
"data_rate",
|
||||
"data_size",
|
||||
"distance",
|
||||
"duration",
|
||||
"energy",
|
||||
"energy_distance",
|
||||
"energy_storage",
|
||||
"frequency",
|
||||
"gas",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"irradiance",
|
||||
"moisture",
|
||||
"monetary",
|
||||
"nitrogen_dioxide",
|
||||
"nitrogen_monoxide",
|
||||
"nitrous_oxide",
|
||||
"ozone",
|
||||
"ph",
|
||||
"pm1",
|
||||
"pm10",
|
||||
"pm25",
|
||||
"pm4",
|
||||
"power",
|
||||
"power_factor",
|
||||
"precipitation",
|
||||
"precipitation_intensity",
|
||||
"pressure",
|
||||
"reactive_energy",
|
||||
"reactive_power",
|
||||
"signal_strength",
|
||||
"sound_pressure",
|
||||
"speed",
|
||||
"sulphur_dioxide",
|
||||
"temperature",
|
||||
"temperature_delta",
|
||||
"volatile_organic_compounds",
|
||||
"volatile_organic_compounds_parts",
|
||||
"voltage",
|
||||
"volume",
|
||||
"volume_flow_rate",
|
||||
"volume_storage",
|
||||
"water",
|
||||
"weight",
|
||||
"wind_direction",
|
||||
"wind_speed"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -312,10 +312,6 @@ def async_track_state_change_event(
|
||||
Unlike async_track_state_change, async_track_state_change_event
|
||||
passes the full event to the callback.
|
||||
|
||||
The action will not be called immediately, but will be scheduled to run
|
||||
in the next event loop iteration, even if the action is decorated with
|
||||
@callback.
|
||||
|
||||
In order to avoid having to iterate a long list
|
||||
of EVENT_STATE_CHANGED and fire and create a job
|
||||
for each one, we keep a dict of entity ids that
|
||||
@@ -331,16 +327,6 @@ def async_track_state_change_event(
|
||||
return _async_track_state_change_event(hass, entity_ids, action, job_type)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_dispatch_entity_id_event_soon[_StateEventDataT: EventStateEventData](
|
||||
hass: HomeAssistant,
|
||||
callbacks: dict[str, list[HassJob[[Event[_StateEventDataT]], Any]]],
|
||||
event: Event[_StateEventDataT],
|
||||
) -> None:
|
||||
"""Dispatch to listeners soon to ensure one event loop runs before dispatch."""
|
||||
hass.loop.call_soon(_async_dispatch_entity_id_event, hass, callbacks, event)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_dispatch_entity_id_event[_StateEventDataT: EventStateEventData](
|
||||
hass: HomeAssistant,
|
||||
@@ -374,7 +360,7 @@ def _async_state_filter[_StateEventDataT: EventStateEventData](
|
||||
_KEYED_TRACK_STATE_CHANGE = _KeyedEventTracker(
|
||||
key=_TRACK_STATE_CHANGE_DATA,
|
||||
event_type=EVENT_STATE_CHANGED,
|
||||
dispatcher_callable=_async_dispatch_entity_id_event_soon,
|
||||
dispatcher_callable=_async_dispatch_entity_id_event,
|
||||
filter_callable=_async_state_filter,
|
||||
)
|
||||
|
||||
@@ -397,7 +383,7 @@ def _async_track_state_change_event(
|
||||
_KEYED_TRACK_STATE_REPORT = _KeyedEventTracker(
|
||||
key=_TRACK_STATE_REPORT_DATA,
|
||||
event_type=EVENT_STATE_REPORTED,
|
||||
dispatcher_callable=_async_dispatch_entity_id_event_soon,
|
||||
dispatcher_callable=_async_dispatch_entity_id_event,
|
||||
filter_callable=_async_state_filter,
|
||||
)
|
||||
|
||||
@@ -860,10 +846,6 @@ def async_track_state_change_filtered(
|
||||
) -> _TrackStateChangeFiltered:
|
||||
"""Track state changes with a TrackStates filter that can be updated.
|
||||
|
||||
The action will not be called immediately, but will be scheduled to run
|
||||
in the next event loop iteration, even if the action is decorated with
|
||||
@callback.
|
||||
|
||||
Args:
|
||||
hass:
|
||||
Home assistant object.
|
||||
@@ -1336,10 +1318,6 @@ def async_track_template_result(
|
||||
evaluation is different from the previous run, the action is passed
|
||||
the result.
|
||||
|
||||
The action will not be called immediately, but will be scheduled to run
|
||||
in the next event loop iteration, even if the action is decorated with
|
||||
@callback.
|
||||
|
||||
If the template results in an TemplateError, this will be returned to
|
||||
the listener the first time this happens but not for subsequent errors.
|
||||
Once the template returns to a non-error condition the result is sent
|
||||
|
||||
Generated
+10
-10
@@ -970,7 +970,7 @@ essent-dynamic-pricing==0.3.1
|
||||
eternalegypt==0.0.18
|
||||
|
||||
# homeassistant.components.eufylife_ble
|
||||
eufylife-ble-client==0.1.8
|
||||
eufylife-ble-client==0.1.10
|
||||
|
||||
# homeassistant.components.eurotronic_cometblue
|
||||
eurotronic-cometblue-ha==1.4.0
|
||||
@@ -982,7 +982,7 @@ eurotronic-cometblue-ha==1.4.0
|
||||
evohome-async==1.2.0
|
||||
|
||||
# homeassistant.components.bryant_evolution
|
||||
evolutionhttp==0.0.18
|
||||
evolutionhttp==0.0.19
|
||||
|
||||
# homeassistant.components.faa_delays
|
||||
faadelays==2023.9.1
|
||||
@@ -1037,7 +1037,7 @@ flux-led==1.2.0
|
||||
fnv-hash-fast==2.0.3
|
||||
|
||||
# homeassistant.components.foobot
|
||||
foobot_async==1.0.0
|
||||
foobot_async==1.0.1
|
||||
|
||||
# homeassistant.components.forecast_solar
|
||||
forecast-solar==5.0.1
|
||||
@@ -1244,7 +1244,7 @@ hdate[astral]==1.2.1
|
||||
hdfury==1.6.0
|
||||
|
||||
# homeassistant.components.heatmiser
|
||||
heatmiserV3==2.0.4
|
||||
heatmiserV3==2.0.6
|
||||
|
||||
# homeassistant.components.hegel
|
||||
hegel-ip-client==0.1.4
|
||||
@@ -1516,7 +1516,7 @@ loqedAPI==2.1.11
|
||||
luftdaten==0.7.4
|
||||
|
||||
# homeassistant.components.lunatone
|
||||
lunatone-rest-api-client==0.9.1
|
||||
lunatone-rest-api-client==0.9.2
|
||||
|
||||
# homeassistant.components.lupusec
|
||||
lupupy==0.3.2
|
||||
@@ -1833,7 +1833,7 @@ peblar==0.5.1
|
||||
peco==0.1.2
|
||||
|
||||
# homeassistant.components.pencom
|
||||
pencompy==0.0.3
|
||||
pencompy==0.0.4
|
||||
|
||||
# homeassistant.components.escea
|
||||
pescea==1.0.12
|
||||
@@ -2027,7 +2027,7 @@ pyanglianwater==3.1.2
|
||||
pyaprilaire==0.9.1
|
||||
|
||||
# homeassistant.components.aqvify
|
||||
pyaqvify==0.0.10
|
||||
pyaqvify==0.0.11
|
||||
|
||||
# homeassistant.components.atag
|
||||
pyatag==0.3.5.3
|
||||
@@ -2184,7 +2184,7 @@ pyfido==2.1.2
|
||||
pyfirefly==0.1.12
|
||||
|
||||
# homeassistant.components.fireservicerota
|
||||
pyfireservicerota==0.0.46
|
||||
pyfireservicerota==0.0.49
|
||||
|
||||
# homeassistant.components.flic
|
||||
pyflic==2.0.4
|
||||
@@ -2238,7 +2238,7 @@ pyhomeworks==1.1.2
|
||||
pyialarm==2.2.0
|
||||
|
||||
# homeassistant.components.icloud
|
||||
pyicloud==2.4.1
|
||||
pyicloud==2.6.5
|
||||
|
||||
# homeassistant.components.imou
|
||||
pyimouapi==1.2.8
|
||||
@@ -2537,7 +2537,7 @@ pysensibo==1.2.1
|
||||
pysenz==1.0.2
|
||||
|
||||
# homeassistant.components.sesame
|
||||
pysesame2==1.0.1
|
||||
pysesame2==1.0.2
|
||||
|
||||
# homeassistant.components.seventeentrack
|
||||
pyseventeentrack==1.1.3
|
||||
|
||||
@@ -12,11 +12,8 @@ agent will refuse to resolve the new kind.
|
||||
from .models import CheckKind, CheckRunResult, CheckStatus, PackageChange
|
||||
|
||||
MARKER = "<!-- requirements-check -->"
|
||||
# Hidden marker carrying the PR head commit the checks ran against. The agentic
|
||||
# stage's gate job parses it from the previous comment to decide whether any
|
||||
# tracked requirement file changed since then; if not, the agent is skipped.
|
||||
SHA_MARKER_PREFIX = "<!-- requirements-check-sha:"
|
||||
HEADER = "## Check requirements"
|
||||
REPO_URL = "https://github.com/home-assistant/core"
|
||||
|
||||
# Column / bullet labels per check kind, in display order.
|
||||
_CHECK_DISPLAY: tuple[tuple[CheckKind, str], ...] = (
|
||||
@@ -116,13 +113,12 @@ def _details_block(pkg: PackageChange) -> str:
|
||||
|
||||
|
||||
def _intro(result: CheckRunResult) -> str:
|
||||
"""Marker(s), header, and the optional visible commit line."""
|
||||
markers = MARKER
|
||||
"""Marker, header, and the optional commit line the gate reads back."""
|
||||
parts: list[str] = []
|
||||
if result.head_sha:
|
||||
markers = f"{MARKER}\n{SHA_MARKER_PREFIX} {result.head_sha} -->"
|
||||
parts.append(f"Checked at commit `{result.head_sha[:7]}`.")
|
||||
return "\n\n".join([f"{markers}\n{HEADER}", *parts])
|
||||
commit = f"[`{result.head_sha[:7]}`]({REPO_URL}/commit/{result.head_sha})"
|
||||
parts.append(f"Checked at commit {commit}.")
|
||||
return "\n\n".join([f"{MARKER}\n{HEADER}", *parts])
|
||||
|
||||
|
||||
def render_comment(result: CheckRunResult) -> str:
|
||||
|
||||
@@ -29,6 +29,7 @@ from . import (
|
||||
mypy_config,
|
||||
quality_scale,
|
||||
requirements,
|
||||
sensor,
|
||||
services,
|
||||
ssdp,
|
||||
translations,
|
||||
@@ -69,6 +70,7 @@ HASS_PLUGINS = [
|
||||
mdi_icons,
|
||||
mypy_config,
|
||||
metadata,
|
||||
sensor,
|
||||
]
|
||||
|
||||
ALL_PLUGIN_NAMES = [
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Generate the sensor.json file."""
|
||||
|
||||
import json
|
||||
|
||||
from homeassistant.components.sensor.const import (
|
||||
NON_NUMERIC_DEVICE_CLASSES,
|
||||
SensorDeviceClass,
|
||||
)
|
||||
|
||||
from .model import Config, Integration
|
||||
|
||||
PATH = "homeassistant/generated/sensor.json"
|
||||
|
||||
|
||||
def _generate() -> str:
|
||||
"""Generate the sensor data."""
|
||||
numeric_device_classes = sorted(
|
||||
device_class.value
|
||||
for device_class in set(SensorDeviceClass) - NON_NUMERIC_DEVICE_CLASSES
|
||||
)
|
||||
return json.dumps({"numeric_device_classes": numeric_device_classes}, indent=2)
|
||||
|
||||
|
||||
def validate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
"""Validate sensor.json."""
|
||||
path = config.root / PATH
|
||||
config.cache["sensor"] = content = _generate()
|
||||
|
||||
if path.read_text() != content + "\n":
|
||||
config.add_error(
|
||||
"sensor",
|
||||
"File sensor.json is not up to date. Run python3 -m script.hassfest",
|
||||
fixable=True,
|
||||
)
|
||||
|
||||
|
||||
def generate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
"""Generate sensor.json."""
|
||||
path = config.root / PATH
|
||||
path.write_text(f"{config.cache['sensor']}\n")
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"deviceKey": "DeviceKey_1",
|
||||
"name": "Device 1"
|
||||
},
|
||||
{
|
||||
"deviceKey": "DeviceKey_2",
|
||||
"name": "Device 2"
|
||||
},
|
||||
{
|
||||
"deviceKey": "DeviceKey_3",
|
||||
"name": "Device 3"
|
||||
}
|
||||
]
|
||||
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"accountId": "test_account_id"
|
||||
"accountId": "test_account_id",
|
||||
"name": "Mr Aquarius"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"dateTime": "2026-06-04T09:36:06+00:00",
|
||||
"dateTime": "2026-06-14T09:36:06+00:00",
|
||||
"waterLevel": -0.136786005,
|
||||
"meterValue": 0.823213995,
|
||||
"status": null
|
||||
"status": null,
|
||||
"temperature": 12.3,
|
||||
"volume": 345.0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"accountId": "test_account_id"
|
||||
}
|
||||
@@ -3,15 +3,19 @@
|
||||
dict({
|
||||
'device_data': dict({
|
||||
'DeviceKey_1': dict({
|
||||
'dateTime': '2026-06-04T09:36:06+00:00',
|
||||
'dateTime': '2026-06-14T09:36:06+00:00',
|
||||
'meterValue': 0.823213995,
|
||||
'status': None,
|
||||
'temperature': 12.3,
|
||||
'volume': 345.0,
|
||||
'waterLevel': -0.136786005,
|
||||
}),
|
||||
'DeviceKey_2': dict({
|
||||
'dateTime': '2026-06-04T09:36:06+00:00',
|
||||
'dateTime': '2026-06-14T09:36:06+00:00',
|
||||
'meterValue': 0.823213995,
|
||||
'status': None,
|
||||
'temperature': 12.3,
|
||||
'volume': 345.0,
|
||||
'waterLevel': -0.136786005,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -57,6 +57,122 @@
|
||||
'state': '0.823213995',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_snapshot[sensor.device_1_stored_volume-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.device_1_stored_volume',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Stored volume',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 0,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLUME_STORAGE: 'volume_storage'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Stored volume',
|
||||
'platform': 'aqvify',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'test_account_id_DeviceKey_1_volume',
|
||||
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_snapshot[sensor.device_1_stored_volume-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'volume_storage',
|
||||
'friendly_name': 'Device 1 Stored volume',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.device_1_stored_volume',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '345.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_snapshot[sensor.device_1_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.device_1_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Temperature',
|
||||
'platform': 'aqvify',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'test_account_id_DeviceKey_1_temperature',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_snapshot[sensor.device_1_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'Device 1 Temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.device_1_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '12.3',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_snapshot[sensor.device_1_water_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
@@ -173,6 +289,122 @@
|
||||
'state': '0.823213995',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_snapshot[sensor.device_2_stored_volume-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.device_2_stored_volume',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Stored volume',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 0,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLUME_STORAGE: 'volume_storage'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Stored volume',
|
||||
'platform': 'aqvify',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'test_account_id_DeviceKey_2_volume',
|
||||
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_snapshot[sensor.device_2_stored_volume-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'volume_storage',
|
||||
'friendly_name': 'Device 2 Stored volume',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.device_2_stored_volume',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '345.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_snapshot[sensor.device_2_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.device_2_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Temperature',
|
||||
'platform': 'aqvify',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'test_account_id_DeviceKey_2_temperature',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_snapshot[sensor.device_2_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'Device 2 Temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.device_2_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '12.3',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_snapshot[sensor.device_2_water_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_load_json_object_fixture
|
||||
|
||||
|
||||
async def test_full_flow(
|
||||
@@ -32,6 +32,36 @@ async def test_full_flow(
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Mr Aquarius"
|
||||
assert result["data"] == {
|
||||
CONF_API_KEY: "test-api-key",
|
||||
}
|
||||
assert result["result"].unique_id == "test_account_id"
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_missing_username(
|
||||
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_aqvify_client: MagicMock
|
||||
) -> None:
|
||||
"""Test full flow with missing name in account."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {}
|
||||
|
||||
mock_aqvify_client.async_get_account_id.return_value = AqvifyAccount(
|
||||
await async_load_json_object_fixture(hass, "empty_name_account.json", DOMAIN)
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_API_KEY: "test-api-key",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Aqvify"
|
||||
assert result["data"] == {
|
||||
@@ -88,7 +118,7 @@ async def test_form_invalid(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Aqvify"
|
||||
assert result["title"] == "Mr Aquarius"
|
||||
assert result["data"] == {
|
||||
CONF_API_KEY: "test-api-key",
|
||||
}
|
||||
|
||||
@@ -84,8 +84,10 @@ async def test_device_registry_integration(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
|
||||
# Snapshot the devices to ensure they have the correct structure
|
||||
assert device_entries == snapshot
|
||||
sorted_devices = sorted(
|
||||
device_entries, key=lambda dev_entry: dev_entry.serial_number
|
||||
)
|
||||
assert sorted_devices == snapshot
|
||||
|
||||
|
||||
async def test_setup_entry_auth_error_triggers_reauth(
|
||||
@@ -132,6 +134,31 @@ async def test_autoremove_stale_devices(
|
||||
assert hass.states.get("sensor.device_2_water_level") is None
|
||||
|
||||
|
||||
async def test_devices_multiple_created_count(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_aqvify_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that added devices are created."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert len(device_registry.devices) == 2
|
||||
assert hass.states.get("sensor.device_3_water_level") is None
|
||||
|
||||
mock_aqvify_client.async_get_devices.return_value = AqvifyDevices(
|
||||
await async_load_json_array_fixture(hass, "added_devices.json", DOMAIN)
|
||||
)
|
||||
|
||||
freezer.tick(timedelta(seconds=240))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(device_registry.devices) == 3
|
||||
assert hass.states.get("sensor.device_3_water_level").state == EXPECTED_WATER_LEVEL
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "log_message", "expected_state"),
|
||||
[
|
||||
|
||||
@@ -5,6 +5,7 @@ from http import HTTPStatus
|
||||
import io
|
||||
from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, mock_open, patch
|
||||
|
||||
from aiohttp import hdrs
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from webrtc_models import RTCIceCandidateInit
|
||||
@@ -691,6 +692,47 @@ async def test_camera_proxy_stream(hass_client: ClientSessionGenerator) -> None:
|
||||
assert response.status == HTTPStatus.BAD_GATEWAY
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_camera")
|
||||
async def test_camera_proxy_unauthenticated(
|
||||
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
|
||||
) -> None:
|
||||
"""Test camera_proxy with an unauthenticated client."""
|
||||
client = await hass_client_no_auth()
|
||||
|
||||
# Invalid token and no Authorization header: skip ban by 403
|
||||
resp = await client.get("/api/camera_proxy/camera.demo_camera?token=invalid_token")
|
||||
assert resp.status == HTTPStatus.FORBIDDEN
|
||||
|
||||
# An invalid Bearer token is a real auth attempt, return 401 so the ban
|
||||
# middleware can handle it.
|
||||
resp = await client.get(
|
||||
"/api/camera_proxy/camera.demo_camera",
|
||||
headers={hdrs.AUTHORIZATION: "blabla"},
|
||||
)
|
||||
assert resp.status == HTTPStatus.UNAUTHORIZED
|
||||
|
||||
# A valid access token in the query is accepted.
|
||||
state = hass.states.get("camera.demo_camera")
|
||||
resp = await client.get(state.attributes["entity_picture"])
|
||||
assert resp.status == HTTPStatus.OK
|
||||
assert await resp.read() == b"Test"
|
||||
|
||||
# Unknown entity while unauthenticated returns 401
|
||||
resp = await client.get("/api/camera_proxy/camera.unknown")
|
||||
assert resp.status == HTTPStatus.UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_camera")
|
||||
async def test_camera_proxy_authenticated_unknown_entity(
|
||||
hass_client: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Test camera_proxy for an unknown entity with an authenticated client."""
|
||||
client = await hass_client()
|
||||
|
||||
resp = await client.get("/api/camera_proxy/camera.unknown")
|
||||
assert resp.status == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_camera")
|
||||
async def test_state_streaming(hass: HomeAssistant) -> None:
|
||||
"""Camera state."""
|
||||
|
||||
@@ -43,8 +43,12 @@ async def test_async_setup_entry_device_not_found(
|
||||
"""Test setup raises ConfigEntryNotReady when BLE device is not found."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# Do not inject BLE info — device is not in the cache
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.async_address_reachability_diagnostics",
|
||||
return_value="no_reason",
|
||||
):
|
||||
# Do not inject BLE info — device is not in the cache
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from ccm15 import CCM15DeviceState
|
||||
from ccm15 import CCM15DeviceState, CCM15SlaveDevice
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.ccm15.const import DOMAIN
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
ATTR_FAN_MODE,
|
||||
ATTR_HVAC_MODE,
|
||||
ATTR_TEMPERATURE,
|
||||
@@ -21,9 +22,16 @@ from homeassistant.components.climate import (
|
||||
SERVICE_TURN_ON,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, SERVICE_TURN_OFF
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_HOST,
|
||||
CONF_PORT,
|
||||
SERVICE_TURN_OFF,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
@@ -130,3 +138,38 @@ async def test_climate_state(
|
||||
|
||||
assert hass.states.get("climate.midea_0") == snapshot
|
||||
assert hass.states.get("climate.midea_1") == snapshot
|
||||
|
||||
|
||||
async def test_climate_fahrenheit_unit(hass: HomeAssistant) -> None:
|
||||
"""A controller set to Fahrenheit is reported in Fahrenheit."""
|
||||
hass.config.units = US_CUSTOMARY_SYSTEM
|
||||
device_state = CCM15DeviceState(
|
||||
devices={0: CCM15SlaveDevice(bytes.fromhex("01000041c0004b"))}
|
||||
)
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="1.1.1.1",
|
||||
data={CONF_HOST: "1.1.1.1", CONF_PORT: 80},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.ccm15.coordinator.CCM15Device.get_status_async",
|
||||
return_value=device_state,
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The entity must report the device's Fahrenheit unit, not hardcoded Celsius.
|
||||
climate_component = hass.data[CLIMATE_DOMAIN]
|
||||
entity = climate_component.get_entity("climate.midea_0")
|
||||
assert entity is not None
|
||||
assert entity.temperature_unit == UnitOfTemperature.FAHRENHEIT
|
||||
|
||||
# With the entity already in Fahrenheit under the US system, the device's
|
||||
# native values pass through unconverted; were it still Celsius they would
|
||||
# be converted and differ.
|
||||
state = hass.states.get("climate.midea_0")
|
||||
assert state is not None
|
||||
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 75
|
||||
assert state.attributes[ATTR_TEMPERATURE] == 86
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# serializer version: 1
|
||||
# name: test_entities[button.edifier_r1700bt_bluetooth-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.edifier_r1700bt_bluetooth',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Bluetooth',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Bluetooth',
|
||||
'platform': 'edifier_infrared',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'bluetooth',
|
||||
'unique_id': '01JTEST0000000000000000000_bluetooth',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[button.edifier_r1700bt_bluetooth-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Edifier R1700BT Bluetooth',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.edifier_r1700bt_bluetooth',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[button.edifier_r1700bt_fx_off-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.edifier_r1700bt_fx_off',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'FX off',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'FX off',
|
||||
'platform': 'edifier_infrared',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'fx_off',
|
||||
'unique_id': '01JTEST0000000000000000000_fx_off',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[button.edifier_r1700bt_fx_off-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Edifier R1700BT FX off',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.edifier_r1700bt_fx_off',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[button.edifier_r1700bt_fx_on-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.edifier_r1700bt_fx_on',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'FX on',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'FX on',
|
||||
'platform': 'edifier_infrared',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'fx_on',
|
||||
'unique_id': '01JTEST0000000000000000000_fx_on',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[button.edifier_r1700bt_fx_on-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Edifier R1700BT FX on',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.edifier_r1700bt_fx_on',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[button.edifier_r1700bt_line_1-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.edifier_r1700bt_line_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Line 1',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Line 1',
|
||||
'platform': 'edifier_infrared',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'line_1',
|
||||
'unique_id': '01JTEST0000000000000000000_line_1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[button.edifier_r1700bt_line_1-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Edifier R1700BT Line 1',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.edifier_r1700bt_line_1',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[button.edifier_r1700bt_line_2-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.edifier_r1700bt_line_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Line 2',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Line 2',
|
||||
'platform': 'edifier_infrared',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'line_2',
|
||||
'unique_id': '01JTEST0000000000000000000_line_2',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[button.edifier_r1700bt_line_2-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Edifier R1700BT Line 2',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.edifier_r1700bt_line_2',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Tests for the Edifier Infrared button platform."""
|
||||
|
||||
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
|
||||
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
from tests.components.common import assert_availability_follows_source_entity
|
||||
from tests.components.infrared import EMITTER_ENTITY_ID
|
||||
from tests.components.infrared.common import MockInfraredEmitterEntity
|
||||
|
||||
BLUETOOTH_BUTTON_ENTITY_ID = "button.edifier_r1700bt_bluetooth"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Return platforms to set up."""
|
||||
return [Platform.BUTTON]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_entities(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the button entities are created with correct attributes."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "expected_code"),
|
||||
[
|
||||
("button.edifier_r1700bt_bluetooth", EdifierR1700BTCode.BLUETOOTH),
|
||||
("button.edifier_r1700bt_line_1", EdifierR1700BTCode.LINE_1),
|
||||
("button.edifier_r1700bt_line_2", EdifierR1700BTCode.LINE_2),
|
||||
("button.edifier_r1700bt_fx_on", EdifierR1700BTCode.FX_ON),
|
||||
("button.edifier_r1700bt_fx_off", EdifierR1700BTCode.FX_OFF),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_button_press_sends_correct_code(
|
||||
hass: HomeAssistant,
|
||||
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
|
||||
entity_id: str,
|
||||
expected_code: EdifierR1700BTCode,
|
||||
) -> None:
|
||||
"""Test each button press sends the correct IR code."""
|
||||
await hass.services.async_call(
|
||||
BUTTON_DOMAIN,
|
||||
SERVICE_PRESS,
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert len(mock_infrared_emitter_entity.send_command_calls) == 1
|
||||
assert mock_infrared_emitter_entity.send_command_calls[0] == expected_code
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_button_availability_follows_ir_entity(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test button becomes unavailable when IR entity is unavailable."""
|
||||
await assert_availability_follows_source_entity(
|
||||
hass, BLUETOOTH_BUTTON_ENTITY_ID, EMITTER_ENTITY_ID
|
||||
)
|
||||
@@ -251,7 +251,7 @@ async def test_fetch_image_unauthenticated(
|
||||
assert body == b"Test"
|
||||
|
||||
resp = await client.get("/api/image_proxy/image.unknown")
|
||||
assert resp.status == HTTPStatus.NOT_FOUND
|
||||
assert resp.status == HTTPStatus.UNAUTHORIZED
|
||||
|
||||
|
||||
@respx.mock
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import patch
|
||||
|
||||
from aiohttp import hdrs
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -112,6 +113,47 @@ async def test_get_image_http(
|
||||
assert content == b"image"
|
||||
|
||||
|
||||
async def test_get_image_http_unauthenticated(
|
||||
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
|
||||
) -> None:
|
||||
"""Test get image via http with an unauthenticated client."""
|
||||
await async_setup_component(hass, DOMAIN, {"media_player": {"platform": "demo"}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
|
||||
# Invalid token and no Authorization header: skip ban by 403
|
||||
resp = await client.get(
|
||||
"/api/media_player_proxy/media_player.bedroom?token=invalid_token"
|
||||
)
|
||||
assert resp.status == HTTPStatus.FORBIDDEN
|
||||
|
||||
# An invalid Bearer token is a real auth attempt, return 401 so the ban
|
||||
# middleware can handle it.
|
||||
resp = await client.get(
|
||||
"/api/media_player_proxy/media_player.bedroom",
|
||||
headers={hdrs.AUTHORIZATION: "blabla"},
|
||||
)
|
||||
assert resp.status == HTTPStatus.UNAUTHORIZED
|
||||
|
||||
# Unknown entity while unauthenticated returns 401
|
||||
resp = await client.get("/api/media_player_proxy/media_player.unknown")
|
||||
assert resp.status == HTTPStatus.UNAUTHORIZED
|
||||
|
||||
|
||||
async def test_get_image_http_authenticated_unknown_entity(
|
||||
hass: HomeAssistant, hass_client: ClientSessionGenerator
|
||||
) -> None:
|
||||
"""Test get image via http for an unknown entity with an authenticated client."""
|
||||
await async_setup_component(hass, DOMAIN, {"media_player": {"platform": "demo"}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_client()
|
||||
|
||||
resp = await client.get("/api/media_player_proxy/media_player.unknown")
|
||||
assert resp.status == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
async def test_get_image_http_remote(
|
||||
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
|
||||
) -> None:
|
||||
|
||||
@@ -6,6 +6,7 @@ from homeassistant.components.subaru.const import (
|
||||
API_GEN_1,
|
||||
API_GEN_2,
|
||||
API_GEN_3,
|
||||
API_GEN_4,
|
||||
VEHICLE_API_GEN,
|
||||
VEHICLE_HAS_EV,
|
||||
VEHICLE_HAS_REMOTE_SERVICE,
|
||||
@@ -21,6 +22,7 @@ from homeassistant.components.subaru.const import (
|
||||
TEST_VIN_1_G1 = "JF2ABCDE6L0000001"
|
||||
TEST_VIN_2_EV = "JF2ABCDE6L0000002"
|
||||
TEST_VIN_3_G3 = "JF2ABCDE6L0000003"
|
||||
TEST_VIN_4_G4 = "JF2ABCDE6L0000004"
|
||||
|
||||
VEHICLE_DATA = {
|
||||
TEST_VIN_1_G1: {
|
||||
@@ -56,6 +58,17 @@ VEHICLE_DATA = {
|
||||
VEHICLE_HAS_REMOTE_SERVICE: True,
|
||||
VEHICLE_HAS_SAFETY_SERVICE: True,
|
||||
},
|
||||
TEST_VIN_4_G4: {
|
||||
VEHICLE_VIN: TEST_VIN_4_G4,
|
||||
VEHICLE_MODEL_YEAR: "2026",
|
||||
VEHICLE_MODEL_NAME: "Outback",
|
||||
VEHICLE_NAME: "test_vehicle_4",
|
||||
VEHICLE_HAS_EV: False,
|
||||
VEHICLE_API_GEN: API_GEN_4,
|
||||
VEHICLE_HAS_REMOTE_START: True,
|
||||
VEHICLE_HAS_REMOTE_SERVICE: True,
|
||||
VEHICLE_HAS_SAFETY_SERVICE: True,
|
||||
},
|
||||
}
|
||||
|
||||
MOCK_DATETIME = datetime.fromtimestamp(1595560000, UTC)
|
||||
|
||||
@@ -12,12 +12,14 @@ from homeassistant.components.subaru.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .api_responses import (
|
||||
TEST_VIN_1_G1,
|
||||
TEST_VIN_2_EV,
|
||||
TEST_VIN_3_G3,
|
||||
TEST_VIN_4_G4,
|
||||
VEHICLE_DATA,
|
||||
VEHICLE_STATUS_EV,
|
||||
VEHICLE_STATUS_G3,
|
||||
@@ -58,6 +60,29 @@ async def test_setup_g3(hass: HomeAssistant, subaru_config_entry) -> None:
|
||||
assert check_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
async def test_setup_g4(hass: HomeAssistant, subaru_config_entry) -> None:
|
||||
"""Test setup with a G4 vehicle (2026+ models report api_gen "g4")."""
|
||||
await setup_subaru_config_entry(
|
||||
hass,
|
||||
subaru_config_entry,
|
||||
vehicle_list=[TEST_VIN_4_G4],
|
||||
vehicle_data=VEHICLE_DATA[TEST_VIN_4_G4],
|
||||
vehicle_status=VEHICLE_STATUS_G3,
|
||||
)
|
||||
check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id)
|
||||
assert check_entry
|
||||
assert check_entry.state is ConfigEntryState.LOADED
|
||||
# Gen4 must receive both Gen2+ and Gen3+ sensor sets; without this, only
|
||||
# the odometer was created on 2026 model year vehicles.
|
||||
entity_registry = er.async_get(hass)
|
||||
assert entity_registry.async_get_entity_id(
|
||||
"sensor", DOMAIN, f"{TEST_VIN_4_G4}_AVG_FUEL_CONSUMPTION"
|
||||
)
|
||||
assert entity_registry.async_get_entity_id(
|
||||
"sensor", DOMAIN, f"{TEST_VIN_4_G4}_REMAINING_FUEL_PERCENT"
|
||||
)
|
||||
|
||||
|
||||
async def test_setup_g1(hass: HomeAssistant, subaru_config_entry) -> None:
|
||||
"""Test setup with a G1 vehicle."""
|
||||
await setup_subaru_config_entry(
|
||||
|
||||
@@ -319,6 +319,16 @@ async def test_reauth_flow_update_configuration(
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
context = next(
|
||||
flow["context"]
|
||||
for flow in hass.config_entries.flow.async_progress()
|
||||
if flow["flow_id"] == result["flow_id"]
|
||||
)
|
||||
assert context["title_placeholders"] == {
|
||||
"host": config_entry.data[CONF_HOST],
|
||||
"name": config_entry.title,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.unifi.UnifiHub.available", new_callable=PropertyMock
|
||||
) as ws_mock:
|
||||
@@ -439,7 +449,9 @@ async def test_discover_unifi_negative(hass: HomeAssistant) -> None:
|
||||
INTEGRATION_DISCOVERY_INFO = {
|
||||
"source_ip": "10.0.0.1",
|
||||
"hw_addr": "e0:63:da:20:14:a9",
|
||||
"name": "Dream Machine Pro",
|
||||
"hostname": "UniFi-Dream-Machine",
|
||||
"product_name": "UDMPRO",
|
||||
"platform": "UCG-Ultra",
|
||||
"direct_connect_domain": "x.ui.direct",
|
||||
}
|
||||
@@ -461,13 +473,54 @@ async def test_flow_integration_discovery(hass: HomeAssistant) -> None:
|
||||
for flow in hass.config_entries.flow.async_progress()
|
||||
if flow["flow_id"] == result["flow_id"]
|
||||
)
|
||||
assert context["title_placeholders"] == {
|
||||
"host": "x.ui.direct",
|
||||
"site": "default",
|
||||
}
|
||||
assert context["configuration_url"] == "https://x.ui.direct"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("extra_info", "expected_name"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"name": "Dream Machine Pro",
|
||||
"hostname": "UniFi-Dream-Machine",
|
||||
"product_name": "UDMPRO",
|
||||
},
|
||||
"Dream Machine Pro",
|
||||
),
|
||||
(
|
||||
{"hostname": "UniFi-Dream-Machine", "product_name": "UDMPRO"},
|
||||
"UniFi-Dream-Machine",
|
||||
),
|
||||
({"product_name": "UDMPRO"}, "UDMPRO"),
|
||||
({}, "UniFi Network"),
|
||||
],
|
||||
ids=["name", "hostname", "product_name", "fallback"],
|
||||
)
|
||||
async def test_flow_integration_discovery_title(
|
||||
hass: HomeAssistant, extra_info: dict[str, str], expected_name: str
|
||||
) -> None:
|
||||
"""Test discovery title priority: name, hostname, product_name, then fallback."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
|
||||
data={
|
||||
"source_ip": "10.0.0.1",
|
||||
"hw_addr": "e0:63:da:20:14:a9",
|
||||
"direct_connect_domain": "x.ui.direct",
|
||||
**extra_info,
|
||||
},
|
||||
)
|
||||
context = next(
|
||||
flow["context"]
|
||||
for flow in hass.config_entries.flow.async_progress()
|
||||
if flow["flow_id"] == result["flow_id"]
|
||||
)
|
||||
assert context["title_placeholders"] == {
|
||||
"host": "10.0.0.1",
|
||||
"name": expected_name,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("config_entry")
|
||||
async def test_flow_integration_discovery_aborts_if_host_already_exists(
|
||||
hass: HomeAssistant,
|
||||
@@ -497,16 +550,6 @@ async def test_flow_integration_discovery_uses_direct_connect_domain(
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
context = next(
|
||||
flow["context"]
|
||||
for flow in hass.config_entries.flow.async_progress()
|
||||
if flow["flow_id"] == result["flow_id"]
|
||||
)
|
||||
assert context["title_placeholders"] == {
|
||||
"host": "x.ui.direct",
|
||||
"site": "default",
|
||||
}
|
||||
|
||||
schema_defaults = {
|
||||
marker.schema: marker.default()
|
||||
for marker in result["data_schema"].schema
|
||||
@@ -599,6 +642,6 @@ async def test_flow_integration_discovery_gets_form_with_ignored_entry(
|
||||
if flow["flow_id"] == result["flow_id"]
|
||||
)
|
||||
assert context["title_placeholders"] == {
|
||||
"host": "x.ui.direct",
|
||||
"site": "default",
|
||||
"host": "10.0.0.1",
|
||||
"name": "Dream Machine Pro",
|
||||
}
|
||||
|
||||
@@ -918,6 +918,38 @@ async def test_discovered_by_unifi_discovery_partial(
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("overrides", "expected_name"),
|
||||
[
|
||||
(
|
||||
{"name": "Front Gate", "hostname": "unvr", "product_name": "UNVR"},
|
||||
"Front Gate",
|
||||
),
|
||||
({"name": None, "hostname": "unvr", "product_name": "UNVR"}, "unvr"),
|
||||
(
|
||||
{"name": None, "hostname": None, "product_name": "Dream Machine"},
|
||||
"Dream Machine",
|
||||
),
|
||||
],
|
||||
ids=["console-name", "hostname", "product-name"],
|
||||
)
|
||||
async def test_discovery_name_resolution(
|
||||
hass: HomeAssistant, overrides: dict[str, str | None], expected_name: str
|
||||
) -> None:
|
||||
"""Test the discovery title prefers the console name over raw platform codes."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
|
||||
data={**UNIFI_DISCOVERY_DICT, **overrides},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "discovery_confirm"
|
||||
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
|
||||
assert flows[0]["context"]["title_placeholders"]["name"] == expected_name
|
||||
|
||||
|
||||
async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+122
-131
@@ -1,8 +1,9 @@
|
||||
"""The tests for the trigger helper."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import AbstractContextManager, nullcontext as does_not_raise
|
||||
import datetime
|
||||
import inspect
|
||||
import io
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -55,6 +56,7 @@ from homeassistant.helpers.automation import (
|
||||
DomainSpec,
|
||||
move_top_level_schema_fields_to_options,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.trigger import (
|
||||
ATTR_BEHAVIOR,
|
||||
@@ -97,6 +99,23 @@ from tests.common import (
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
async def _call_in_order(funcs: list[Callable[[], Any]], *, reverse: bool) -> list[Any]:
|
||||
"""Call each function in order (or reversed), awaiting awaitable results.
|
||||
|
||||
Used to control the registration order of test listeners. Results are
|
||||
returned in the order of `funcs`, regardless of call order, so callers
|
||||
can unpack them consistently.
|
||||
"""
|
||||
indexes = range(len(funcs))
|
||||
results: list[Any] = [None] * len(funcs)
|
||||
for index in reversed(indexes) if reverse else indexes:
|
||||
result = funcs[index]()
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
results[index] = result
|
||||
return results
|
||||
|
||||
|
||||
async def test_bad_trigger_platform(hass: HomeAssistant) -> None:
|
||||
"""Test bad trigger platform."""
|
||||
with pytest.raises(vol.Invalid) as ex:
|
||||
@@ -2130,17 +2149,28 @@ async def test_numerical_state_attribute_changed_trigger_thresholds(
|
||||
assert len(service_calls) == (1 if expected_fires else 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("adversary_first", "expected_calls"),
|
||||
[
|
||||
pytest.param(True, 0, id="adversary_first"),
|
||||
pytest.param(False, 1, id="trigger_first"),
|
||||
],
|
||||
)
|
||||
async def test_numerical_state_trigger_threshold_entity_same_loop_iteration(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
adversary_first: bool,
|
||||
expected_calls: int,
|
||||
) -> None:
|
||||
"""Test threshold entity changing in the same loop iteration.
|
||||
"""Test the threshold entity changed synchronously during the value dispatch.
|
||||
|
||||
The threshold is read from the live state machine when the state change
|
||||
event is evaluated, one event loop iteration after it was fired. We do
|
||||
not guarantee exact sequencing between the threshold entity and the
|
||||
targeted entity: a threshold change in the same iteration as a tracked
|
||||
value change is applied to the evaluation of that change, even though
|
||||
the threshold changed after the tracked value.
|
||||
Threshold entities are read from the live state machine; unlike targeted
|
||||
entities, they are not snapshotted in a state view. So when an adversary
|
||||
listener changes the threshold synchronously while the tracked value's
|
||||
state change is dispatched, the outcome depends on listener order: the
|
||||
value rises to 150 while the threshold is 100, which should fire, but if
|
||||
the adversary runs first the trigger reads the bumped threshold (200) and
|
||||
misses the crossing.
|
||||
"""
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
@@ -2156,49 +2186,46 @@ async def test_numerical_state_trigger_threshold_entity_same_loop_iteration(
|
||||
hass.states.async_set("sensor.threshold", "100")
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "test.attribute_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
|
||||
CONF_OPTIONS: {
|
||||
"threshold": {
|
||||
"type": "above",
|
||||
"value": {"entity": "sensor.threshold"},
|
||||
}
|
||||
},
|
||||
@callback
|
||||
def bump_threshold(event: Event[EventStateChangedData]) -> None:
|
||||
"""Bump the threshold above the new value when the value changes."""
|
||||
if event.data["entity_id"] == "test.test_entity":
|
||||
hass.states.async_set("sensor.threshold", "200")
|
||||
|
||||
automation_config = {
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "test.attribute_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
|
||||
CONF_OPTIONS: {
|
||||
"threshold": {
|
||||
"type": "above",
|
||||
"value": {"entity": "sensor.threshold"},
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
}
|
||||
|
||||
# The listener order in the dispatch follows registration order.
|
||||
await _call_in_order(
|
||||
[
|
||||
lambda: async_setup_component(hass, automation.DOMAIN, automation_config),
|
||||
lambda: async_track_state_change_event(
|
||||
hass, ["test.test_entity"], bump_threshold
|
||||
),
|
||||
],
|
||||
reverse=adversary_first,
|
||||
)
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# The tracked value rises above the threshold (100) and the threshold
|
||||
# rises above the tracked value in the same event loop iteration. The
|
||||
# value change is evaluated against the live threshold (200), so the
|
||||
# trigger does not fire, even though the threshold was 100 when the
|
||||
# value changed.
|
||||
# value -> 150. At that instant the threshold is 100, so 150 > 100 should
|
||||
# fire; whether it does depends on whether the adversary bumped the
|
||||
# threshold to 200 before the trigger evaluated the value change.
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": 150})
|
||||
hass.states.async_set("sensor.threshold", "200")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# The tracked value changes but stays below the threshold (200), and
|
||||
# the threshold drops below the tracked value in the same event loop
|
||||
# iteration. The value change is evaluated against the live threshold
|
||||
# (100), so the trigger fires, even though the threshold was 200 when
|
||||
# the value changed. Threshold changes alone never fire the trigger.
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": 190})
|
||||
hass.states.async_set("sensor.threshold", "100")
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert len(service_calls) == expected_calls
|
||||
|
||||
|
||||
async def test_numerical_state_attribute_changed_entity_limit_unit_validation(
|
||||
@@ -4047,15 +4074,10 @@ async def test_entity_trigger_first_same_loop_iteration(
|
||||
) -> None:
|
||||
"""Test behavior first when two entities change in the same loop iteration.
|
||||
|
||||
The trigger's state change listener is dispatched via loop.call_soon, one
|
||||
event loop iteration after the state change event is fired. If a second
|
||||
tracked entity changes state in the same iteration as the first, both
|
||||
events are evaluated against the live state machine, which already
|
||||
includes both changes.
|
||||
|
||||
This test documents existing unwanted behavior: entity_a was the first
|
||||
entity to turn on, so the trigger should fire exactly once for entity_a,
|
||||
but both events count two matching entities and the trigger never fires.
|
||||
State change listeners are called synchronously, so when two tracked
|
||||
entities turn on as separate updates in the same iteration, entity_a's
|
||||
event is evaluated before entity_b's update is applied. entity_a turns on
|
||||
first, so the trigger fires exactly once, for entity_a.
|
||||
"""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
@@ -4068,15 +4090,14 @@ async def test_entity_trigger_first_same_loop_iteration(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_FIRST, calls, duration=None
|
||||
)
|
||||
|
||||
# Both entities turn on in the same event loop iteration, before the
|
||||
# trigger's deferred listener has a chance to run.
|
||||
# entity_a then entity_b turn on as separate updates in the same iteration.
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Unwanted: the trigger should fire exactly once, for entity_a, which
|
||||
# was the first entity to turn on, but it doesn't.
|
||||
assert len(calls) == 0
|
||||
# entity_a was the first to turn on, so the trigger fires once for it.
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_a
|
||||
|
||||
unsub()
|
||||
|
||||
@@ -4088,12 +4109,10 @@ async def test_entity_trigger_first_no_fire_on_swap_same_loop_iteration(
|
||||
|
||||
When one entity is already on and, within a single event loop iteration,
|
||||
a second entity turns on and the first turns off, the number of matching
|
||||
entities goes 1 -> 2 -> 1 and never reaches zero, so the trigger should
|
||||
not fire for the second entity: it was not the first to turn on.
|
||||
|
||||
This test documents existing unwanted behavior: entity_b's event is
|
||||
evaluated against the live state machine, which already shows entity_a
|
||||
off, counting exactly one match — and the trigger fires spuriously.
|
||||
entities goes 1 -> 2 -> 1 and never reaches zero, so the trigger does not
|
||||
fire for the second entity: it was not the first to turn on. State change
|
||||
listeners are called synchronously, so entity_b's event is evaluated while
|
||||
entity_a is still on (two matches).
|
||||
"""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
@@ -4117,10 +4136,8 @@ async def test_entity_trigger_first_no_fire_on_swap_same_loop_iteration(
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Unwanted: B was never the first matching entity, the trigger should
|
||||
# not fire.
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_b
|
||||
# B was never the first matching entity, so the trigger does not fire.
|
||||
assert len(calls) == 0
|
||||
|
||||
unsub()
|
||||
|
||||
@@ -4130,13 +4147,10 @@ async def test_entity_trigger_all_same_loop_iteration(
|
||||
) -> None:
|
||||
"""Test behavior all when two entities change in the same loop iteration.
|
||||
|
||||
Both entities turn on in a single event loop iteration. Only the second
|
||||
event completes the all-match, so the trigger should fire exactly once,
|
||||
for the second entity.
|
||||
|
||||
This test documents existing unwanted behavior: both events are
|
||||
evaluated against the live state machine, which already shows both
|
||||
entities on, and the trigger fires twice.
|
||||
Both entities turn on in a single event loop iteration. State change
|
||||
listeners are called synchronously, so entity_a's event is evaluated
|
||||
before entity_b's update is applied: only entity_b's event completes the
|
||||
all-match, so the trigger fires exactly once, for entity_b.
|
||||
"""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
@@ -4149,17 +4163,14 @@ async def test_entity_trigger_all_same_loop_iteration(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_ALL, calls, duration=None
|
||||
)
|
||||
|
||||
# Both entities turn on in the same event loop iteration, before the
|
||||
# trigger's deferred listener has a chance to run.
|
||||
# entity_a then entity_b turn on as separate updates in the same iteration.
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Unwanted: the all-match was completed by entity_b turning on, so the
|
||||
# trigger should fire exactly once, for entity_b — but it fires twice.
|
||||
assert len(calls) == 2
|
||||
assert calls[0]["entity_id"] == entity_a
|
||||
assert calls[1]["entity_id"] == entity_b
|
||||
# entity_b completed the all-match, so the trigger fires once, for it.
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_b
|
||||
|
||||
unsub()
|
||||
|
||||
@@ -4169,16 +4180,10 @@ async def test_entity_trigger_first_resubscribe_same_loop_iteration(
|
||||
) -> None:
|
||||
"""Test behavior first when a registry update causes resubscription.
|
||||
|
||||
A registry update makes the target tracker resubscribe its state change
|
||||
listener. A state change event fired in the same event loop iteration,
|
||||
but not yet dispatched, should still be delivered to the trigger.
|
||||
|
||||
This test documents existing unwanted behavior: the tracker unsubscribes
|
||||
its old listener before subscribing the new one, and as the only
|
||||
subscriber this tears down the shared state change tracker — dropping
|
||||
the event which was fired but not yet dispatched. The trigger never
|
||||
fires: entity_a's event is lost, and entity_b's event counts two
|
||||
matching entities in the live state machine.
|
||||
A registry update in the same iteration as a first-match makes the target
|
||||
tracker resubscribe its state change listener. State change listeners are
|
||||
called synchronously, so entity_a's event is dispatched (and the trigger
|
||||
fires for it) before the registry-driven resubscription.
|
||||
"""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
@@ -4192,20 +4197,20 @@ async def test_entity_trigger_first_resubscribe_same_loop_iteration(
|
||||
)
|
||||
|
||||
# entity_a turns on and an unrelated registry entry is created in the
|
||||
# same event loop iteration. The registry update makes the target tracker
|
||||
# resubscribe before entity_a's state change event is dispatched.
|
||||
# same event loop iteration; the registry update makes the target tracker
|
||||
# resubscribe its state change listener.
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
entity_registry.async_get_or_create("test", "test", "unrelated")
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Unwanted: entity_a was the first entity to turn on, the trigger
|
||||
# should fire — but its event was dropped during resubscription.
|
||||
assert len(calls) == 0
|
||||
# entity_a was the first to turn on, so the trigger fires for it.
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_a
|
||||
|
||||
# entity_b turning on must not fire the trigger: it is not the first.
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
@@ -4484,12 +4489,10 @@ async def test_entity_trigger_blip_same_loop_iteration(
|
||||
) -> None:
|
||||
"""Test a same-iteration blip (off→on→off) without a duration.
|
||||
|
||||
The on-event should fire the trigger — consistent with behavior each
|
||||
and with the same blip spread over multiple iterations.
|
||||
|
||||
This test documents existing unwanted behavior: the on-event is
|
||||
evaluated against the live state machine, which already shows the
|
||||
entity off again, so the trigger never fires.
|
||||
State change listeners are called synchronously, so the on-event is
|
||||
evaluated (and fires) before the off-event is applied — consistent with
|
||||
behavior each and with the same blip spread over multiple iterations.
|
||||
The off-event is then an invalid transition and does not fire.
|
||||
"""
|
||||
entity_id = "test.entity_1"
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
@@ -4504,8 +4507,9 @@ async def test_entity_trigger_blip_same_loop_iteration(
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Unwanted: the trigger should fire once, for the on-event, but doesn't.
|
||||
assert len(calls) == 0
|
||||
# The on-event fires before the off-event is applied.
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_id
|
||||
|
||||
unsub()
|
||||
|
||||
@@ -4516,10 +4520,9 @@ async def test_entity_trigger_blip_same_loop_iteration_with_duration(
|
||||
) -> None:
|
||||
"""Test a same-iteration blip (off→on→off) with a duration.
|
||||
|
||||
The state did not hold for the duration, so the trigger must not fire.
|
||||
(Currently the duration timer is not even armed: the on-event is
|
||||
evaluated against the live state machine, which already shows the
|
||||
entity off again.)
|
||||
State change listeners are called synchronously, so the on-event arms the
|
||||
duration timer and the off-event from the same iteration then cancels it.
|
||||
The state did not hold for the duration, so the trigger does not fire.
|
||||
"""
|
||||
entity_id = "test.entity_1"
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
@@ -4543,31 +4546,19 @@ async def test_entity_trigger_blip_same_loop_iteration_with_duration(
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("behavior", "expected_calls"),
|
||||
[
|
||||
pytest.param(BEHAVIOR_EACH, 0, id="each"),
|
||||
pytest.param(BEHAVIOR_FIRST, 1, id="first"),
|
||||
pytest.param(BEHAVIOR_ALL, 1, id="all"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("behavior", [BEHAVIOR_EACH, BEHAVIOR_FIRST, BEHAVIOR_ALL])
|
||||
async def test_entity_trigger_duration_cancelled_by_dip_same_loop_iteration(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
behavior: str,
|
||||
expected_calls: int,
|
||||
) -> None:
|
||||
"""Test a same-iteration dip through unavailable cancels the timer.
|
||||
|
||||
The dip breaks the continuity of the matching state, and the recovery
|
||||
event cannot restart the timer because the transition out of
|
||||
unavailable is excluded, so the trigger should never fire.
|
||||
|
||||
For behavior each the cancel check uses the dip event's own state and
|
||||
the timer is correctly cancelled. For first/all this test documents
|
||||
existing unwanted behavior: the cancel check counts matches against
|
||||
the live state machine, which already shows the recovery, so the timer
|
||||
survives and the trigger fires.
|
||||
event cannot restart the timer because the transition out of unavailable
|
||||
is excluded, so the trigger does not fire. State change listeners are
|
||||
called synchronously, so the unavailable event cancels the timer before
|
||||
the recovery event is applied.
|
||||
"""
|
||||
entity_id = "test.entity_1"
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
@@ -4589,11 +4580,11 @@ async def test_entity_trigger_duration_cancelled_by_dip_same_loop_iteration(
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original duration — the trigger should not fire
|
||||
# Advance past the original duration — the trigger does not fire
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == expected_calls
|
||||
assert len(calls) == 0
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@@ -46,12 +46,13 @@ def test_main_writes_artifact(
|
||||
),
|
||||
)
|
||||
|
||||
sha = "abc1234def5678abc1234def5678abc1234def56"
|
||||
exit_code = main_mod.main(
|
||||
[
|
||||
"--pr-number",
|
||||
"42",
|
||||
"--head-sha",
|
||||
"abc1234def5678",
|
||||
sha,
|
||||
"--diff",
|
||||
str(diff_file),
|
||||
"--output",
|
||||
@@ -62,11 +63,11 @@ def test_main_writes_artifact(
|
||||
|
||||
payload = json.loads(output_file.read_text(encoding="utf-8"))
|
||||
assert payload["pr_number"] == 42
|
||||
assert payload["head_sha"] == "abc1234def5678"
|
||||
assert payload["head_sha"] == sha
|
||||
assert payload["packages"][0]["name"] == "pkg"
|
||||
assert (
|
||||
"<!-- requirements-check-sha: abc1234def5678 -->"
|
||||
in (payload["rendered_comment"])
|
||||
f"https://github.com/home-assistant/core/commit/{sha}"
|
||||
in payload["rendered_comment"]
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
|
||||
@@ -74,8 +74,8 @@ def test_render_empty_change_set() -> None:
|
||||
assert "No tracked requirement changes detected" in rendered
|
||||
|
||||
|
||||
def test_render_embeds_head_sha_marker_and_visible_line() -> None:
|
||||
"""A head SHA produces the hidden marker (for the gate) and a visible line."""
|
||||
def test_render_embeds_head_sha_as_commit_link() -> None:
|
||||
"""A head SHA renders a commit link whose URL carries the full SHA."""
|
||||
pkg = PackageChange(
|
||||
name="pkg",
|
||||
old_version="1.0.0",
|
||||
@@ -83,18 +83,19 @@ def test_render_embeds_head_sha_marker_and_visible_line() -> None:
|
||||
repo_url="https://github.com/x/pkg",
|
||||
checks={CheckKind.CI_UPLOAD: _pass("ok")},
|
||||
)
|
||||
sha = "abc1234def5678"
|
||||
sha = "abc1234def5678abc1234def5678abc1234def56"
|
||||
rendered = render_comment(CheckRunResult(pr_number=1, head_sha=sha, packages=[pkg]))
|
||||
assert f"<!-- requirements-check-sha: {sha} -->" in rendered
|
||||
assert "Checked at commit `abc1234`." in rendered
|
||||
# The visible marker must still lead so add_comment dedup keeps working.
|
||||
# Short SHA shown to humans, full SHA recoverable from the link URL.
|
||||
assert (
|
||||
f"Checked at commit [`abc1234`]"
|
||||
f"(https://github.com/home-assistant/core/commit/{sha})."
|
||||
) in rendered
|
||||
assert rendered.startswith("<!-- requirements-check -->\n")
|
||||
|
||||
|
||||
def test_render_without_head_sha_omits_marker() -> None:
|
||||
"""With no head SHA, neither the hidden marker nor the visible line appears."""
|
||||
def test_render_without_head_sha_omits_commit_line() -> None:
|
||||
"""With no head SHA, the commit line is absent entirely."""
|
||||
rendered = render_comment(CheckRunResult(pr_number=1))
|
||||
assert "requirements-check-sha" not in rendered
|
||||
assert "Checked at commit" not in rendered
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user