mirror of
https://github.com/home-assistant/core.git
synced 2026-06-16 17:02:57 +02:00
Compare commits
63 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 | |||
| f5600a602f | |||
| d83cd941a7 | |||
| 2120cad533 | |||
| fb4e72af77 | |||
| badd4130b6 | |||
| 7a4ca4dcfd | |||
| 9b47a0d440 | |||
| 4b99e81a8a | |||
| 62e5238f43 | |||
| 149c884a89 | |||
| 71ca453c42 | |||
| aad6080307 | |||
| 2db2e0b0cf | |||
| 3fc36ab6f9 | |||
| 0fad24393c | |||
| a992a58367 | |||
| f0cefe2f2e | |||
| 40264992a2 | |||
| c29aebd60e | |||
| 36b74d6f05 | |||
| 2c626fa8f0 | |||
| cab0d015f6 | |||
| c544f95979 | |||
| 2189d0ae74 | |||
| 9e96a06aff | |||
| d16e0e9867 | |||
| 2209996919 | |||
| d88767155b | |||
| 334d02077f | |||
| 2b7e9289d2 |
@@ -193,7 +193,7 @@ jobs:
|
||||
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
|
||||
uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0
|
||||
with:
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build machine image
|
||||
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
|
||||
uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0
|
||||
with:
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
|
||||
+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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairq"],
|
||||
"requirements": ["aioairq==0.4.7"],
|
||||
"requirements": ["aioairq==0.4.8"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
from homeassistant.const import CONF_MAC, CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -87,9 +87,12 @@ class AnthemAVR(MediaPlayerEntity):
|
||||
via_device=(DOMAIN, mac_address),
|
||||
)
|
||||
else:
|
||||
# Zone 1 is the physical receiver that owns the network MAC; higher
|
||||
# zones are via_device children and carry no connection.
|
||||
self._attr_unique_id = mac_address
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, mac_address)},
|
||||
connections={(CONNECTION_NETWORK_MAC, mac_address)},
|
||||
name=name,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=model,
|
||||
|
||||
@@ -52,10 +52,7 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
Service integration, no discovery.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: |
|
||||
No data updates.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
|
||||
@@ -193,6 +193,7 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
|
||||
|
||||
device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.unique_id)},
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, data[Attribute.MAC_ADDRESS])},
|
||||
name=self.create_device_name(data),
|
||||
manufacturer="Aprilaire",
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aten_pe",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["atenpdu==0.3.2"]
|
||||
"requirements": ["atenpdu==0.3.6"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -460,7 +460,7 @@ class EventTrigger(Trigger):
|
||||
listener = TargetCalendarEventListener(
|
||||
self._hass, target_selection, self._event_type, offset, run_action
|
||||
)
|
||||
return listener.async_setup()
|
||||
return await listener.async_setup()
|
||||
|
||||
|
||||
class EventStartedTrigger(EventTrigger):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["webexpythonsdk"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["webexpythonsdk==2.0.1"]
|
||||
"requirements": ["webexpythonsdk==2.0.6"]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["doorbirdpy"],
|
||||
"requirements": ["DoorBirdPy==3.0.11"],
|
||||
"requirements": ["DoorBirdPy==3.0.12"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pdunehd"],
|
||||
"requirements": ["pdunehd==1.3.2"]
|
||||
"requirements": ["pdunehd==1.3.3"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/gree",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["greeclimate"],
|
||||
"requirements": ["greeclimate==2.1.1"]
|
||||
"requirements": ["greeclimate==2.1.4"]
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhomematic"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyhomematic==0.1.77"]
|
||||
"requirements": ["pyhomematic==0.1.78"]
|
||||
}
|
||||
|
||||
@@ -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%]"
|
||||
|
||||
@@ -6,7 +6,7 @@ from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -41,6 +41,7 @@ class IAlarmPanel(
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.mac)},
|
||||
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)},
|
||||
manufacturer="Antifurto365 - Meian",
|
||||
name="iAlarm",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/influxdb",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["influxdb", "influxdb_client"],
|
||||
"requirements": ["influxdb==5.3.1", "influxdb-client==1.50.0"],
|
||||
"requirements": ["influxdb==5.3.2", "influxdb-client==1.50.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/infrared",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["infrared-protocols==6.0.0"]
|
||||
"requirements": ["infrared-protocols==6.0.1"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["geopy", "pyipma"],
|
||||
"requirements": ["pyipma==3.0.9"]
|
||||
"requirements": ["pyipma==3.0.10"]
|
||||
}
|
||||
|
||||
@@ -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,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["krakenex", "pykrakenapi"],
|
||||
"requirements": ["krakenex==2.2.2", "pykrakenapi==0.1.8"]
|
||||
"requirements": ["krakenex==2.2.2", "pykrakenapi==0.1.9"]
|
||||
}
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pypck"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pypck==0.9.11", "lcn-frontend==0.2.9"]
|
||||
"requirements": ["pypck==0.9.13", "lcn-frontend==0.2.9"]
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ class LcnLedLogicSensor(LcnEntity, SensorEntity):
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
self._attr_available = (
|
||||
await self.device_connection.request_status_led_and_logic_ops(
|
||||
await self.device_connection.request_status_leds_and_logic_ops(
|
||||
SCAN_INTERVAL.seconds
|
||||
)
|
||||
is not None
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["messagebird"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["messagebird==1.2.0"]
|
||||
"requirements": ["messagebird==1.2.1"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["librouteros"],
|
||||
"requirements": ["librouteros==3.2.0"]
|
||||
"requirements": ["librouteros==3.2.1"]
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["config", "omnilogic"],
|
||||
"requirements": ["omnilogic==0.4.5"],
|
||||
"requirements": ["omnilogic==0.4.9"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["hole"],
|
||||
"requirements": ["hole==0.9.0"]
|
||||
"requirements": ["hole==0.9.2"]
|
||||
}
|
||||
|
||||
@@ -352,6 +352,8 @@ class PS4Device(MediaPlayerEntity):
|
||||
for device in d_registry.devices.get_devices_for_config_entry_id(
|
||||
self._entry_id
|
||||
):
|
||||
# Rebuilt from the existing device entry, which already carries
|
||||
# the network MAC connection added by the live-status branch.
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers=device.identifiers,
|
||||
manufacturer=device.manufacturer,
|
||||
@@ -365,7 +367,9 @@ class PS4Device(MediaPlayerEntity):
|
||||
_sw_version = status["system-version"]
|
||||
_sw_version = _sw_version[1:4]
|
||||
sw_version = f"{_sw_version[0]}.{_sw_version[1:]}"
|
||||
# status["host-id"] is the console's network MAC address.
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, status["host-id"])},
|
||||
identifiers={(DOMAIN, status["host-id"])},
|
||||
manufacturer="Sony Interactive Entertainment Inc.",
|
||||
model="PlayStation 4",
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
from rabbitair import Model
|
||||
|
||||
from homeassistant.const import CONF_MAC
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -36,6 +36,7 @@ class RabbitAirBaseEntity(CoordinatorEntity[RabbitAirDataUpdateCoordinator]):
|
||||
self._attr_unique_id = entry.unique_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.data[CONF_MAC])},
|
||||
connections={(CONNECTION_NETWORK_MAC, entry.data[CONF_MAC])},
|
||||
manufacturer="Rabbit Air",
|
||||
model=MODELS.get(coordinator.data.model),
|
||||
name=entry.title,
|
||||
|
||||
@@ -13,9 +13,10 @@ from pyrainbird.async_client import (
|
||||
)
|
||||
from pyrainbird.data import ModelAndVersion, Schedule
|
||||
|
||||
from homeassistant.const import CONF_MAC
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS
|
||||
@@ -104,13 +105,18 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
|
||||
"""Return information about the device."""
|
||||
if self._unique_id is None:
|
||||
return None
|
||||
return DeviceInfo(
|
||||
device_info = DeviceInfo(
|
||||
name=self.device_name,
|
||||
identifiers={(DOMAIN, self._unique_id)},
|
||||
manufacturer=MANUFACTURER,
|
||||
model=self._model_info.model_name,
|
||||
sw_version=f"{self._model_info.major}.{self._model_info.minor}",
|
||||
)
|
||||
# The unique id is the formatted MAC for current config entries, but was
|
||||
# historically the serial number, so derive the connection from the MAC.
|
||||
if mac_address := self.config_entry.data.get(CONF_MAC):
|
||||
device_info["connections"] = {(CONNECTION_NETWORK_MAC, mac_address)}
|
||||
return device_info
|
||||
|
||||
async def _async_update_data(self) -> RainbirdDeviceState:
|
||||
"""Fetch data from Rain Bird device."""
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["paho_mqtt", "roombapy"],
|
||||
"requirements": ["roombapy==1.9.0"],
|
||||
"requirements": ["roombapy==1.9.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "irobot-*",
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "assumed_state",
|
||||
"loggers": ["tellcore"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["tellcore-net==0.4", "tellcore-py==1.1.2"]
|
||||
"requirements": ["tellcore-net==0.4", "tellcore-py==1.1.3"]
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ class TimeRemainingTrigger(Trigger):
|
||||
state = self._hass.states.get(entity_id)
|
||||
schedule_for_state(entity_id, state, state.context if state else None)
|
||||
|
||||
unsub = async_track_target_selector_state_change_event(
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
self._hass,
|
||||
self._target,
|
||||
state_change_listener,
|
||||
|
||||
@@ -153,7 +153,7 @@ class ItemTriggerBase(Trigger, abc.ABC):
|
||||
functools.partial(self._handle_item_change, run_action=run_action),
|
||||
self._handle_entities_updated,
|
||||
)
|
||||
return listener.async_setup()
|
||||
return await listener.async_setup()
|
||||
|
||||
@callback
|
||||
@abc.abstractmethod
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -160,7 +160,11 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data=merged_input,
|
||||
)
|
||||
|
||||
name = discovery_info.get("hostname") or discovery_info.get("platform")
|
||||
name = (
|
||||
discovery_info.get("name")
|
||||
or discovery_info.get("hostname")
|
||||
or discovery_info.get("product_name")
|
||||
)
|
||||
if not name:
|
||||
short_mac = discovery_info["hw_addr"].replace(":", "").upper()[-6:]
|
||||
name = f"Access {short_mac}"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"protect_api_key": "This API key is associated with UniFi Protect, not UniFi Access. Please generate a new API key from the UniFi Access application settings.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"flow_title": "{name} ({ip_address})",
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
"data": {
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["unifi_discovery"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["unifi-discovery==1.4.0"],
|
||||
"requirements": ["unifi-discovery==1.5.0"],
|
||||
"single_config_entry": true,
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["yoto_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["yoto-api==4.2.1"]
|
||||
"requirements": ["yoto-api==4.3.0"]
|
||||
}
|
||||
|
||||
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"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Offer reusable conditions."""
|
||||
|
||||
import abc
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from collections.abc import Callable, Container, Coroutine, Generator, Iterable, Mapping
|
||||
from contextlib import contextmanager
|
||||
@@ -56,7 +57,7 @@ from homeassistant.const import (
|
||||
STATE_UNKNOWN,
|
||||
WEEKDAYS,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.core import HomeAssistant, State, callback, split_entity_id
|
||||
from homeassistant.exceptions import (
|
||||
ConditionError,
|
||||
ConditionErrorContainer,
|
||||
@@ -87,6 +88,7 @@ from .automation import (
|
||||
move_options_fields_to_top_level,
|
||||
)
|
||||
from .integration_platform import async_process_integration_platforms
|
||||
from .recorder import get_instance
|
||||
from .selector import (
|
||||
NumericThresholdMode,
|
||||
NumericThresholdSelector,
|
||||
@@ -119,6 +121,16 @@ VALIDATE_CONFIG_FORMAT = "{}_validate_config"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Upper bound on the best-effort recorder query used to prime `for:` durations
|
||||
# at setup. If history can't be read within this window we fall back to the
|
||||
# conservative live-state anchor rather than blocking condition setup.
|
||||
HISTORY_PRIMING_TIMEOUT = 10
|
||||
|
||||
# How far back the `for:` priming query reaches. Caps the cost of the query for
|
||||
# very long `for:` durations; beyond this we rely on the live-state anchor, so
|
||||
# such conditions may only become true once enough time has elapsed since setup.
|
||||
MAX_HISTORY_PRIMING_LOOKBACK = timedelta(hours=6)
|
||||
|
||||
_PLATFORM_ALIASES: dict[str | None, str | None] = {
|
||||
"and": None,
|
||||
"device": "device_automation",
|
||||
@@ -493,6 +505,11 @@ class EntityConditionBase(Condition):
|
||||
self._matcher = self._check_all_match_state
|
||||
self._on_unload: list[Callable[[], None]] = []
|
||||
self._valid_since: dict[str, datetime] = {}
|
||||
# Entities whose `for:` anchor is currently being resolved from recorder
|
||||
# history. While an entity is here the live listener leaves its anchor to
|
||||
# the priming, except that an invalidation removes it (the run broke, so
|
||||
# the in-flight history is stale and live tracking takes over).
|
||||
self._priming: set[str] = set()
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities matching any of the domain specs."""
|
||||
@@ -533,11 +550,19 @@ class EntityConditionBase(Condition):
|
||||
and self._should_include(_state)
|
||||
and self.is_valid_state(_state)
|
||||
):
|
||||
# While an entity is being primed from history, leave its anchor to
|
||||
# the priming: the entity stayed valid, so the run is unbroken and the
|
||||
# history start (which can be earlier than this update) is accurate.
|
||||
if entity_id in self._priming:
|
||||
return
|
||||
# Only record the time if not already tracked, to avoid
|
||||
# resetting the duration on unrelated state/attribute updates.
|
||||
if entity_id not in self._valid_since:
|
||||
self._valid_since[entity_id] = self._state_valid_since(_state)
|
||||
else:
|
||||
# An invalidation breaks the run, so any history being loaded for the
|
||||
# entity is now stale; stop priming it and let live tracking own it.
|
||||
self._priming.discard(entity_id)
|
||||
self._valid_since.pop(entity_id, None)
|
||||
|
||||
@override
|
||||
@@ -557,24 +582,150 @@ class EntityConditionBase(Condition):
|
||||
|
||||
self._update_valid_since(entity_id, to_state)
|
||||
|
||||
@callback
|
||||
def _on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
"""Handle changes to the tracked entity set."""
|
||||
for entity_id in added:
|
||||
self._update_valid_since(entity_id, self._hass.states.get(entity_id))
|
||||
for entity_id in removed:
|
||||
self._valid_since.pop(entity_id, None)
|
||||
|
||||
unsub = async_track_target_selector_state_change_event(
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
self._hass,
|
||||
self._target,
|
||||
_state_change_listener,
|
||||
self.entity_filter,
|
||||
_on_entities_update,
|
||||
self._async_on_entities_update,
|
||||
primary_entities_only=self._primary_entities_only,
|
||||
)
|
||||
self._on_unload.append(unsub)
|
||||
|
||||
async def _async_on_entities_update(
|
||||
self, added: set[str], removed: set[str]
|
||||
) -> None:
|
||||
"""Handle changes to the tracked entity set.
|
||||
|
||||
Removed entities stop being tracked immediately. Added entities are only
|
||||
considered by the condition once their `for:` anchor has been resolved
|
||||
(see `_async_prime_valid_since`); until then they are absent from
|
||||
`_valid_since`. The target tracker awaits this for the initial entity set
|
||||
at setup and runs it as a background task for later registry-driven
|
||||
changes.
|
||||
"""
|
||||
for entity_id in removed:
|
||||
self._priming.discard(entity_id)
|
||||
self._valid_since.pop(entity_id, None)
|
||||
await self._async_prime_valid_since(added)
|
||||
|
||||
async def _async_prime_valid_since(self, entity_ids: set[str]) -> None:
|
||||
"""Resolve and store the `for:` anchor for newly tracked entities.
|
||||
|
||||
For each currently-valid entity the anchor is the start of its current
|
||||
continuous run of validity, read from recorder history (bounded by
|
||||
`MAX_HISTORY_PRIMING_LOOKBACK`). The earlier of that and the current
|
||||
state's own anchor wins, so a run that began before the lookback window
|
||||
is not cut short. When the recorder is unavailable or the read fails,
|
||||
the current state's anchor is used alone. An entity is added to
|
||||
`_valid_since` only once this resolves, so a newly tracked entity does
|
||||
not participate in the condition until its anchor is known — rather than
|
||||
briefly using a conservative anchor that then changes.
|
||||
|
||||
While loading, an entity is held in `_priming`. A live change that keeps
|
||||
it valid is ignored (the run is unbroken, history is accurate), but an
|
||||
invalidation removes it from `_priming` so that we do not apply now-stale
|
||||
history over the live tracking that observed the break.
|
||||
"""
|
||||
# Conservative anchor from the live state for each currently-valid entity.
|
||||
anchors = {
|
||||
entity_id: self._state_valid_since(_state)
|
||||
for entity_id in entity_ids
|
||||
if (_state := self._hass.states.get(entity_id)) is not None
|
||||
and self._should_include(_state)
|
||||
and self.is_valid_state(_state)
|
||||
}
|
||||
if not anchors:
|
||||
return
|
||||
|
||||
self._priming.update(anchors)
|
||||
try:
|
||||
if "recorder" in self._hass.config.components:
|
||||
await self._async_refine_anchors_from_history(anchors)
|
||||
for entity_id, anchor in anchors.items():
|
||||
# Skip entities a live change invalidated mid-load: they were
|
||||
# removed from `_priming`, the run broke, and live tracking (which
|
||||
# saw the break) owns them — applying this history would be stale.
|
||||
if entity_id in self._priming:
|
||||
self._valid_since[entity_id] = anchor
|
||||
finally:
|
||||
self._priming.difference_update(anchors)
|
||||
|
||||
async def _async_refine_anchors_from_history(
|
||||
self, anchors: dict[str, datetime]
|
||||
) -> None:
|
||||
"""Move each anchor in `anchors` back to the true start of its run.
|
||||
|
||||
For each entity the anchor becomes the earlier of the recorded run start
|
||||
and the existing (live) anchor; entities with no usable history keep
|
||||
their existing anchor. Mutates `anchors` in place.
|
||||
"""
|
||||
from sqlalchemy.exc import SQLAlchemyError # noqa: PLC0415
|
||||
|
||||
from homeassistant.components.recorder import history # noqa: PLC0415
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert self._duration is not None
|
||||
lookback = min(self._duration, MAX_HISTORY_PRIMING_LOOKBACK)
|
||||
start_time = dt_util.utcnow() - lookback
|
||||
instance = get_instance(self._hass)
|
||||
try:
|
||||
async with asyncio.timeout(HISTORY_PRIMING_TIMEOUT):
|
||||
# The history query only sees committed rows. Wait for the
|
||||
# recorder to flush its queue first.
|
||||
if (commit_future := instance.async_get_commit_future()) is not None:
|
||||
await commit_future
|
||||
historical_states = await instance.async_add_executor_job(
|
||||
ft.partial(
|
||||
history.get_significant_states,
|
||||
self._hass,
|
||||
start_time,
|
||||
entity_ids=list(anchors),
|
||||
include_start_time_state=True,
|
||||
# Mandatory: the default (True) drops attribute-only
|
||||
# changes for entities outside SIGNIFICANT_DOMAINS, which
|
||||
# are exactly the transitions attribute-based conditions
|
||||
# depend on.
|
||||
significant_changes_only=False,
|
||||
minimal_response=False,
|
||||
)
|
||||
)
|
||||
except (SQLAlchemyError, TimeoutError) as err:
|
||||
# Best effort: keep the conservative anchors rather than failing.
|
||||
_LOGGER.debug("Error priming condition durations from history: %s", err)
|
||||
return
|
||||
|
||||
for entity_id, rows in historical_states.items():
|
||||
valid_since = self._valid_since_from_history(
|
||||
entity_id, cast(list[State], rows)
|
||||
)
|
||||
if valid_since is not None:
|
||||
anchors[entity_id] = min(valid_since, anchors[entity_id])
|
||||
|
||||
def _valid_since_from_history(
|
||||
self, entity_id: str, rows: list[State]
|
||||
) -> datetime | None:
|
||||
"""Return when the current continuous run of valid states began.
|
||||
|
||||
Walks recorded states newest-first and stops at the first one that is
|
||||
not valid; the anchor is the oldest state in the unbroken run leading up
|
||||
to the latest recorded state. (We can't just take the first valid state
|
||||
in the window: an intervening invalid period breaks the run, so the
|
||||
anchor must come from after it.) Returns None when the latest recorded
|
||||
state is not valid, e.g. the recorder lags behind the live state machine.
|
||||
"""
|
||||
# Recorder rows are LazyState objects, which skip State.__init__ and so
|
||||
# never populate the domain/object_id that the validity checks rely on.
|
||||
domain, object_id = split_entity_id(entity_id)
|
||||
valid_since: datetime | None = None
|
||||
for _state in reversed(rows):
|
||||
_state.domain = domain
|
||||
_state.object_id = object_id
|
||||
if not (self._should_include(_state) and self.is_valid_state(_state)):
|
||||
break
|
||||
valid_since = self._state_valid_since(_state)
|
||||
return valid_since
|
||||
|
||||
@override
|
||||
def _async_unload(self) -> None:
|
||||
"""Unsubscribe from listeners."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Helpers for dealing with entity targets."""
|
||||
|
||||
import abc
|
||||
from collections.abc import Callable
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
import dataclasses
|
||||
import logging
|
||||
from logging import Logger
|
||||
@@ -292,7 +293,7 @@ class TargetEntityChangeTracker(abc.ABC):
|
||||
|
||||
self._registry_unsubs: list[CALLBACK_TYPE] = []
|
||||
|
||||
def async_setup(self) -> Callable[[], None]:
|
||||
async def async_setup(self) -> Callable[[], None]:
|
||||
"""Set up the state change tracking."""
|
||||
self._setup_registry_listeners()
|
||||
self._handle_target_update()
|
||||
@@ -304,18 +305,20 @@ class TargetEntityChangeTracker(abc.ABC):
|
||||
"""Called when there's an update to tracked target entities."""
|
||||
|
||||
@callback
|
||||
def _handle_target_update(self, event: Event[Any] | None = None) -> None:
|
||||
"""Handle updates in the tracked targets."""
|
||||
def _referenced_entities(self) -> set[str]:
|
||||
"""Return the currently tracked, filtered entity ids."""
|
||||
selected = async_extract_referenced_entity_ids(
|
||||
self._hass,
|
||||
self._target_selection,
|
||||
expand_group=False,
|
||||
primary_entities_only=self._primary_entities_only,
|
||||
)
|
||||
filtered_entities = self._entity_filter(
|
||||
selected.referenced | selected.indirectly_referenced
|
||||
)
|
||||
self._handle_entities_update(filtered_entities)
|
||||
return self._entity_filter(selected.referenced | selected.indirectly_referenced)
|
||||
|
||||
@callback
|
||||
def _handle_target_update(self, event: Event[Any] | None = None) -> None:
|
||||
"""Handle updates in the tracked targets."""
|
||||
self._handle_entities_update(self._referenced_entities())
|
||||
|
||||
def _setup_registry_listeners(self) -> None:
|
||||
"""Set up listeners for registry changes that require resubscription."""
|
||||
@@ -356,11 +359,20 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
target_selection: TargetSelection,
|
||||
action: Callable[[TargetStateChangedData], Any],
|
||||
entity_filter: Callable[[set[str]], set[str]],
|
||||
on_entities_update: Callable[[set[str], set[str]], None] | None = None,
|
||||
on_entities_update: Callable[
|
||||
[set[str], set[str]], Coroutine[Any, Any, None] | None
|
||||
]
|
||||
| None = None,
|
||||
*,
|
||||
primary_entities_only: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the state change tracker."""
|
||||
"""Initialize the state change tracker.
|
||||
|
||||
`on_entities_update` may be a plain callback or a coroutine function.
|
||||
A coroutine is awaited for the initial entity set (so setup is
|
||||
deterministic) and scheduled as a background task for later
|
||||
registry-driven changes.
|
||||
"""
|
||||
super().__init__(
|
||||
hass,
|
||||
target_selection,
|
||||
@@ -371,17 +383,47 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
self._on_entities_update = on_entities_update
|
||||
self._state_change_unsub: CALLBACK_TYPE | None = None
|
||||
self._tracked_entities: set[str] = set()
|
||||
self._update_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
async def async_setup(self) -> Callable[[], None]:
|
||||
"""Set up tracking, awaiting the update for the initial entity set.
|
||||
|
||||
The initial update is awaited so that a coroutine `on_entities_update`
|
||||
(e.g. one that loads history) completes before setup returns. Later
|
||||
registry-driven updates instead arrive via the callback
|
||||
`_handle_entities_update` and are scheduled as background tasks.
|
||||
"""
|
||||
self._setup_registry_listeners()
|
||||
entities = self._referenced_entities()
|
||||
if (coro := self._apply_entities_update(entities)) is not None:
|
||||
await coro
|
||||
return self._unsubscribe
|
||||
|
||||
@callback
|
||||
def _handle_entities_update(self, tracked_entities: set[str]) -> None:
|
||||
"""Handle the tracked entities."""
|
||||
"""Handle a registry-driven change to the tracked entity set."""
|
||||
if (coro := self._apply_entities_update(tracked_entities)) is None:
|
||||
return
|
||||
# Tracked so it can be cancelled on unsubscribe.
|
||||
task = self._hass.async_create_background_task(
|
||||
coro, "Target entity tracker update"
|
||||
)
|
||||
self._update_tasks.add(task)
|
||||
task.add_done_callback(self._update_tasks.discard)
|
||||
|
||||
def _apply_entities_update(
|
||||
self, tracked_entities: set[str]
|
||||
) -> Coroutine[Any, Any, None] | None:
|
||||
"""Resubscribe to state changes; return the update coroutine, if any."""
|
||||
previous_entities = self._tracked_entities
|
||||
self._tracked_entities = tracked_entities
|
||||
|
||||
result: Coroutine[Any, Any, None] | None = None
|
||||
if self._on_entities_update is not None:
|
||||
added = tracked_entities - previous_entities
|
||||
removed = previous_entities - tracked_entities
|
||||
if added or removed:
|
||||
self._on_entities_update(added, removed)
|
||||
result = self._on_entities_update(added, removed)
|
||||
|
||||
@callback
|
||||
def state_change_listener(event: Event[EventStateChangedData]) -> None:
|
||||
@@ -395,6 +437,7 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
self._state_change_unsub = async_track_state_change_event(
|
||||
self._hass, tracked_entities, state_change_listener
|
||||
)
|
||||
return result
|
||||
|
||||
def _unsubscribe(self) -> None:
|
||||
"""Unsubscribe from all events."""
|
||||
@@ -402,14 +445,18 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
if self._state_change_unsub:
|
||||
self._state_change_unsub()
|
||||
self._state_change_unsub = None
|
||||
for task in self._update_tasks:
|
||||
task.cancel()
|
||||
self._update_tasks.clear()
|
||||
|
||||
|
||||
def async_track_target_selector_state_change_event(
|
||||
async def async_track_target_selector_state_change_event(
|
||||
hass: HomeAssistant,
|
||||
target_selector_config: ConfigType,
|
||||
action: Callable[[TargetStateChangedData], Any],
|
||||
entity_filter: Callable[[set[str]], set[str]] = lambda x: x,
|
||||
on_entities_update: Callable[[set[str], set[str]], None] | None = None,
|
||||
on_entities_update: Callable[[set[str], set[str]], Coroutine[Any, Any, None] | None]
|
||||
| None = None,
|
||||
*,
|
||||
primary_entities_only: bool = True,
|
||||
) -> CALLBACK_TYPE:
|
||||
@@ -419,6 +466,10 @@ def async_track_target_selector_state_change_event(
|
||||
When `primary_entities_only` is True, indirect target
|
||||
expansion (via device, area, and floor) skips entities
|
||||
with an `entity_category` (config or diagnostic entities).
|
||||
|
||||
`on_entities_update` may be a coroutine function; it is awaited for the
|
||||
initial entity set and scheduled as a task for later registry-driven
|
||||
changes, so this function must itself be awaited.
|
||||
"""
|
||||
target_selection = TargetSelection(target_selector_config)
|
||||
if not target_selection.has_any_target:
|
||||
@@ -435,4 +486,4 @@ def async_track_target_selector_state_change_event(
|
||||
on_entities_update,
|
||||
primary_entities_only=primary_entities_only,
|
||||
)
|
||||
return tracker.async_setup()
|
||||
return await tracker.async_setup()
|
||||
|
||||
@@ -579,7 +579,7 @@ class EntityTriggerBase(Trigger):
|
||||
),
|
||||
)
|
||||
|
||||
unsub = async_track_target_selector_state_change_event(
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
self._hass,
|
||||
self._target,
|
||||
state_change_listener,
|
||||
|
||||
Generated
+1
-1
@@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0
|
||||
home-assistant-intents==2026.6.1
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
infrared-protocols==6.0.0
|
||||
infrared-protocols==6.0.1
|
||||
Jinja2==3.1.6
|
||||
lru-dict==1.4.1
|
||||
mutagen==1.47.0
|
||||
|
||||
Generated
+30
-30
@@ -13,7 +13,7 @@ AIOSomecomfort==0.0.35
|
||||
Adax-local==0.3.0
|
||||
|
||||
# homeassistant.components.doorbird
|
||||
DoorBirdPy==3.0.11
|
||||
DoorBirdPy==3.0.12
|
||||
|
||||
# homeassistant.components.homekit
|
||||
HAP-python==5.0.0
|
||||
@@ -181,7 +181,7 @@ aio-ownet==0.0.5
|
||||
aioacaia==0.1.18
|
||||
|
||||
# homeassistant.components.airq
|
||||
aioairq==0.4.7
|
||||
aioairq==0.4.8
|
||||
|
||||
# homeassistant.components.airzone_cloud
|
||||
aioairzone-cloud==0.7.2
|
||||
@@ -584,7 +584,7 @@ asyncsleepiq==1.7.1
|
||||
asyncssh==2.21.0
|
||||
|
||||
# homeassistant.components.aten_pe
|
||||
# atenpdu==0.3.2
|
||||
# atenpdu==0.3.6
|
||||
|
||||
# homeassistant.components.aurora
|
||||
auroranoaa==0.0.5
|
||||
@@ -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
|
||||
@@ -1171,7 +1171,7 @@ gpiozero==1.6.2
|
||||
gps3==0.33.3
|
||||
|
||||
# homeassistant.components.gree
|
||||
greeclimate==2.1.1
|
||||
greeclimate==2.1.4
|
||||
|
||||
# homeassistant.components.greencell
|
||||
greencell_client==1.0.3
|
||||
@@ -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
|
||||
@@ -1268,7 +1268,7 @@ hko==0.3.2
|
||||
hlk-sw16==0.0.9
|
||||
|
||||
# homeassistant.components.pi_hole
|
||||
hole==0.9.0
|
||||
hole==0.9.2
|
||||
|
||||
# homeassistant.components.holiday
|
||||
# homeassistant.components.workday
|
||||
@@ -1365,10 +1365,10 @@ indevolt-api==1.8.5
|
||||
influxdb-client==1.50.0
|
||||
|
||||
# homeassistant.components.influxdb
|
||||
influxdb==5.3.1
|
||||
influxdb==5.3.2
|
||||
|
||||
# homeassistant.components.infrared
|
||||
infrared-protocols==6.0.0
|
||||
infrared-protocols==6.0.1
|
||||
|
||||
# homeassistant.components.inkbird
|
||||
inkbird-ble==1.4.4
|
||||
@@ -1480,7 +1480,7 @@ libpyvivotek==0.6.1
|
||||
librehardwaremonitor-api==1.11.1
|
||||
|
||||
# homeassistant.components.mikrotik
|
||||
librouteros==3.2.0
|
||||
librouteros==3.2.1
|
||||
|
||||
# homeassistant.components.soundtouch
|
||||
libsoundtouch==0.8
|
||||
@@ -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
|
||||
@@ -1559,7 +1559,7 @@ medcom-ble==0.1.1
|
||||
melnor-bluetooth==0.0.25
|
||||
|
||||
# homeassistant.components.message_bird
|
||||
messagebird==1.2.0
|
||||
messagebird==1.2.1
|
||||
|
||||
# homeassistant.components.meteo_lt
|
||||
meteo-lt-pkg==0.2.4
|
||||
@@ -1739,7 +1739,7 @@ ohme==1.9.1
|
||||
ollama==0.6.2
|
||||
|
||||
# homeassistant.components.omnilogic
|
||||
omnilogic==0.4.5
|
||||
omnilogic==0.4.9
|
||||
|
||||
# homeassistant.components.ondilo_ico
|
||||
ondilo==0.5.0
|
||||
@@ -1824,7 +1824,7 @@ panacotta==0.2
|
||||
panasonic-viera==0.4.4
|
||||
|
||||
# homeassistant.components.dunehd
|
||||
pdunehd==1.3.2
|
||||
pdunehd==1.3.3
|
||||
|
||||
# homeassistant.components.peblar
|
||||
peblar==0.5.1
|
||||
@@ -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
|
||||
@@ -2229,7 +2229,7 @@ pyheos==1.0.6
|
||||
pyhive-integration==1.0.9
|
||||
|
||||
# homeassistant.components.homematic
|
||||
pyhomematic==0.1.77
|
||||
pyhomematic==0.1.78
|
||||
|
||||
# homeassistant.components.homeworks
|
||||
pyhomeworks==1.1.2
|
||||
@@ -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
|
||||
@@ -2253,7 +2253,7 @@ pyintelliclima==0.3.1
|
||||
pyintesishome==1.8.8
|
||||
|
||||
# homeassistant.components.ipma
|
||||
pyipma==3.0.9
|
||||
pyipma==3.0.10
|
||||
|
||||
# homeassistant.components.ipp
|
||||
pyipp==0.17.2
|
||||
@@ -2298,7 +2298,7 @@ pykodi==0.2.7
|
||||
pykoplenti==1.5.0
|
||||
|
||||
# homeassistant.components.kraken
|
||||
pykrakenapi==0.1.8
|
||||
pykrakenapi==0.1.9
|
||||
|
||||
# homeassistant.components.kulersky
|
||||
pykulersky==0.5.8
|
||||
@@ -2453,7 +2453,7 @@ pypaperless==4.1.1
|
||||
pypca==0.0.7
|
||||
|
||||
# homeassistant.components.lcn
|
||||
pypck==0.9.11
|
||||
pypck==0.9.13
|
||||
|
||||
# homeassistant.components.pglab
|
||||
pypglab==0.0.5
|
||||
@@ -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
|
||||
@@ -2932,7 +2932,7 @@ rokuecp==0.19.5
|
||||
romy==0.0.10
|
||||
|
||||
# homeassistant.components.roomba
|
||||
roombapy==1.9.0
|
||||
roombapy==1.9.1
|
||||
|
||||
# homeassistant.components.roon
|
||||
roonapi==0.1.6
|
||||
@@ -3142,7 +3142,7 @@ tapsaff==0.2.1
|
||||
tellcore-net==0.4
|
||||
|
||||
# homeassistant.components.tellstick
|
||||
tellcore-py==1.1.2
|
||||
tellcore-py==1.1.3
|
||||
|
||||
# homeassistant.components.tellduslive
|
||||
tellduslive==0.10.12
|
||||
@@ -3261,7 +3261,7 @@ uiprotect==13.1.2
|
||||
ultraheat-api==0.6.1
|
||||
|
||||
# homeassistant.components.unifi_discovery
|
||||
unifi-discovery==1.4.0
|
||||
unifi-discovery==1.5.0
|
||||
|
||||
# homeassistant.components.unifi_direct
|
||||
unifi_ap==0.0.2
|
||||
@@ -3360,7 +3360,7 @@ watergate-local-api==2025.1.0
|
||||
weatherflow4py==1.5.4
|
||||
|
||||
# homeassistant.components.cisco_webex_teams
|
||||
webexpythonsdk==2.0.1
|
||||
webexpythonsdk==2.0.6
|
||||
|
||||
# homeassistant.components.nasweb
|
||||
webio-api==0.1.12
|
||||
@@ -3439,7 +3439,7 @@ yeelightsunflower==0.0.10
|
||||
yolink-api==0.6.5
|
||||
|
||||
# homeassistant.components.yoto
|
||||
yoto-api==4.2.1
|
||||
yoto-api==4.3.0
|
||||
|
||||
# homeassistant.components.youless
|
||||
youless-api==2.2.0
|
||||
|
||||
@@ -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,36 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_registry
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
tuple(
|
||||
'mac',
|
||||
'00:00:00:00:00:01',
|
||||
),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'anthemav',
|
||||
'00:00:00:00:00:01',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Anthem',
|
||||
'model': 'MRX 520',
|
||||
'model_id': None,
|
||||
'name': 'Anthem AV',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -5,10 +5,13 @@ from unittest.mock import ANY, AsyncMock, patch
|
||||
|
||||
from anthemav.device_error import DeviceError
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.anthemav.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -34,6 +37,19 @@ async def test_load_unload_config_entry(
|
||||
mock_anthemav.close.assert_called_once()
|
||||
|
||||
|
||||
async def test_device_registry(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
init_integration: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test the device registry entry, including the network MAC connection."""
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "00:00:00:00:00:01")}
|
||||
)
|
||||
assert device_entry == snapshot
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error", [OSError, DeviceError])
|
||||
async def test_config_entry_not_ready_when_oserror(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry, error: Exception
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_registry
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
tuple(
|
||||
'mac',
|
||||
'12:34:56:78:90:ab',
|
||||
),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': 'Rev. B',
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'aprilaire',
|
||||
'12:34:56:78:90:ab',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Aprilaire',
|
||||
'model': '8476W',
|
||||
'model_id': None,
|
||||
'name': 'Aprilaire',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '1.05',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for the Aprilaire integration setup."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pyaprilaire.const import Attribute
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.aprilaire.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_device_registry(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test the device registry entry, including the network MAC connection."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="12:34:56:78:90:ab",
|
||||
data={CONF_HOST: "localhost", CONF_PORT: 7000},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
client = AsyncMock()
|
||||
client.data = {
|
||||
Attribute.MAC_ADDRESS: "1234567890ab",
|
||||
Attribute.NAME: "Aprilaire",
|
||||
Attribute.MODEL_NUMBER: 0,
|
||||
Attribute.HARDWARE_REVISION: ord("B"),
|
||||
Attribute.FIRMWARE_MAJOR_REVISION: 1,
|
||||
Attribute.FIRMWARE_MINOR_REVISION: 5,
|
||||
Attribute.THERMOSTAT_MODES: 0,
|
||||
Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_STATUS: 0,
|
||||
Attribute.CONNECTED: True,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.aprilaire.coordinator.pyaprilaire.client.AprilaireClient",
|
||||
return_value=client,
|
||||
):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "12:34:56:78:90:ab")}
|
||||
)
|
||||
assert device_entry == snapshot
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_registry
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
tuple(
|
||||
'mac',
|
||||
'00:00:54:12:34:56',
|
||||
),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'ialarm',
|
||||
'00:00:54:12:34:56',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Antifurto365 - Meian',
|
||||
'model': None,
|
||||
'model_id': None,
|
||||
'name': 'iAlarm',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -1,14 +1,16 @@
|
||||
"""Test the Antifurto365 iAlarm init."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.ialarm.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -54,6 +56,26 @@ async def test_setup_not_ready(
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_device_registry(
|
||||
hass: HomeAssistant,
|
||||
ialarm_api: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test the device registry entry, including the network MAC connection."""
|
||||
ialarm_api.return_value.get_mac = Mock(return_value="00:00:54:12:34:56")
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "00:00:54:12:34:56")}
|
||||
)
|
||||
assert device_entry == snapshot
|
||||
|
||||
|
||||
async def test_unload_entry(hass: HomeAssistant, ialarm_api, mock_config_entry) -> None:
|
||||
"""Test being able to unload an entry."""
|
||||
ialarm_api.return_value.get_mac = Mock(return_value="00:00:54:12:34:56")
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user