Skip to main content

Browse all posts by date →

New entity lifecycle hooks

· 8 min read

Summary​

Two new entity lifecycle hooks have been added:

  • Entity.async_prepare_to_add_to_hass allows integrations to run code before an entity is added to Home Assistant. It's available from Home Assistant Core 2026.10.
  • Entity.async_entity_id_changed is called when an entity's entity_id is changed in the entity registry. From Home Assistant Core 2026.11, an entity is no longer removed and added again when its entity_id is changed; it's updated in place instead.

Custom integrations which implement async_prepare_to_add_to_hass, async_added_to_hass or async_will_remove_from_hass need to opt in to in-place entity_id changes by implementing async_entity_id_changed. Until they do, their entities are still removed and added again when the entity_id changes. This backwards compatibility will be removed in Home Assistant Core 2027.11.

The lifecycle hooks are documented in entity lifecycle hooks.

Running code before an entity is added​

The new hook​

async def async_prepare_to_add_to_hass(self) -> None:
"""Run before the entity is added to hass."""

async_prepare_to_add_to_hass is awaited on every add attempt, before the entity platform processes the entity's registry entry for this add attempt, and before the entity's state is written to the state machine. The entity's hass and platform attributes are assigned when the hook runs, but the hook must not rely on entity_id or registry_entry: depending on how the entity is added, they may be unset, set by the integration, or left over from an earlier add.

Unlike async_added_to_hass, the hook also runs for adds which will be aborted, for example because the entity is disabled in the entity registry, or because its entity_id or unique_id collides with an entity which has already been added. That's what makes it useful for work which must happen regardless of whether the entity ends up being added, but it also means code in the hook must not assume that the add will complete.

Raising an exception from the hook aborts the add.

This is implemented in core PR #182156, the rationale is described in architecture proposal home-assistant/architecture#1480.

Cleaning up​

Since the add may be aborted, async_will_remove_from_hass is not guaranteed to run for an entity whose async_prepare_to_add_to_hass has run. Clean up of anything set up in the hook should be registered with async_on_remove, which is called both when an add is aborted and when a successfully added entity is removed:

async def async_prepare_to_add_to_hass(self) -> None:
"""Run before the entity is added to hass."""
await super().async_prepare_to_add_to_hass()
unsubscribe = self._device.subscribe(self._handle_update)
self.async_on_remove(unsubscribe)

Migrating from add_to_platform_start​

Integrations have so far accomplished running code before an entity is added by overriding Entity.add_to_platform_start. That method is an implementation detail of the entity platform helper and is not meant to be overridden. Integrations which override it should override async_prepare_to_add_to_hass instead. The new hook takes no arguments; use self.hass and self.platform in place of the hass and platform arguments, and self.parallel_updates in place of parallel_updates.

Before:

@callback
def add_to_platform_start(
self,
hass: HomeAssistant,
platform: EntityPlatform,
parallel_updates: asyncio.Semaphore | None,
) -> None:
"""Start adding an entity to a platform."""
super().add_to_platform_start(hass, platform, parallel_updates)
_async_register_thing(hass, platform.platform_name, self.unique_id)

After:

async def async_prepare_to_add_to_hass(self) -> None:
"""Run before the entity is added to hass."""
await super().async_prepare_to_add_to_hass()
_async_register_thing(self.hass, self.platform.platform_name, self.unique_id)

Changing the entity ID in place​

Until now, an entity was removed and then added again under its new entity_id when its entity_id was changed in the entity registry. This ran the entity's whole remove and add lifecycle again, including async_will_remove_from_hass and async_added_to_hass, even though for most entities nothing but the entity_id itself had to change.

From Home Assistant Core 2026.11, the entity is instead updated in place. Home Assistant removes the state under the old entity_id, sets self.entity_id to the new entity_id, moves its own bookkeeping (entity registry and device registry tracking, entity sources, restore state and entity groups) to the new entity_id, and writes the state under the new entity_id. async_will_remove_from_hass and async_added_to_hass are not called.

This is implemented in core PR #183946, PR #184943 and PR #185462, the rationale is described in architecture proposal home-assistant/architecture#1393.

The new hook​

Entities which have set up anything keyed on their own entity_id can implement a new callback to update it:

@callback
def async_entity_id_changed(self, old_entity_id: str) -> None:
"""Run when the entity_id has been changed in the entity registry."""

