Skip to main content

Browse all posts by date →

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.

New stop action and idle activity for lawn mowers

· One min read

As of Home Assistant Core 2026.10, LawnMowerEntity supports a stop action and an IDLE activity.

stop cancels the current mowing task without returning the mower to the dock. It differs from pause, which keeps the task so it can be resumed, and from dock, which sends the mower home. To support it, add LawnMowerEntityFeature.STOP to the supported features and implement stop or async_stop.

LawnMowerActivity.IDLE describes a mower that is stopped, but neither docked nor paused. Integrations that mapped such a state to PAUSED or ERROR should now use IDLE.

More details can be found in the documentation.

Example​

class MyLawnMower(LawnMowerEntity):
_attr_supported_features = (
LawnMowerEntityFeature.START_MOWING
| LawnMowerEntityFeature.DOCK
| LawnMowerEntityFeature.STOP
)

async def async_stop(self) -> None:
"""Stop the mower and cancel the current task."""
await self.mower.stop()
self._attr_activity = LawnMowerActivity.IDLE
self.async_write_ha_state()

OAuth2 error handling moved into the helper

· 2 min read

As of Home Assistant Core 2026.10, the OAuth2 helper raises exceptions that config entry setup already understands, so integrations no longer translate OAuth2 failures themselves.

What changed​

The OAuth2 exceptions now inherit from the config entry exception that describes what should happen, and carry a translated message from the homeassistant integration.

ExceptionAlso aResult when uncaught
ImplementationUnavailableErrorConfigEntryNotReadySetup is retried
UnknownImplementationErrorConfigEntryAuthFailedReauth flow starts
OAuth2TokenRequestErrorConfigEntryNotReadySetup is retried
OAuth2TokenRequestTransientErrorConfigEntryNotReadySetup is retried
OAuth2TokenRequestConnectionErrorConfigEntryNotReadySetup is retried
OAuth2TokenRequestReauthErrorConfigEntryAuthFailedReauth flow starts

All of them are importable from homeassistant.exceptions and carry a translated user-facing message, so the integration does not need a strings.json entry for them.

Migration​

- try:
- implementation = await async_get_config_entry_implementation(hass, entry)
- except ImplementationUnavailableError as err:
- raise ConfigEntryNotReady(
- translation_domain=DOMAIN,
- translation_key="oauth2_implementation_unavailable",
- ) from err
+ implementation = await async_get_config_entry_implementation(hass, entry)

Also remove the now-unused oauth2_implementation_unavailable entry from the exceptions section of strings.json.

Catching ValueError around this call can go as well. UnknownImplementationError subclasses ValueError for backwards compatibility, so a ValueError handler silently downgrades it and discards the translated message.

Token requests​

- try:
- await auth.async_get_access_token()
- except OAuth2TokenRequestReauthError as err:
- raise ConfigEntryAuthFailed from err
- except OAuth2TokenRequestError as err:
- raise ConfigEntryNotReady from err
+ await auth.async_get_access_token()

The same applies to await session.async_ensure_token_valid().

Quality scale​

The test-before-setup rule accepts await session.async_ensure_token_valid() in async_setup_entry as satisfying the rule, alongside await coordinator.async_config_entry_first_refresh(). Both raise the appropriate config entry exception on the integration's behalf.

When to keep catching​

Central handling is a default, not a restriction. Keep an explicit handler when the integration genuinely needs different behavior, for example:

  • Treating a specific status as permanent with ConfigEntryError instead of retrying.
  • Adding context to the message that only the integration knows.
  • Cleaning up integration state before the exception propagates.

In those cases, catch the most specific exception that applies and let the rest propagate.