Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -734,12 +734,49 @@ jobs:
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
- name: Restore pytest test counts cache
id: cache-pytest-counts
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: pytest_test_counts.json
# Primary key is a sentinel; restore-keys pick the most recent
# prefix match since the real (content-addressed) key isn't
# known until split_tests.py runs below.
key: >-
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}-restore-sentinel
restore-keys: |
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }}-
- name: Run split_tests.py
env:
TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }}
run: |
. venv/bin/activate
python -m script.split_tests ${TEST_GROUP_COUNT} tests
python -m script.split_tests \
--cache pytest_test_counts.json \
${TEST_GROUP_COUNT} tests
- name: Hash pytest test counts cache
id: cache-pytest-counts-hash
run: |
echo "hash=$(sha256sum pytest_test_counts.json | cut -d' ' -f1)" \
>> "$GITHUB_OUTPUT"
- name: Save pytest test counts cache
# Content-addressed key: identical content reuses the same entry.
# Skip the save when the restore already matched that hash.
if: >-
!endsWith(
steps.cache-pytest-counts.outputs.cache-matched-key,
steps.cache-pytest-counts-hash.outputs.hash
)
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: pytest_test_counts.json
key: >-
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}-${{
steps.cache-pytest-counts-hash.outputs.hash }}
- name: Upload pytest_buckets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/aidot/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
"name": "AiDot",
"codeowners": ["@s1eedz", "@HongBryan"],
"config_flow": true,
"dhcp": [
{
"hostname": "aidot"
}
],
"documentation": "https://www.home-assistant.io/integrations/aidot",
"integration_type": "hub",
"iot_class": "local_polling",
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/aidot/quality_scale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ rules:
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/components/aidot/strings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
Expand Down
40 changes: 40 additions & 0 deletions homeassistant/components/config/device_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
def async_setup(hass: HomeAssistant) -> bool:
"""Enable the Device Registry views."""