async_entity_id_changed is called after Home Assistant has removed the state under the old entity_id, moved its own bookkeeping and run async_registry_entry_updated. self.entity_id is already the new entity_id, the previous entity_id is passed as old_entity_id. Use it to update anything the state or attributes are derived from, for example state change listeners or dispatcher signals keyed on self.entity_id.

Writing the state from the hook is optional: if the state has not been written under the new entity_id when the hook returns, Home Assistant writes it. Work which needs the entity's own state under its new entity_id, for example rendering templates which reference this, should first write the state with self.async_write_ha_state().

Work which must be awaited can be done in a task; since entity registry updates are not serialized with it, check after each await that the entity is still added and that self.entity_id has not changed again.

The hook must call super(), so base classes can update their own bookkeeping. If the hook writes the state, it must call super() before doing so.

Exceptions raised by the hook are caught and logged by Home Assistant, and the entity_id change still completes: the state is written under the new entity_id. This is unlike async_prepare_to_add_to_hass, where raising an exception aborts the add.

Example:

async def async_added_to_hass(self) -> None:
"""Run when the entity has been added to hass."""
await super().async_added_to_hass()
self._subscribe_signal()
self.async_on_remove(lambda: self._unsub_signal())

@callback
def _subscribe_signal(self) -> None:
"""Subscribe to the signal for the current entity_id."""
self._unsub_signal = async_dispatcher_connect(
self.hass, f"{DOMAIN}_{self.entity_id}", self._handle_signal
)

@callback
def async_entity_id_changed(self, old_entity_id: str) -> None:
"""Run when the entity_id has been changed in the entity registry."""
super().async_entity_id_changed(old_entity_id)
self._unsub_signal()
self._subscribe_signal()

Note that the callback registered with async_on_remove calls whatever the current unsubscribe function is. Passing the unsubscribe function itself, as in self.async_on_remove(self._unsub_signal), registers the initial subscription's unsubscribe function: after the entity_id has changed, removing the entity calls that stale function again, and the new subscription leaks.

Backwards compatibility​

An entity whose add and remove hooks depend on its entity_id would break if it was silently changed in place. To give custom integrations time to migrate, an entity of a custom integration is still removed and added again when its entity_id changes if one of the custom integration's own classes in its class hierarchy implements async_prepare_to_add_to_hass, async_added_to_hass or async_will_remove_from_hass, unless that same class or a subclass of it also implements async_entity_id_changed. Base classes provided by Home Assistant Core, such as SensorEntity or CoordinatorEntity, don't count, and entities of core integrations are always changed in place.

Custom integration authors should check whether their entities set up anything keyed on their own entity_id, and then implement async_entity_id_changed. If nothing needs to be updated, implementing it as a method which only calls super() is enough to opt in:

@callback
def async_entity_id_changed(self, old_entity_id: str) -> None:
"""Opt in to in-place entity_id changes."""
super().async_entity_id_changed(old_entity_id)

This backwards compatibility will be removed in Home Assistant Core 2027.11. After that, all entities have their entity_id changed in place.

Which hook to use​

As a rule of thumb:

  • Use async_added_to_hass for the usual setup work: restoring state, subscribing to updates, registering listeners, fetching initial data. It runs as the last step of an add attempt, after the entity has its entity_id and its entity registry entry. It's not called for adds which are aborted earlier, for example because the entity is disabled, but the add can still fail after it has started: if it raises, if the add is cancelled, or if writing the first state raises.
  • Use async_will_remove_from_hass to undo work done in async_added_to_hass when a successfully added entity is removed. It's not called when the add fails.
  • Use async_prepare_to_add_to_hass only when the work genuinely has to happen before the entity platform processes the entity's registry entry, or has to happen even if the entity is disabled.
  • Use async_on_remove for clean up which must run both when an add is aborted or fails and when the entity is removed. This is the safe choice for anything set up in async_prepare_to_add_to_hass or async_added_to_hass.
  • Use async_entity_id_changed to update anything keyed on the entity's own entity_id when it changes. If the work needs the state under the new entity_id, write it with self.async_write_ha_state() first.
  • Do not override add_to_platform_start, add_to_platform_finish or add_to_platform_abort.

New unit of measurement selector

· 4 min read

