Compare commits

...

33 Commits

Author SHA1 Message Date
Erik Montnemery 6ad8ad5715 Call state change listeners immediately instead of deferring them to the event loop (#173974) 2026-06-16 16:50:32 +02:00
Åke Strandberg a45867b896 Use username as config entry title in aqvify (#174008) 2026-06-16 14:30:25 +02:00
Franck Nijhof 000e075a8e Add missing subentry flow translations in scrape (#174006) 2026-06-16 14:26:12 +02:00
Franck Nijhof 0899d016b9 Add missing flow form field translations in ecobee (#174002) 2026-06-16 14:25:40 +02:00
Franck Nijhof 3375f2ed76 Add missing flow form field translation in otp (#173994) 2026-06-16 14:25:29 +02:00
Franck Nijhof 3f5778e71b Add missing flow form field translations in tractive (#174005) 2026-06-16 14:10:47 +02:00
Franck Nijhof 86c39694d3 Add missing flow form field translation in iskra (#174004) 2026-06-16 13:48:58 +02:00
Franck Nijhof a53a6644c0 Fix flow form field translations in modem_callerid (#173999) 2026-06-16 13:47:01 +02:00
Franck Nijhof 18fdfacf45 Fix flow form field translation key in sia (#173998) 2026-06-16 13:46:27 +02:00
Franck Nijhof bd9bd29f2c Add missing flow form field translation in airvisual (#174000) 2026-06-16 13:46:19 +02:00
Franck Nijhof 334c6614cc Fix flow form field translations in local_calendar (#173997) 2026-06-16 13:43:22 +02:00
Franck Nijhof aa772f6ecd Add missing flow form field translation in honeywell (#173996) 2026-06-16 13:41:18 +02:00
Franck Nijhof 87169921ae Fix flow form field translations in hlk_sw16 (#173993) 2026-06-16 13:39:40 +02:00
Franck Nijhof 16338b8b6b Fix flow form field translation keys in here_travel_time (#173992) 2026-06-16 13:38:50 +02:00
Åke Strandberg 519da3c9c9 Add aqvify devices dynamically (#173534) 2026-06-16 13:37:42 +02:00
Åke Strandberg 6f34718c1f Bump pyaqvify to 0.0.11 (#173989) 2026-06-16 13:37:02 +02:00
Tim Laing e4287bb43c Bump PyiCloud to 2.6.5 (#173928) 2026-06-16 13:05:50 +02:00
Mike O'Driscoll d724ebac2a casper_glow: add bluetooth reachability diagnostics (#173921) 2026-06-16 13:02:46 +02:00
Raphael Hehl dc480051db Use console name in UniFi Network discovery title (#173931) 2026-06-16 12:57:51 +02:00
Franck Nijhof 63b6ced9c4 Bump evolutionhttp to 0.0.19 (#173911) 2026-06-16 12:46:21 +02:00
Franck Nijhof 34e9b3ff1e Bump lunatone-rest-api-client to 0.9.2 (#173918) 2026-06-16 12:45:19 +02:00
Robert Resch 210746525e Fix missing full sha as hidden field in requirements check aw (#173900) 2026-06-16 11:05:08 +02:00
Robert Resch 0134e99366 Token views should behave the same (#173500) 2026-06-16 10:46:18 +02:00
Oscar Calvo 06de89d6a3 Fix CCM15 temperature unit to follow the device's C/F setting (#173788) 2026-06-16 10:41:58 +02:00
Paul Bottein 4c267617f8 Publish numeric sensor device classes as generated sensor.json (#173919) 2026-06-16 11:41:27 +03:00
Franck Nijhof a82f1a7a1d Bump pyfireservicerota to 0.0.49 (#173935) 2026-06-16 10:36:34 +02:00
Franck Nijhof d234f65dd9 Bump heatmiserV3 to 2.0.6 (#173913)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-16 10:36:03 +02:00
John Pettitt 30148980e1 Add API_GEN_4 support to Subaru integration (#173956) 2026-06-16 10:32:02 +02:00
Franck Nijhof 1fa9a3353c Bump eufylife-ble-client to 0.1.10 (#173934) 2026-06-16 10:30:52 +02:00
Raphael Hehl 2dbbd70085 Use console name in UniFi Protect discovery title (#173966) 2026-06-16 10:25:50 +02:00
Franck Nijhof 73903b0bfc Bump pysesame2 to 1.0.2 (#173904) 2026-06-16 10:20:23 +02:00
Franck Nijhof b09f54ce3b Bump foobot_async to 1.0.1 (#173905) 2026-06-16 10:19:38 +02:00
Franck Nijhof 6d9e41da07 Bump pencompy to 0.0.4 (#173906) 2026-06-16 10:17:46 +02:00
64 changed files with 803 additions and 279 deletions
+5 -4
View File
@@ -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
+6 -7
View File
@@ -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
+1 -1
View File
@@ -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"]
}
+17 -5
View File
@@ -59,11 +59,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"]
}
+10 -4
View File
@@ -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}"
}
}
}
+7 -1
View File
@@ -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."""
+3 -1
View File
@@ -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."
}
@@ -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"]
}
@@ -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"]
}
+10 -4
View File
@@ -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
+2 -1
View File
@@ -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."
}
+2 -1
View File
@@ -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"]
}
+1 -1
View File
@@ -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%]",
+1
View File
@@ -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 = [
+3 -2
View File
@@ -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}"
+1 -1
View File
@@ -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"],
}
+12
View File
@@ -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",
+61
View File
@@ -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"
]
}
+2 -24
View File
@@ -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
+10 -10
View File
@@ -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
+5 -9
View File
@@ -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:
+2
View File
@@ -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 = [
+40
View File
@@ -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"
}
@@ -0,0 +1,3 @@
{
"accountId": "test_account_id"
}
+32 -2
View File
@@ -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",
}
+29 -2
View File
@@ -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"),
[
+42
View File
@@ -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."""
+6 -2
View File
@@ -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
+45 -2
View File
@@ -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
+1 -1
View File
@@ -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:
+13
View File
@@ -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)
+25
View File
@@ -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(
+59 -16
View File
@@ -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:
+122 -131
View File
@@ -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