websocket_api.async_register_command(hass, websocket_list_composite_splits)
websocket_api.async_register_command(hass, websocket_list_devices)
websocket_api.async_register_command(hass, websocket_update_device)
websocket_api.async_register_command(
Expand All @@ -25,6 +26,45 @@ def async_setup(hass: HomeAssistant) -> bool:
return True


@callback
@websocket_api.websocket_command(
{
vol.Required("type"): "config/device_registry/list_composite_splits",
}
)
def websocket_list_composite_splits(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Handle list composite device splits command.

Maps every pre-migration composite device id that was removed by splitting the
device into one device per config entry to the ids of the devices which replaced
it, and which of those (if any) belongs to the composite's former primary config
entry.
"""
registry = dr.async_get(hass)
connection.send_result(
msg["id"],
{
composite_id: {
"split_ids": [device.id for device in devices],
"primary_id": next(
(
device.id
for device in devices
if device.config_entry_id
== device.composite_primary_config_entry
),
None,
),
}
for composite_id, devices in registry.devices.get_composite_splits().items()
},
)


@callback
@websocket_api.websocket_command(
{
Expand Down
23 changes: 16 additions & 7 deletions homeassistant/components/http/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -612,13 +612,22 @@ async def _async_create_redirect_server(self) -> asyncio.Server:

async def _async_stop_legacy_redirect(self) -> None:
"""Stop the legacy redirect server and free its port."""
if self._legacy_redirect_server is not None:
self._legacy_redirect_server.close()
await self._legacy_redirect_server.wait_closed()
self._legacy_redirect_server = None
if self._legacy_redirect_runner is not None:
await self._legacy_redirect_runner.cleanup()
self._legacy_redirect_runner = None
server = self._legacy_redirect_server
runner = self._legacy_redirect_runner
# Drop the references up front: both the onboarding listener and the
# stop event tear the redirect down, so a second call must not wait on
# a teardown that is already in flight.
self._legacy_redirect_server = None
self._legacy_redirect_runner = None
if server is not None:
# Stop accepting new connections, but do not await wait_closed()
# yet: it only returns once every open connection is done, and the
# runner cleanup below is what actually closes them.
server.close()
if runner is not None:
await runner.cleanup()
if server is not None:
await server.wait_closed()

async def stop(self) -> None:
"""Stop the aiohttp server."""
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/mealie/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["aiomealie==1.2.4"]
"requirements": ["aiomealie==2.0.0"]
}
6 changes: 2 additions & 4 deletions homeassistant/components/reolink/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,7 @@ class ReolinkHostButtonEntityDescription(
key="ptz_stop",
translation_key="ptz_stop",
enabled_default=lambda api, ch: api.supported(ch, "pan_tilt"),
supported=lambda api, ch: (
api.supported(ch, "pan_tilt") or api.supported(ch, "zoom_basic")
),
supported=lambda api, ch: api.supported(ch, "ptz_stop"),
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.stop.value),
),
ReolinkButtonEntityDescription(
Expand Down Expand Up @@ -204,7 +202,7 @@ async def async_setup_entry(
entities: list[ReolinkButtonEntity | ReolinkHostButtonEntity] = [
ReolinkButtonEntity(reolink_data, channel, entity_description)
for entity_description in BUTTON_ENTITIES
for channel in reolink_data.host.api.channels
for channel in reolink_data.host.api.stream_channels
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
Expand Down
6 changes: 6 additions & 0 deletions homeassistant/components/reolink/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,12 @@
"on": "mdi:eye-off"
}
},
"privacy_mask_lens": {
"default": "mdi:eye",
"state": {
"on": "mdi:eye-off"
}
},
"privacy_mode": {
"default": "mdi:eye",
"state": {
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/reolink/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -966,13 +966,13 @@ async def async_setup_entry(
entities: list[NumberEntity] = [
ReolinkNumberEntity(reolink_data, channel, entity_description)
for entity_description in NUMBER_ENTITIES
for channel in api.channels
for channel in api.stream_channels
if entity_description.supported(api, channel)
]
entities.extend(
ReolinkSmartAINumberEntity(reolink_data, channel, location, entity_description)
for entity_description in SMART_AI_NUMBER_ENTITIES
for channel in api.channels
for channel in api.stream_channels
for location in api.baichuan.smart_location_list(
channel, entity_description.smart_type
)
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/reolink/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ async def async_setup_entry(
entities: list[SelectEntity] = [
ReolinkSelectEntity(reolink_data, channel, entity_description)
for entity_description in SELECT_ENTITIES
for channel in reolink_data.host.api.channels
for channel in reolink_data.host.api.stream_channels
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/reolink/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ async def async_setup_entry(
] = [
ReolinkSensorEntity(reolink_data, channel, entity_description)
for entity_description in SENSORS
for channel in reolink_data.host.api.channels
for channel in reolink_data.host.api.stream_channels
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/components/reolink/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,9 @@
"privacy_mask": {
"name": "Privacy mask"
},
"privacy_mask_lens": {
"name": "Privacy mask lens {channel}"
},
"privacy_mode": {
"name": "Privacy mode"
},
Expand Down
8 changes: 7 additions & 1 deletion homeassistant/components/reolink/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ class ReolinkSwitchIndexEntityDescription(
key="privacy_mask",
cmd_key="GetMask",
translation_key="privacy_mask",
lens_entity=True,
entity_category=EntityCategory.CONFIG,
supported=lambda api, ch: api.supported(ch, "privacy_mask"),
value=lambda api, ch: api.privacy_mask_enabled(ch),
Expand Down Expand Up @@ -358,7 +359,7 @@ async def async_setup_entry(
entities: list[SwitchEntity] = [
ReolinkSwitchEntity(reolink_data, channel, entity_description)
for entity_description in SWITCH_ENTITIES
for channel in reolink_data.host.api.channels
for channel in reolink_data.host.api.stream_channels
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
Expand Down Expand Up @@ -401,6 +402,11 @@ def __init__(
"""Initialize Reolink switch entity."""
self.entity_description = entity_description
super().__init__(reolink_data, channel)
if self.entity_description.lens_entity and self._host.api.is_dual_lens:
self._attr_translation_key = f"{entity_description.translation_key}_lens"
self._attr_translation_placeholders = {
"channel": str(self._channel),
}

@property
@override
Expand Down
10 changes: 7 additions & 3 deletions homeassistant/components/slack/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
)
from homeassistant.const import ATTR_ICON, CONF_PATH
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import aiohttp_client, config_validation as cv, template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

Expand All @@ -34,6 +35,7 @@
ATTR_USERNAME,
CONF_DEFAULT_CHANNEL,
DATA_CLIENT,
DOMAIN,
SLACK_DATA,
)
from .utils import upload_file_to_slack
Expand Down Expand Up @@ -278,10 +280,12 @@ async def async_send_message(self, message: str, **kwargs: Any) -> None:

try:
DATA_SCHEMA(data)
# pylint: disable-next=home-assistant-action-swallowed-exception
except vol.Invalid as err:
_LOGGER.error("Invalid message data: %s", err)
data = {}
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_message_data",
translation_placeholders={"error": str(err)},
) from err

title = kwargs.get(ATTR_TITLE)
targets = _async_sanitize_channel_names(
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/slack/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,10 @@
"name": "Do not disturb until"
}
}
},
"exceptions": {
"invalid_message_data": {
"message": "Invalid message data: {error}"
}
}
}
29 changes: 27 additions & 2 deletions homeassistant/components/template/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
)
from homeassistant.core import Event, HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryError, HomeAssistantError
from homeassistant.helpers import discovery
from homeassistant.helpers import device_registry as dr, discovery, issue_registry as ir
from homeassistant.helpers.helper_integration import async_remove_helper_devices
from homeassistant.helpers.reload import async_reload_integration_platforms
from homeassistant.helpers.service import async_register_admin_service
Expand Down Expand Up @@ -91,15 +91,35 @@ async def _reload_config(call: Event | ServiceCall) -> None:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""

device_id = entry.options.get(CONF_DEVICE_ID)

# Clean up devices this helper created for previously selected source devices;
# this can be removed in HA Core 2027.8.
async_remove_helper_devices(
hass,
helper_config_entry_id=entry.entry_id,
source_device_id=entry.options.get(CONF_DEVICE_ID),
source_device_id=device_id,
remove_all_devices=True,
)

if device_id is not None and dr.async_get(hass).async_is_composite_device_id(
device_id
):
# The device was split into one device per config entry; ask the user to
# select a device again
ir.async_create_issue(
hass,
DOMAIN,
f"composite_device_id_{entry.entry_id}",
data={"entry_id": entry.entry_id},
is_fixable=True,
severity=ir.IssueSeverity.WARNING,
translation_key="composite_device_id",
translation_placeholders={"name": entry.title},
)
else:
ir.async_delete_issue(hass, DOMAIN, f"composite_device_id_{entry.entry_id}")

for key in (CONF_MAX, CONF_MIN, CONF_STEP):
if key not in entry.options:
continue
Expand All @@ -123,6 +143,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)


async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle removal of a config entry."""
ir.async_delete_issue(hass, DOMAIN, f"composite_device_id_{entry.entry_id}")


async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry."""

Expand Down
4 changes: 3 additions & 1 deletion homeassistant/components/template/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ def __init__(
)

device_registry = dr.async_get(hass)
if (device_id := config.get(CONF_DEVICE_ID)) is not None:
if (
device_id := config.get(CONF_DEVICE_ID)
) is not None and not device_registry.async_is_composite_device_id(device_id):
self.device_entry = device_registry.async_get(device_id)

@property
Expand Down
Loading
Loading