A new UnitOfMeasurementSelector lets users pick a unit of measurement in a config, options, or subentry flow, and in blueprints. Until now, flows had to build a SelectSelector with a hand-made list of units, often rebuilt from the previous step's input. The new selector gets its units from the sensor unit tables, so the options always match what a sensor entity accepts.

Limit the units​

The selector config has two optional keys, device_classes and state_classes. Both take a single sensor device class or state class, or a list of them.

import probatio

from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import CONF_UNIT_OF_MEASUREMENT
from homeassistant.helpers.selector import (
UnitOfMeasurementSelector,
UnitOfMeasurementSelectorConfig,
)

DATA_SCHEMA = probatio.Schema(
{
probatio.Required(CONF_UNIT_OF_MEASUREMENT): UnitOfMeasurementSelector(
UnitOfMeasurementSelectorConfig(
device_classes=[SensorDeviceClass.ENERGY],
state_classes=[SensorStateClass.TOTAL_INCREASING],
)
),
}
)

The allowed units follow the rules for sensor entities:

  • Device classes: the selector allows the units of all the listed device classes, taken from DEVICE_CLASS_UNITS. A non-numeric device class, like date, enum, or timestamp, allows only no unit. A device class without a unit table, like monetary, doesn't limit the units.
  • State classes: the selector allows the units of all the listed state classes, taken from STATE_CLASS_UNITS, but only those that are also allowed by the device classes. A state class without a unit table, like measurement or total, doesn't limit the units.
  • No limit: without a device class or state class limit, the selector accepts any string, so the user can enter a custom unit.

The selector returns the unit as a string, or None when the user picks no unit.

Filter the units on other fields​

Often the user picks the device class and state class in the same form as the unit, using the device class and state class selectors. Use the context key in the selector config to tell the frontend which fields hold these values. The frontend then narrows the list of units as soon as the user changes one of those fields.

The unit of measurement selector supports these context keys:

Context keyField selector type
filter_device_classdevice_class
filter_state_classstate_class

The value of each key is the name of the field in the same schema.

import probatio

from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
)
from homeassistant.helpers.selector import (
DeviceClassSelector,
DeviceClassSelectorConfig,
StateClassSelector,
UnitOfMeasurementSelector,
UnitOfMeasurementSelectorConfig,
)

DATA_SCHEMA = probatio.Schema(
{
probatio.Optional(CONF_DEVICE_CLASS): DeviceClassSelector(
DeviceClassSelectorConfig(domain="sensor")
),
probatio.Optional(CONF_STATE_CLASS): StateClassSelector(),
probatio.Optional(CONF_UNIT_OF_MEASUREMENT): UnitOfMeasurementSelector(
UnitOfMeasurementSelectorConfig(
context={
"filter_device_class": CONF_DEVICE_CLASS,
"filter_state_class": CONF_STATE_CLASS,
}
)
),
}
)

The context is validated with the rest of the selector config. It only accepts the context keys listed above, with a string value. A context key can't be combined with the matching fixed limit: you can't use filter_device_class together with device_classes, or filter_state_class together with state_classes.

The context only affects the frontend. The selector validates the submitted unit against its device_classes and state_classes config, not against the values of the other fields. Your flow still needs to check that the unit is valid for the device class and state class that the user picked, and return an error if it isn't.

Use the selector in blueprints​

The selector is also available in blueprints and service descriptions as the unit_of_measurement selector. It takes the same config as in Python: device_classes, state_classes, and context.

Because the context is part of the selector config, it also works in blueprints. The value of each context key is the name of another input of the blueprint. In this example, the units offered for the unit input follow the device class and state class that the user picked in the device_class and state_class inputs:

blueprint:
name: Sensor with a unit
domain: template
input:
device_class:
name: Device class
selector:
device_class:
domain: sensor
state_class:
name: State class
selector:
state_class:
unit:
name: Unit of measurement
selector:
unit_of_measurement:
context:
filter_device_class: device_class
filter_state_class: state_class

The Selectors documentation will describe the selector once home-assistant.io PR #48587 is merged.

Migrating existing unit of measurement selectors​

Flows that let the user pick a unit with a SelectSelector built from DEVICE_CLASS_UNITS or STATE_CLASS_UNITS should be migrated to use UnitOfMeasurementSelector. You no longer need to build the unit list from the previous submission. Instead, add a context so the frontend filters the units for you.

For more information, see core PR #183337 and the data entry flow documentation.

Climate entities now expose their temperature unit

· 2 min read

