mirror of
https://github.com/home-assistant/core.git
synced 2026-06-16 17:02:57 +02:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"accountId": "test_account_id"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+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