Compare commits

...

63 Commits

Author SHA1 Message Date
Erik Montnemery 6ad8ad5715 Call state change listeners immediately instead of deferring them to the event loop (#173974) 2026-06-16 16:50:32 +02:00
Åke Strandberg a45867b896 Use username as config entry title in aqvify (#174008) 2026-06-16 14:30:25 +02:00
Franck Nijhof 000e075a8e Add missing subentry flow translations in scrape (#174006) 2026-06-16 14:26:12 +02:00
Franck Nijhof 0899d016b9 Add missing flow form field translations in ecobee (#174002) 2026-06-16 14:25:40 +02:00
Franck Nijhof 3375f2ed76 Add missing flow form field translation in otp (#173994) 2026-06-16 14:25:29 +02:00
Franck Nijhof 3f5778e71b Add missing flow form field translations in tractive (#174005) 2026-06-16 14:10:47 +02:00
Franck Nijhof 86c39694d3 Add missing flow form field translation in iskra (#174004) 2026-06-16 13:48:58 +02:00
Franck Nijhof a53a6644c0 Fix flow form field translations in modem_callerid (#173999) 2026-06-16 13:47:01 +02:00
Franck Nijhof 18fdfacf45 Fix flow form field translation key in sia (#173998) 2026-06-16 13:46:27 +02:00
Franck Nijhof bd9bd29f2c Add missing flow form field translation in airvisual (#174000) 2026-06-16 13:46:19 +02:00
Franck Nijhof 334c6614cc Fix flow form field translations in local_calendar (#173997) 2026-06-16 13:43:22 +02:00
Franck Nijhof aa772f6ecd Add missing flow form field translation in honeywell (#173996) 2026-06-16 13:41:18 +02:00
Franck Nijhof 87169921ae Fix flow form field translations in hlk_sw16 (#173993) 2026-06-16 13:39:40 +02:00
Franck Nijhof 16338b8b6b Fix flow form field translation keys in here_travel_time (#173992) 2026-06-16 13:38:50 +02:00
Åke Strandberg 519da3c9c9 Add aqvify devices dynamically (#173534) 2026-06-16 13:37:42 +02:00
Åke Strandberg 6f34718c1f Bump pyaqvify to 0.0.11 (#173989) 2026-06-16 13:37:02 +02:00
Tim Laing e4287bb43c Bump PyiCloud to 2.6.5 (#173928) 2026-06-16 13:05:50 +02:00
Mike O'Driscoll d724ebac2a casper_glow: add bluetooth reachability diagnostics (#173921) 2026-06-16 13:02:46 +02:00
Raphael Hehl dc480051db Use console name in UniFi Network discovery title (#173931) 2026-06-16 12:57:51 +02:00
Franck Nijhof 63b6ced9c4 Bump evolutionhttp to 0.0.19 (#173911) 2026-06-16 12:46:21 +02:00
Franck Nijhof 34e9b3ff1e Bump lunatone-rest-api-client to 0.9.2 (#173918) 2026-06-16 12:45:19 +02:00
Robert Resch 210746525e Fix missing full sha as hidden field in requirements check aw (#173900) 2026-06-16 11:05:08 +02:00
Robert Resch 0134e99366 Token views should behave the same (#173500) 2026-06-16 10:46:18 +02:00
Oscar Calvo 06de89d6a3 Fix CCM15 temperature unit to follow the device's C/F setting (#173788) 2026-06-16 10:41:58 +02:00
Paul Bottein 4c267617f8 Publish numeric sensor device classes as generated sensor.json (#173919) 2026-06-16 11:41:27 +03:00
Franck Nijhof a82f1a7a1d Bump pyfireservicerota to 0.0.49 (#173935) 2026-06-16 10:36:34 +02:00
Franck Nijhof d234f65dd9 Bump heatmiserV3 to 2.0.6 (#173913)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-16 10:36:03 +02:00
John Pettitt 30148980e1 Add API_GEN_4 support to Subaru integration (#173956) 2026-06-16 10:32:02 +02:00
Franck Nijhof 1fa9a3353c Bump eufylife-ble-client to 0.1.10 (#173934) 2026-06-16 10:30:52 +02:00
Raphael Hehl 2dbbd70085 Use console name in UniFi Protect discovery title (#173966) 2026-06-16 10:25:50 +02:00
Franck Nijhof 73903b0bfc Bump pysesame2 to 1.0.2 (#173904) 2026-06-16 10:20:23 +02:00
Franck Nijhof b09f54ce3b Bump foobot_async to 1.0.1 (#173905) 2026-06-16 10:19:38 +02:00
Franck Nijhof 6d9e41da07 Bump pencompy to 0.0.4 (#173906) 2026-06-16 10:17:46 +02:00
Franck Nijhof f5600a602f Bump webexpythonsdk to 2.0.6 (#173916) 2026-06-16 09:50:37 +02:00
Franck Nijhof d83cd941a7 Bump hole to 0.9.2 (#173936) 2026-06-16 09:37:58 +02:00
Franck Nijhof 2120cad533 Bump pdunehd to 1.3.3 (#173907)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-06-16 09:34:44 +02:00
dependabot[bot] fb4e72af77 Bump home-assistant/builder from 2026.03.2 to 2026.06.0 (#173963)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-16 09:22:22 +02:00
Paul Bottein badd4130b6 Bump yoto-api to 4.3.0 (#173910) 2026-06-16 09:18:10 +02:00
Franck Nijhof 7a4ca4dcfd Bump roombapy to 1.9.1 (#173922) 2026-06-16 09:14:40 +02:00
Franck Nijhof 9b47a0d440 Bump atenpdu to 0.3.6 (#173932) 2026-06-16 09:10:16 +02:00
Franck Nijhof 4b99e81a8a Bump influxdb to 5.3.2 (#173891) 2026-06-16 08:54:20 +02:00
Franck Nijhof 62e5238f43 Bump tellcore-py to 1.1.3 (#173894) 2026-06-16 08:53:50 +02:00
Franck Nijhof 149c884a89 Bump DoorBirdPy to 3.0.12 (#173923) 2026-06-16 08:52:50 +02:00
Franck Nijhof 71ca453c42 Bump pyhomematic to 0.1.78 (#173925) 2026-06-16 08:52:27 +02:00
Franck Nijhof aad6080307 Bump omnilogic to 0.4.9 (#173938) 2026-06-16 08:51:40 +02:00
Franck Nijhof 2db2e0b0cf Bump aioairq to 0.4.8 (#173940) 2026-06-16 08:50:50 +02:00
Franck Nijhof 3fc36ab6f9 Bump messagebird to 1.2.1 (#173942) 2026-06-16 08:49:56 +02:00
Denis Shulyaka 0fad24393c Fix docs-data-update IQS for Anthropic (#173947) 2026-06-16 08:21:09 +02:00
Raphael Hehl a992a58367 Use console name in UniFi Access discovery title (#173962) 2026-06-16 08:20:29 +02:00
jasonjhofmann f0cefe2f2e Add network MAC connection to Rain Bird controller (#173672)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:15:33 +02:00
jasonjhofmann 40264992a2 Add network MAC connection to AnthemAV main zone device (#173682)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:12:52 +02:00
jasonjhofmann c29aebd60e Add network MAC connection to PlayStation 4 devices (#173681)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:11:25 +02:00
jasonjhofmann 36b74d6f05 Add network MAC connection to iAlarm device (#173676)
Co-authored-by: jasonjhofmann <16144532+jasonjhofmann@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:02:49 +02:00
jasonjhofmann 2c626fa8f0 Add network MAC connection to Rabbit Air devices (#173684)
Co-authored-by: jasonjhofmann <16144532+jasonjhofmann@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:01:22 +02:00
jasonjhofmann cab0d015f6 Add network MAC connection to Aprilaire devices (#173675)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:00:13 +02:00
Erik Montnemery c544f95979 Prime condition durations from history (#173426) 2026-06-16 07:53:41 +02:00
renovate[bot] 2189d0ae74 Update infrared-protocols to 6.0.1 (#173958) 2026-06-16 07:53:22 +02:00
Raphael Hehl 9e96a06aff Bump unifi-discovery to 1.5.0 (#173927) 2026-06-16 00:06:01 +02:00
Franck Nijhof d16e0e9867 Bump greeclimate to 2.1.4 (#173924) 2026-06-15 22:53:59 +02:00
Franck Nijhof 2209996919 Bump pyipma to 3.0.10 (#173943) 2026-06-15 22:09:00 +02:00
Franck Nijhof d88767155b Bump pykrakenapi to 0.1.9 (#173933) 2026-06-15 21:47:44 +02:00
Franck Nijhof 334d02077f Bump pypck to 0.9.13 (#173914) 2026-06-15 21:46:57 +02:00
Franck Nijhof 2b7e9289d2 Bump librouteros to 3.2.1 (#173937) 2026-06-15 21:41:42 +02:00
120 changed files with 2244 additions and 377 deletions
+2 -2
View File
@@ -193,7 +193,7 @@ jobs:
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
- name: Build base image
uses: home-assistant/builder/actions/build-image@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
View File
@@ -1,4 +1,4 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"328ce915f8b178bbcfcb1b69f397ac996456a4ab50490805b5b3bdd26cbf58fe","body_hash":"0665c72fe4b0a14b3a0b396dc293ce78235771fe15ebc894662fece33b189135","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"7b142e96e0f8b454cdcc9c0c25070cf9a52c44d83a6b1fbc3ad6725b6567337c","body_hash":"3894ded07d5934ac5f29d160ffb1f9115cf72b6da8a7e453a4d4f69e8641a48e","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"v0.79.6","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# ___ _ _
# / _ \ | | (_)
@@ -1500,11 +1500,12 @@ jobs:
echo "Artifact has no head_sha; running the agent."
exit 0
fi
# Recover the commit recorded in the most recent requirements-check comment.
# Recover the commit recorded in the most recent requirements-check
# comment from the "Checked at commit" link
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
| grep -oE '<!-- requirements-check-sha: [0-9a-f]{7,40} -->' \
| grep -oE '[0-9a-f]{7,40}' | tail -1 || true)
| grep -oiE '/commit/[0-9a-f]{40}' \
| grep -oiE '[0-9a-f]{40}' | tail -1 || true)
if [ -z "${PRIOR}" ]; then
echo "No previous comment with a recorded commit; running the agent."
exit 0
+6 -7
View File
@@ -51,11 +51,12 @@ jobs:
echo "Artifact has no head_sha; running the agent."
exit 0
fi
# Recover the commit recorded in the most recent requirements-check comment.
# Recover the commit recorded in the most recent requirements-check
# comment from the "Checked at commit" link
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
| grep -oE '<!-- requirements-check-sha: [0-9a-f]{7,40} -->' \
| grep -oE '[0-9a-f]{7,40}' | tail -1 || true)
| grep -oiE '/commit/[0-9a-f]{40}' \
| grep -oiE '[0-9a-f]{40}' | tail -1 || true)
if [ -z "${PRIOR}" ]; then
echo "No previous comment with a recorded commit; running the agent."
exit 0
@@ -188,10 +189,8 @@ Then stop. Do not improvise a verdict.
Replace every placeholder with the resolved value and emit
`rendered_comment` via `add_comment`. Preserve the leading
`<!-- requirements-check -->` marker and the
`<!-- requirements-check-sha: … -->` marker that follows it — the next
run reads the recorded commit from it to decide whether anything changed.
The PR target is already wired; do not pass `item_number`.
`<!-- requirements-check -->` marker. The PR target is already wired;
do not pass `item_number`.
## Check instructions
+1 -1
View File
@@ -102,7 +102,7 @@ repos:
pass_filenames: false
language: script
types: [text]
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|homeassistant/components/sensor/const\.py|requirements.+\.txt)$
- id: hassfest-metadata
name: hassfest-metadata
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
+1 -1
View File
@@ -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"]
}
+17 -5
View File
@@ -59,11 +59,23 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aqvify sensor entities from a config entry."""
async_add_entities(
AqvifySensor(entry.runtime_data, description, device_key)
for description in ENTITIES
for device_key in entry.runtime_data.data.devices.devices
)
coordinator = entry.runtime_data
added_devices: set[str] = set()
def _async_add_new_devices() -> None:
nonlocal added_devices
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
added_devices = current_devices
async_add_entities(
AqvifySensor(coordinator, description, device_key)
for description in ENTITIES
for device_key in new_devices_set
)
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
_async_add_new_devices()
class AqvifySensor(AqvifyBaseEntity, SensorEntity):
@@ -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"]
}
+1 -1
View File
@@ -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):
+10 -4
View File
@@ -785,7 +785,9 @@ class CameraView(HomeAssistantView):
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
if (camera := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
raise (
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
)
authenticated = (
request[KEY_AUTHENTICATED]
@@ -793,11 +795,15 @@ class CameraView(HomeAssistantView):
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
# A failed request that carried an Authorization header is a real
# Bearer auth attempt — return 401 and let the ban middleware count
# it as a wrong login.
raise web.HTTPUnauthorized
# Invalid sigAuth or camera access token
# No Authorization header: most likely a benign signed-URL / query-
# token request whose token has expired (e.g. a browser tab left
# open that re-fetches resources later). Return 403 so it doesn't
# register as a wrong login and ban the user's own IP.
raise web.HTTPForbidden
if not camera.is_on:
@@ -3,6 +3,7 @@
from pycasperglow import CasperGlow
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -27,7 +28,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"address": address},
translation_placeholders={
"address": address,
"reason": bluetooth.async_address_reachability_diagnostics(
hass, address.upper(), BluetoothReachabilityIntent.CONNECTION
),
},
)
glow = CasperGlow(ble_device)
@@ -56,7 +56,7 @@
"message": "An error occurred while communicating with the Casper Glow: {error}"
},
"device_not_found": {
"message": "Could not find Casper Glow device with address {address}"
"message": "Could not find Casper Glow device with address {address}: {reason}"
}
}
}
+7 -1
View File
@@ -49,7 +49,6 @@ async def async_setup_entry(
class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
"""Climate device for CCM15 coordinator."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_has_entity_name = True
_attr_target_temperature_step = PRECISION_WHOLE
_attr_hvac_modes = [
@@ -93,6 +92,13 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
"""Return device data."""
return self.coordinator.get_ac_data(self._ac_index)
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement reported by the device."""
if (data := self.data) is not None and not data.is_celsius:
return UnitOfTemperature.FAHRENHEIT
return UnitOfTemperature.CELSIUS
@property
def current_temperature(self) -> int | None:
"""Return current temperature."""
@@ -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"]
}
+3 -1
View File
@@ -30,7 +30,9 @@
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
"api_key": "[%key:common::config_flow::data::api_key%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"description": "Please enter the API key obtained from ecobee.com."
}
@@ -2,6 +2,12 @@
"domain": "eufylife_ble",
"name": "EufyLife",
"bluetooth": [
{
"local_name": "eufy T9120"
},
{
"local_name": "eufy T9130"
},
{
"local_name": "eufy T9140"
},
@@ -16,6 +22,9 @@
},
{
"local_name": "eufy T9149"
},
{
"local_name": "eufy T9150"
}
],
"codeowners": ["@bdr99"],
@@ -24,5 +33,5 @@
"documentation": "https://www.home-assistant.io/integrations/eufylife_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["eufylife-ble-client==0.1.8"]
"requirements": ["eufylife-ble-client==0.1.10"]
}
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyfireservicerota"],
"requirements": ["pyfireservicerota==0.0.46"]
"requirements": ["pyfireservicerota==0.0.49"]
}
@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["foobot_async"],
"quality_scale": "legacy",
"requirements": ["foobot_async==1.0.0"]
"requirements": ["foobot_async==1.0.1"]
}
+1 -1
View File
@@ -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"]
}
+10 -4
View File
@@ -326,7 +326,9 @@ class ImageView(HomeAssistantView):
) -> ImageEntity:
"""Authenticate request and return image entity."""
if (image_entity := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
raise (
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
)
authenticated = (
request[KEY_AUTHENTICATED]
@@ -334,11 +336,15 @@ class ImageView(HomeAssistantView):
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
# A failed request that carried an Authorization header is a real
# Bearer auth attempt — return 401 and let the ban middleware count
# it as a wrong login.
raise web.HTTPUnauthorized
# Invalid sigAuth or image entity access token
# No Authorization header: most likely a benign signed-URL / query-
# token request whose token has expired (e.g. a browser tab left
# open that re-fetches resources later). Return 403 so it doesn't
# register as a wrong login and ban the user's own IP.
raise web.HTTPForbidden
return image_entity
@@ -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"]
}
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["geopy", "pyipma"],
"requirements": ["pyipma==3.0.9"]
"requirements": ["pyipma==3.0.10"]
}
+2 -1
View File
@@ -31,7 +31,8 @@
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
"host": "[%key:common::config_flow::data::host%]",
"protocol": "Protocol"
},
"data_description": {
"host": "Hostname or IP address of your Iskra device."
@@ -7,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"]
}
+1 -1
View File
@@ -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"]
}
+1 -1
View File
@@ -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
}
+2 -1
View File
@@ -11,7 +11,8 @@
"step": {
"confirm": {
"data": {
"code": "Verification code (OTP)"
"code": "Verification code (OTP)",
"qr_code": "QR code"
},
"data_description": {
"code": "The six-digit code currently displayed in your authentication app."
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pencompy"],
"quality_scale": "legacy",
"requirements": ["pencompy==0.0.3"]
"requirements": ["pencompy==0.0.4"]
}
@@ -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",
+2 -1
View File
@@ -6,7 +6,7 @@ from typing import Any
from rabbitair import Model
from homeassistant.const import CONF_MAC
from homeassistant.helpers.device_registry import 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"]
}
+1 -1
View File
@@ -13,7 +13,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"additional_account": {
"add_account": {
"data": {
"account": "[%key:component::sia::config::step::user::data::account%]",
"additional_account": "[%key:component::sia::config::step::user::data::additional_account%]",
+1
View File
@@ -29,6 +29,7 @@ VEHICLE_STATUS = "vehicle_status"
API_GEN_1 = "g1"
API_GEN_2 = "g2"
API_GEN_3 = "g3"
API_GEN_4 = "g4"
MANUFACTURER = "Subaru"
PLATFORMS = [
+3 -2
View File
@@ -24,6 +24,7 @@ from . import get_device_info
from .const import (
API_GEN_2,
API_GEN_3,
API_GEN_4,
VEHICLE_API_GEN,
VEHICLE_HAS_EV,
VEHICLE_STATUS,
@@ -153,10 +154,10 @@ def create_vehicle_sensors(
sensor_descriptions_to_add = []
sensor_descriptions_to_add.extend(SAFETY_SENSORS)
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_2, API_GEN_3]:
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_2, API_GEN_3, API_GEN_4]:
sensor_descriptions_to_add.extend(API_GEN_2_SENSORS)
if vehicle_info[VEHICLE_API_GEN] == API_GEN_3:
if vehicle_info[VEHICLE_API_GEN] in [API_GEN_3, API_GEN_4]:
sensor_descriptions_to_add.extend(API_GEN_3_SENSORS)
if vehicle_info[VEHICLE_HAS_EV]:
@@ -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"]
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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}"
+1 -1
View File
@@ -11,7 +11,7 @@
"service_unavailable": "[%key:common::config_flow::error::cannot_connect%]",
"unknown_client_mac": "No client available on that MAC address"
},
"flow_title": "{site} ({host})",
"flow_title": "{name} ({host})",
"step": {
"site": {
"data": {
@@ -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"],
}
+1 -1
View File
@@ -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"]
}
+12
View File
@@ -116,6 +116,14 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"domain": "eq3btsmart",
"local_name": "CC-RT-BLE-EQ",
},
{
"domain": "eufylife_ble",
"local_name": "eufy T9120",
},
{
"domain": "eufylife_ble",
"local_name": "eufy T9130",
},
{
"domain": "eufylife_ble",
"local_name": "eufy T9140",
@@ -136,6 +144,10 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"domain": "eufylife_ble",
"local_name": "eufy T9149",
},
{
"domain": "eufylife_ble",
"local_name": "eufy T9150",
},
{
"connectable": True,
"domain": "eurotronic_cometblue",
+61
View File
@@ -0,0 +1,61 @@
{
"numeric_device_classes": [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed"
]
}
+162 -11
View File
@@ -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."""
+2 -24
View File
@@ -312,10 +312,6 @@ def async_track_state_change_event(
Unlike async_track_state_change, async_track_state_change_event
passes the full event to the callback.
The action will not be called immediately, but will be scheduled to run
in the next event loop iteration, even if the action is decorated with
@callback.
In order to avoid having to iterate a long list
of EVENT_STATE_CHANGED and fire and create a job
for each one, we keep a dict of entity ids that
@@ -331,16 +327,6 @@ def async_track_state_change_event(
return _async_track_state_change_event(hass, entity_ids, action, job_type)
@callback
def _async_dispatch_entity_id_event_soon[_StateEventDataT: EventStateEventData](
hass: HomeAssistant,
callbacks: dict[str, list[HassJob[[Event[_StateEventDataT]], Any]]],
event: Event[_StateEventDataT],
) -> None:
"""Dispatch to listeners soon to ensure one event loop runs before dispatch."""
hass.loop.call_soon(_async_dispatch_entity_id_event, hass, callbacks, event)
@callback
def _async_dispatch_entity_id_event[_StateEventDataT: EventStateEventData](
hass: HomeAssistant,
@@ -374,7 +360,7 @@ def _async_state_filter[_StateEventDataT: EventStateEventData](
_KEYED_TRACK_STATE_CHANGE = _KeyedEventTracker(
key=_TRACK_STATE_CHANGE_DATA,
event_type=EVENT_STATE_CHANGED,
dispatcher_callable=_async_dispatch_entity_id_event_soon,
dispatcher_callable=_async_dispatch_entity_id_event,
filter_callable=_async_state_filter,
)
@@ -397,7 +383,7 @@ def _async_track_state_change_event(
_KEYED_TRACK_STATE_REPORT = _KeyedEventTracker(
key=_TRACK_STATE_REPORT_DATA,
event_type=EVENT_STATE_REPORTED,
dispatcher_callable=_async_dispatch_entity_id_event_soon,
dispatcher_callable=_async_dispatch_entity_id_event,
filter_callable=_async_state_filter,
)
@@ -860,10 +846,6 @@ def async_track_state_change_filtered(
) -> _TrackStateChangeFiltered:
"""Track state changes with a TrackStates filter that can be updated.
The action will not be called immediately, but will be scheduled to run
in the next event loop iteration, even if the action is decorated with
@callback.
Args:
hass:
Home assistant object.
@@ -1336,10 +1318,6 @@ def async_track_template_result(
evaluation is different from the previous run, the action is passed
the result.
The action will not be called immediately, but will be scheduled to run
in the next event loop iteration, even if the action is decorated with
@callback.
If the template results in an TemplateError, this will be returned to
the listener the first time this happens but not for subsequent errors.
Once the template returns to a non-error condition the result is sent
+66 -15
View File
@@ -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()
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+30 -30
View File
@@ -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
+5 -9
View File
@@ -12,11 +12,8 @@ agent will refuse to resolve the new kind.
from .models import CheckKind, CheckRunResult, CheckStatus, PackageChange
MARKER = "<!-- requirements-check -->"
# Hidden marker carrying the PR head commit the checks ran against. The agentic
# stage's gate job parses it from the previous comment to decide whether any
# tracked requirement file changed since then; if not, the agent is skipped.
SHA_MARKER_PREFIX = "<!-- requirements-check-sha:"
HEADER = "## Check requirements"
REPO_URL = "https://github.com/home-assistant/core"
# Column / bullet labels per check kind, in display order.
_CHECK_DISPLAY: tuple[tuple[CheckKind, str], ...] = (
@@ -116,13 +113,12 @@ def _details_block(pkg: PackageChange) -> str:
def _intro(result: CheckRunResult) -> str:
"""Marker(s), header, and the optional visible commit line."""
markers = MARKER
"""Marker, header, and the optional commit line the gate reads back."""
parts: list[str] = []
if result.head_sha:
markers = f"{MARKER}\n{SHA_MARKER_PREFIX} {result.head_sha} -->"
parts.append(f"Checked at commit `{result.head_sha[:7]}`.")
return "\n\n".join([f"{markers}\n{HEADER}", *parts])
commit = f"[`{result.head_sha[:7]}`]({REPO_URL}/commit/{result.head_sha})"
parts.append(f"Checked at commit {commit}.")
return "\n\n".join([f"{MARKER}\n{HEADER}", *parts])
def render_comment(result: CheckRunResult) -> str:
+2
View File
@@ -29,6 +29,7 @@ from . import (
mypy_config,
quality_scale,
requirements,
sensor,
services,
ssdp,
translations,
@@ -69,6 +70,7 @@ HASS_PLUGINS = [
mdi_icons,
mypy_config,
metadata,
sensor,
]
ALL_PLUGIN_NAMES = [
+40
View File
@@ -0,0 +1,40 @@
"""Generate the sensor.json file."""
import json
from homeassistant.components.sensor.const import (
NON_NUMERIC_DEVICE_CLASSES,
SensorDeviceClass,
)
from .model import Config, Integration
PATH = "homeassistant/generated/sensor.json"
def _generate() -> str:
"""Generate the sensor data."""
numeric_device_classes = sorted(
device_class.value
for device_class in set(SensorDeviceClass) - NON_NUMERIC_DEVICE_CLASSES
)
return json.dumps({"numeric_device_classes": numeric_device_classes}, indent=2)
def validate(integrations: dict[str, Integration], config: Config) -> None:
"""Validate sensor.json."""
path = config.root / PATH
config.cache["sensor"] = content = _generate()
if path.read_text() != content + "\n":
config.add_error(
"sensor",
"File sensor.json is not up to date. Run python3 -m script.hassfest",
fixable=True,
)
def generate(integrations: dict[str, Integration], config: Config) -> None:
"""Generate sensor.json."""
path = config.root / PATH
path.write_text(f"{config.cache['sensor']}\n")
@@ -0,0 +1,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,
})
# ---
+16
View File
@@ -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,
})
# ---
+52
View File
@@ -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"
}
+32 -2
View File
@@ -12,7 +12,7 @@ from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_load_json_object_fixture
async def test_full_flow(
@@ -32,6 +32,36 @@ async def test_full_flow(
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Mr Aquarius"
assert result["data"] == {
CONF_API_KEY: "test-api-key",
}
assert result["result"].unique_id == "test_account_id"
assert len(mock_setup_entry.mock_calls) == 1
async def test_missing_username(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_aqvify_client: MagicMock
) -> None:
"""Test full flow with missing name in account."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
mock_aqvify_client.async_get_account_id.return_value = AqvifyAccount(
await async_load_json_object_fixture(hass, "empty_name_account.json", DOMAIN)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: "test-api-key",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Aqvify"
assert result["data"] == {
@@ -88,7 +118,7 @@ async def test_form_invalid(
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Aqvify"
assert result["title"] == "Mr Aquarius"
assert result["data"] == {
CONF_API_KEY: "test-api-key",
}
+29 -2
View File
@@ -84,8 +84,10 @@ async def test_device_registry_integration(
device_registry, mock_config_entry.entry_id
)
# Snapshot the devices to ensure they have the correct structure
assert device_entries == snapshot
sorted_devices = sorted(
device_entries, key=lambda dev_entry: dev_entry.serial_number
)
assert sorted_devices == snapshot
async def test_setup_entry_auth_error_triggers_reauth(
@@ -132,6 +134,31 @@ async def test_autoremove_stale_devices(
assert hass.states.get("sensor.device_2_water_level") is None
async def test_devices_multiple_created_count(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_aqvify_client: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that added devices are created."""
await setup_integration(hass, mock_config_entry)
assert len(device_registry.devices) == 2
assert hass.states.get("sensor.device_3_water_level") is None
mock_aqvify_client.async_get_devices.return_value = AqvifyDevices(
await async_load_json_array_fixture(hass, "added_devices.json", DOMAIN)
)
freezer.tick(timedelta(seconds=240))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(device_registry.devices) == 3
assert hass.states.get("sensor.device_3_water_level").state == EXPECTED_WATER_LEVEL
@pytest.mark.parametrize(
("exception", "log_message", "expected_state"),
[
+42
View File
@@ -5,6 +5,7 @@ from http import HTTPStatus
import io
from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, mock_open, patch
from aiohttp import hdrs
import pytest
from syrupy.assertion import SnapshotAssertion
from webrtc_models import RTCIceCandidateInit
@@ -691,6 +692,47 @@ async def test_camera_proxy_stream(hass_client: ClientSessionGenerator) -> None:
assert response.status == HTTPStatus.BAD_GATEWAY
@pytest.mark.usefixtures("mock_camera")
async def test_camera_proxy_unauthenticated(
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
) -> None:
"""Test camera_proxy with an unauthenticated client."""
client = await hass_client_no_auth()
# Invalid token and no Authorization header: skip ban by 403
resp = await client.get("/api/camera_proxy/camera.demo_camera?token=invalid_token")
assert resp.status == HTTPStatus.FORBIDDEN
# An invalid Bearer token is a real auth attempt, return 401 so the ban
# middleware can handle it.
resp = await client.get(
"/api/camera_proxy/camera.demo_camera",
headers={hdrs.AUTHORIZATION: "blabla"},
)
assert resp.status == HTTPStatus.UNAUTHORIZED
# A valid access token in the query is accepted.
state = hass.states.get("camera.demo_camera")
resp = await client.get(state.attributes["entity_picture"])
assert resp.status == HTTPStatus.OK
assert await resp.read() == b"Test"
# Unknown entity while unauthenticated returns 401
resp = await client.get("/api/camera_proxy/camera.unknown")
assert resp.status == HTTPStatus.UNAUTHORIZED
@pytest.mark.usefixtures("mock_camera")
async def test_camera_proxy_authenticated_unknown_entity(
hass_client: ClientSessionGenerator,
) -> None:
"""Test camera_proxy for an unknown entity with an authenticated client."""
client = await hass_client()
resp = await client.get("/api/camera_proxy/camera.unknown")
assert resp.status == HTTPStatus.NOT_FOUND
@pytest.mark.usefixtures("mock_camera")
async def test_state_streaming(hass: HomeAssistant) -> None:
"""Camera state."""
+6 -2
View File
@@ -43,8 +43,12 @@ async def test_async_setup_entry_device_not_found(
"""Test setup raises ConfigEntryNotReady when BLE device is not found."""
mock_config_entry.add_to_hass(hass)
# Do not inject BLE info — device is not in the cache
await hass.config_entries.async_setup(mock_config_entry.entry_id)
with patch(
"homeassistant.components.bluetooth.async_address_reachability_diagnostics",
return_value="no_reason",
):
# Do not inject BLE info — device is not in the cache
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
+45 -2
View File
@@ -3,13 +3,14 @@
from datetime import timedelta
from unittest.mock import patch
from ccm15 import CCM15DeviceState
from ccm15 import CCM15DeviceState, CCM15SlaveDevice
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.ccm15.const import DOMAIN
from homeassistant.components.climate import (
ATTR_CURRENT_TEMPERATURE,
ATTR_FAN_MODE,
ATTR_HVAC_MODE,
ATTR_TEMPERATURE,
@@ -21,9 +22,16 @@ from homeassistant.components.climate import (
SERVICE_TURN_ON,
HVACMode,
)
from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, SERVICE_TURN_OFF
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_HOST,
CONF_PORT,
SERVICE_TURN_OFF,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -130,3 +138,38 @@ async def test_climate_state(
assert hass.states.get("climate.midea_0") == snapshot
assert hass.states.get("climate.midea_1") == snapshot
async def test_climate_fahrenheit_unit(hass: HomeAssistant) -> None:
"""A controller set to Fahrenheit is reported in Fahrenheit."""
hass.config.units = US_CUSTOMARY_SYSTEM
device_state = CCM15DeviceState(
devices={0: CCM15SlaveDevice(bytes.fromhex("01000041c0004b"))}
)
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="1.1.1.1",
data={CONF_HOST: "1.1.1.1", CONF_PORT: 80},
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.ccm15.coordinator.CCM15Device.get_status_async",
return_value=device_state,
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# The entity must report the device's Fahrenheit unit, not hardcoded Celsius.
climate_component = hass.data[CLIMATE_DOMAIN]
entity = climate_component.get_entity("climate.midea_0")
assert entity is not None
assert entity.temperature_unit == UnitOfTemperature.FAHRENHEIT
# With the entity already in Fahrenheit under the US system, the device's
# native values pass through unconverted; were it still Celsius they would
# be converted and differ.
state = hass.states.get("climate.midea_0")
assert state is not None
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 75
assert state.attributes[ATTR_TEMPERATURE] == 86
@@ -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,
})
# ---
+23 -1
View File
@@ -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")
+1 -1
View File
@@ -251,7 +251,7 @@ async def test_fetch_image_unauthenticated(
assert body == b"Test"
resp = await client.get("/api/image_proxy/image.unknown")
assert resp.status == HTTPStatus.NOT_FOUND
assert resp.status == HTTPStatus.UNAUTHORIZED
@respx.mock

Some files were not shown because too many files have changed in this diff Show More