As of Home Assistant Core 2026.11, climate entities have a new temperature_unit state attribute, and the temperature properties integrations implement have been renamed with a native_ prefix.

Background​

The climate entity converts all temperatures it writes to the state machine to the unit system configured by the user. Until now, the unit was not part of the state, so anyone reading the state had to know that climate temperatures follow the configured unit system. This is inconsistent with other entities, such as sensor, number and weather, which expose the unit next to the value.

The temperature_unit state attribute​

The temperature_unit state attribute holds the unit of the temperatures in the state, which is the temperature unit of the user's configured unit system. It's set by the ClimateEntity base class and can't be overridden by integrations.

Renamed properties​

Integrations specify temperatures in the unit used by the device, and the base class converts them. To make that clear, and to match how sensor, number and weather entities name native values, the following properties and their _attr_ shorthand attributes have been renamed:

Old nameNew name
current_temperaturenative_current_temperature
target_temperaturenative_target_temperature
target_temperature_highnative_target_temperature_high
target_temperature_lownative_target_temperature_low
temperature_unitnative_temperature_unit

The old names keep working, but implementing them, setting the _attr_ attributes or reading them logs a warning. Support for the old names will be removed in Home Assistant Core 2027.11.

min_temp, max_temp and target_temperature_step are not renamed, and are still specified in the native unit.

More details can be found in the climate entity documentation.

Example​

Old:

class MyClimateEntity(ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS

@property
def current_temperature(self) -> float | None:
return self.device.temperature

async def async_update(self) -> None:
self._attr_target_temperature = await self.device.get_setpoint()

New:

class MyClimateEntity(ClimateEntity):
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS

@property
def native_current_temperature(self) -> float | None:
return self.device.temperature

async def async_update(self) -> None:
self._attr_native_target_temperature = await self.device.get_setpoint()

Water heater entities now expose their temperature unit

· 2 min read

As of Home Assistant Core 2026.11, water heater entities have a new temperature_unit state attribute, and the temperature properties integrations implement have been renamed with a native_ prefix.

Background​

The water heater entity converts all temperatures it writes to the state machine to the unit system configured by the user. Until now, the unit was not part of the state, so anyone reading the state had to know that water heater temperatures follow the configured unit system. This is inconsistent with other entities, such as sensor, number and weather, which expose the unit next to the value.

The temperature_unit state attribute​

The temperature_unit state attribute holds the unit of the temperatures in the state, which is the temperature unit of the user's configured unit system. It's set by the WaterHeaterEntity base class and can't be overridden by integrations.

Renamed properties​

Integrations specify temperatures in the unit used by the device, and the base class converts them. To make that clear, and to match how sensor, number and weather entities name native values, the following properties and their _attr_ shorthand attributes have been renamed:

Old nameNew name
current_temperaturenative_current_temperature
target_temperaturenative_target_temperature
target_temperature_highnative_target_temperature_high
target_temperature_lownative_target_temperature_low
temperature_unitnative_temperature_unit

The old names keep working, but implementing them, setting the _attr_ attributes or reading them logs a warning. Support for the old names will be removed in Home Assistant Core 2027.11.

min_temp, max_temp and target_temperature_step are not renamed, and are still specified in the native unit.

More details can be found in the water heater entity documentation.

Example​

Old:

class MyWaterHeaterEntity(WaterHeaterEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS

@property
def current_temperature(self) -> float | None:
return self.device.temperature

async def async_update(self) -> None:
self._attr_target_temperature = await self.device.get_setpoint()

New:

class MyWaterHeaterEntity(WaterHeaterEntity):
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS

@property
def native_current_temperature(self) -> float | None:
return self.device.temperature

async def async_update(self) -> None:
self._attr_native_target_temperature = await self.device.get_setpoint()

Removal of RestoreStateData.last_states

· One min read

Summary​

As of Home Assistant Core 2026.11, states stored by the restore state helper are indexed by the entity's entity registry ID instead of by its entity ID. A stored state now follows its entity when the user changes the entity ID, and a stored state is never restored to a different entity which happens to reuse the entity ID.

As a consequence, RestoreStateData.last_states has been removed. Integrations which read stored states directly from RestoreStateData must use the new RestoreStateData.async_get_stored_state method instead.

Integrations which restore state by extending RestoreEntity, or one of its subclasses such as RestoreSensor, and call async_get_last_state or async_get_last_extra_data are not affected.

RestoreStateData.async_get_stored_state​

RestoreStateData.async_get_stored_state takes an entity ID and returns the StoredState of the entity, or None if there is no stored state. If the entity has an entity registry entry, the state is looked up by the entity registry ID, and the returned StoredState.state has the entity's current entity ID.

Before:

from homeassistant.helpers import restore_state

stored_state = restore_state.async_get(hass).last_states.get(entity_id)

After:

from homeassistant.helpers import restore_state

stored_state = restore_state.async_get(hass).async_get_stored_state(entity_id)

For more details, see core PR #183906.

Probatio is our validation engine

· 5 min read

Since Home Assistant Core 2026.9, schema validation runs on Probatio instead of voluptuous. This went in quietly because it was meant to change nothing, and for most integrations it changed nothing. That is worth saying out loud anyway, along with what it does change and what you can now reach for.

Your existing code keeps working​

Probatio is a clean-room reimplementation of voluptuous with the same public API. import voluptuous as vol still works: Home Assistant aliases the name in sys.modules at startup, so the import resolves to Probatio. Custom integrations need no changes, and voluptuous is no longer installed at all.

Core itself has moved to importing Probatio directly, and import voluptuous is now banned there by a lint rule. That ban applies to our own source. Your integration can keep the old import for as long as you like.

Why we switched​

voluptuous has been effectively unmaintained for a long time, and it sits under every integration in Home Assistant. Probatio is maintained, MIT licensed, pure Python, and holds behavioral compatibility with voluptuous as its primary correctness target.

It is also quicker. The project measures the interpreted engine at roughly 2.3 to 3.4 times voluptuous, and an optional compiled path at roughly 6.7 to 7.4 times, on its own benchmarks. Those are single-machine numbers and the project says not to treat them as guarantees, so take them as a direction rather than a promise. Home Assistant builds a great many schemas and validates a fraction of them per run, so we set a lazy build policy: a schema compiles when it is first validated, not when it is constructed.

What changed for you​

Two things are worth knowing, because both produced real issues after 2026.9.

Error messages are worded differently. If your tests assert on validation error text, they may need updating. Probatio also suggests a close match for an unknown key, and 2026.9.1 raised the bar for when it offers one, so weak guesses no longer appear.

Validation is stricter in a few places, which surfaced data that was quietly wrong before. The clearest case was the KNX config store, which was validated before writing but not on load. Entries written by hand or by third-party tooling had been passed through untouched for years, and started failing at setup. The data was always invalid. Nothing told anyone until something checked.

If your integration stores structured data and only validates it on the way in, this is worth a look.

What you can use now​

The compatibility layer only exposes voluptuous's surface, so reaching any of this means importing probatio directly.

Typing that survives the call. Validators that hand their input back keep the caller's type, so probatio.EnsureList()(names) on a list[str] gives you a list[str] rather than a list[Any].

Dataclass and TypedDict schemas. Build schemas from annotations. DataclassSchema returns a typed dataclass instance, while TypedDictSchema returns a validated dict typed as the TypedDict:

from dataclasses import dataclass

import probatio


@dataclass
class Server:
host: str
port: int = 80


schema = probatio.DataclassSchema(Server)
schema({"host": "nas", "port": 8080}) # Server(host="nas", port=8080)

Markers voluptuous never had. Secret redacts a key's value from error output, which matters when a schema holds a password. Forbidden requires a key's absence, Alias accepts a value under an old name and emits it under the canonical one, and TaggedUnion routes on one key's value instead of trying every branch and reporting all of them.

probatio.Schema(
{
probatio.Required(probatio.Secret(CONF_PASSWORD)): str,
}
)

Structured errors, including translation keys. Every Invalid carries a stable code, a translation_key, placeholders for interpolation, the path to the offending value, and a secret flag. as_dict() serializes the lot. That means a validation failure can be branched on programmatically and rendered in the user's language, rather than parsed out of an English sentence.

{
"code": "length",
"message": "length of value must be at least 12",
"path": ["password"],
"secret": True,
"context": {},
"translation_key": "length_min",
"placeholders": {"min": 12},
}

Cross-field rules, like AtLeastOne, ExactlyOne, AllOrNone, RequiredWith and RequiredIf, which previously had to be hand-rolled per integration.

Codecs. to_json_schema, to_openapi and from_openapi convert between a schema and a document, which is how we describe LLM tools.

Documentation, and a note for AI tooling​

The full documentation lives at probatio.frenck.dev, including a compatibility matrix listing everything Probatio adds beyond voluptuous.

The site also publishes llms.txt, a machine-readable index of the documentation. If you use an AI assistant while working on an integration, pointing it at that file is worth doing. Probatio is newer than most model training data, so an assistant left to its own devices will write voluptuous, guess at the API, or invent a validator that does not exist. There are abridged and complete variants alongside it, plus separate indexes for the guides, the recipes and the API reference.

Shared config flow abort reasons are translated centrally

· 2 min read

As of Home Assistant Core 2026.10, the homeassistant integration can translate abort reasons that every integration words the same way. If you're today linking from a local translation key, for an abort reason, to a shared translation key under the homeassistant integration, you can instead just rely on the shared translation key under the homeassistant domain directly. This is done by default in some helpers and can also be done explicitly by setting the translation_domain parameter when aborting the flow.

What to do​

  • Delete the keys below from the abort sections of strings.json, including those under config_subentries, only when the abort uses the homeassistant translation domain.
  • The helpers listed below use the central translation domain automatically.
  • If your code passes one of these reasons to async_abort or AbortFlow, pass the homeassistant translation domain before deleting the local key. Without that domain, retain the local key. See Raise one yourself.

When an abort uses the central translation domain, the frontend resolves the reason from that domain and does not use a local key.

Default reasons​

ReasonRaised by
already_in_progressasync_set_unique_id, discovery without a unique ID
single_instance_allowedsingle_config_entry, DiscoveryFlowHandler, WebhookFlowHandler
no_devices_foundDiscoveryFlowHandler
cloud_not_connectedWebhookFlowHandler
reauth_successfulasync_update_reload_and_abort, async_update_and_abort
reconfigure_successfulasync_update_reload_and_abort, async_update_and_abort, also for subentries
authorize_url_timeoutAbstractOAuth2FlowHandler
missing_credentialsAbstractOAuth2FlowHandler
no_url_availableAbstractOAuth2FlowHandler
oauth_errorAbstractOAuth2FlowHandler
oauth_failedAbstractOAuth2FlowHandler
oauth_implementation_unavailableAbstractOAuth2FlowHandler
oauth_timeoutAbstractOAuth2FlowHandler
oauth_unauthorizedAbstractOAuth2FlowHandler
user_rejected_authorizeAbstractOAuth2FlowHandler

Explicit translation domain​

Pass the domain that owns the string. Both async_abort and AbortFlow accept it.

from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN

return self.async_abort(
reason="no_devices_found",
translation_domain=HOMEASSISTANT_DOMAIN,
)

This works in config flows and subentry flows. Options flows look in the options section, which the homeassistant integration does not have.

Keep your own wording​

Only needed if you word a reason differently on purpose:

  • Pass reason to async_update_reload_and_abort or async_update_and_abort, even with the default name.
  • In an OAuth2 flow, pass translation_domain=DOMAIN.

Aborts that core raises by itself, like already_in_progress, always use the central string, to keep a customized string there, overwrite the steps that produce the strings upstream.

LLM tools return a ToolResult and declare their integration

· 2 min read

As of Home Assistant Core 2026.10, an LLM tool returns an llm.ToolResult instead of a plain JSON object. ToolResult carries the tool's data and an error flag that says whether the call failed. On the chat log side, ToolResultContent.tool_result is replaced by ToolResultContent.result, which holds the ToolResult.

Returning a plain JSON object from a tool, reading ToolResultContent.tool_result, and setting tool_result on a tool result delta are deprecated. They keep working for custom integrations with a warning in the log until Home Assistant Core 2027.11, and stop working after that.

The release also adds metadata to llm.Tool. A tool now has a title for people to read, annotations that describe how it behaves, and an integration that records which integration provides it. The MCP Server integration serves the title and the annotations to MCP clients.

The annotations are an llm.ToolAnnotations with four flags: read_only, destructive, idempotent and open_world. The defaults describe the least safe case, so a tool that declares nothing is taken to write, to be destructive, and to reach outside Home Assistant.

Creating a tool without an integration is deprecated. A core integration raises an error. A custom integration gets a warning in the log until Home Assistant Core 2027.10, and stops working after that.

Both changes together look like this:

from homeassistant.core import HomeAssistant
from homeassistant.helpers import llm

from .const import DOMAIN


class GetItemsTool(llm.Tool):
"""Tool to read the items on a list."""

name = "my_integration__get_items"
title = "Get list items"
description = "Read the items on a list."
integration = DOMAIN
annotations = llm.ToolAnnotations(
read_only=True, destructive=False, idempotent=True, open_world=False
)

async def async_call(
self,
hass: HomeAssistant,
tool_input: llm.ToolInput,
llm_context: llm.LLMContext,
) -> llm.ToolResult:
"""Call the tool."""
return llm.ToolResult(data={"items": ["Milk", "Bread"]})

Use Config Entry exceptions in integration migration method

· 2 min read

Integration migration async_migrate_entry method can now raise config entry exceptions instead of only being limited to return False. This is preferred to provide more context and being translatable.

Integrations that fetch data from other services during migration can raise ConfigEntryNotReady to have the migration retried automatically later (as during setup), which helps with timeouts and other errors that may resolve on their own.

By returning False or raising any other exception than ConfigEntryNotReady the migration stops and the config entry state is set to migration_error which is a non-recoverable state. This previously required the user to restart Home Assistant to retry the migration. The new correct way to handle it is to create a repair issue. After the user fixes the issue, call hass.config_entries.async_retry_migration(entry_id) to retry the migration.

More info in the config flow documentation.

Example migration function which raises if client has an issue​

The migration function lives in the integration's __init__.py.

from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue

async def async_migrate_entry(hass, config_entry: ConfigEntry):
"""Migrate old entry."""
_LOGGER.debug("Migrating configuration from version %s.%s",
config_entry.version, config_entry.minor_version
)

if config_entry.version == 1:
try:
new_info = await client.get_info()
except ConnectionError as exc:
raise ConfigEntryNotReady(
translation_key="key",
translation_domain=DOMAIN,
) from exc
except AuthenticationError as exc:
async_create_issue(
hass,
DOMAIN,
f"migrate_could_not_auth_{config_entry.entry_id}",
is_fixable=True,
is_persistent=False,
severity=IssueSeverity.ERROR,
translation_key="migrate_could_not_auth",
translation_placeholders={"title": config_entry.title},
data={"entry_id": config_entry.entry_id},
)
raise ConfigEntryError(translation_key="migrate_could_not_auth") from exc

new_data = {**config_entry.data, "some_data": new_info["some_data"]}
hass.config_entries.async_update_entry(
config_entry, data=new_data, version=2
)

_LOGGER.debug("Migration to configuration version %s.%s successful",
config_entry.version, config_entry.minor_version,
)

return True

Example repair which will retry the migration​

The repair flow lives in the integration's repairs.py.

class MigrationRepairFlow(RepairsFlow):
"""Handler for a migration repair flow."""

def __init__(self, entry_id: str) -> None:
"""Initialize the flow."""
self.entry_id = entry_id

async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> RepairsFlowResult:
"""Fix the issue and retry the migration."""
if user_input is not None:
# TODO: Do whatever is necessary to fix the issue,
# for example update the config entry data
await self.hass.config_entries.async_retry_migration(self.entry_id)
return self.async_create_entry(data={})

return self.async_show_form(
step_id="init",
data_schema=probatio.Schema({}),
)


async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create flow."""
if issue_id.startswith("migrate_could_not_auth_"):
return MigrationRepairFlow(data["entry_id"])

`DeviceEntry.config_entries` deprecation is now enforced

· 4 min read

Summary​

The device registry's multi-config-entry compatibility properties — DeviceEntry.config_entries, DeviceEntry.config_entries_subentries and DeviceEntry.primary_config_entry — were announced as deprecated in Devices are restricted to a single config entry and at most one subentry, but reading them was still silent. They now report at runtime: core and core integrations raise RuntimeError, custom integrations log a warning.

Use DeviceEntry.config_entry_id and DeviceEntry.config_subentry_id instead. The properties remain available to custom integrations until Home Assistant Core 2027.10, two releases later than the 2027.8 given in the earlier post.

Reading the properties on a synthesized composite device is not deprecated and does not report, because such a device really does span several config entries.

Most integrations don't read these properties and don't need any changes. Read on if your integration inspects a device's config entries, or accesses them on a deleted or child device.

This is implemented in core PR #181949 and lands in Home Assistant Core 2026.10.