Compare commits

..

2 Commits

Author SHA1 Message Date
Robert Resch
aae63cb397 Adjust docker file to be multistage 2026-03-27 17:32:58 +01:00
Robert Resch
78aa5b9913 Improve build pipeline by triggering translation download async 2026-03-27 17:01:58 +01:00
44 changed files with 715 additions and 1586 deletions

View File

@@ -35,6 +35,7 @@ jobs:
channel: ${{ steps.version.outputs.channel }}
publish: ${{ steps.version.outputs.publish }}
architectures: ${{ env.ARCHITECTURES }}
translation_download_process: ${{ steps.translations.outputs.translation_download_process }}
base_image_version: ${{ env.BASE_IMAGE_VERSION }}
steps:
- name: Checkout the repository
@@ -47,6 +48,26 @@ jobs:
with:
python-version-file: ".python-version"
- name: Fail if translations files are checked in
run: |
if [ -n "$(find homeassistant/components/*/translations -type f)" ]; then
echo "Translations files are checked in, please remove the following files:"
find homeassistant/components/*/translations -type f
exit 1
fi
- name: Start translation download
id: translations
shell: bash
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
run: |
pip install -r script/translations/requirements.txt
PROCESS_OUTPUT=$(python3 -m script.translations download --async-start)
echo "$PROCESS_OUTPUT"
TRANSLATION_DOWNLOAD_PROCESS=$(echo "$PROCESS_OUTPUT" | sed -n 's/.*Process ID: //p')
echo "translation_download_process=$TRANSLATION_DOWNLOAD_PROCESS" >> "$GITHUB_OUTPUT"
- name: Get information
id: info
uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses]
@@ -62,30 +83,6 @@ jobs:
with:
ignore-dev: true
- name: Fail if translations files are checked in
run: |
if [ -n "$(find homeassistant/components/*/translations -type f)" ]; then
echo "Translations files are checked in, please remove the following files:"
find homeassistant/components/*/translations -type f
exit 1
fi
- name: Download Translations
run: python3 -m script.translations download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
- name: Archive translations
shell: bash
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: translations
path: translations.tar.gz
if-no-files-found: error
build_base:
name: Build ${{ matrix.arch }} base core image
if: github.repository_owner == 'home-assistant'
@@ -133,7 +130,6 @@ jobs:
name: package
- name: Set up Python
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
@@ -181,22 +177,35 @@ jobs:
sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt
fi
- name: Download translations
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: translations
- name: Extract translations
run: |
tar xvf translations.tar.gz
rm translations.tar.gz
- name: Write meta info file
shell: bash
run: |
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
- name: Build base image
- name: Build base image (deps stage only)
uses: edenhaus/builder/actions/build-image@b3ea9d4b1d98f979d671aa8442ad8373e38aea11
with:
arch: ${{ matrix.arch }}
build-args: |
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
cache-gha: false
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
cosign-base-identity: "https://github.com/home-assistant/docker/.*"
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
push: false
load: true
target: deps
- name: Download translations
shell: bash
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
TRANSLATION_PROCESS_ID: ${{ needs.init.outputs.translation_download_process }}
run: |
pip install -r script/translations/requirements.txt
python3 -m script.translations download --process-id "$TRANSLATION_PROCESS_ID"
- name: Build base image (fully)
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
with:
arch: ${{ matrix.arch }}
@@ -485,14 +494,13 @@ jobs:
python-version-file: ".python-version"
- name: Download translations
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: translations
- name: Extract translations
shell: bash
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
TRANSLATION_PROCESS_ID: ${{ needs.init.outputs.translation_download_process }}
run: |
tar xvf translations.tar.gz
rm translations.tar.gz
pip install -r script/translations/requirements.txt
python3 -m script.translations download --process-id "$TRANSLATION_PROCESS_ID"
- name: Build package
shell: bash

4
Dockerfile generated
View File

@@ -2,7 +2,7 @@
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM
FROM ${BUILD_FROM}
FROM ${BUILD_FROM} as deps
LABEL \
io.hass.type="core" \
@@ -50,6 +50,8 @@ RUN \
--no-build \
-r homeassistant/requirements_all.txt
FROM deps
## Setup Home Assistant Core
COPY . homeassistant/
RUN \

View File

@@ -51,6 +51,7 @@ def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool:
return a.name not in (
"_cache",
"compat_aliases",
"compat_name",
"original_name_unprefixed",
)

View File

@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260325.2"]
"requirements": ["home-assistant-frontend==20260325.1"]
}

View File

@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==2.3.0"]
"requirements": ["gardena-bluetooth==2.1.0"]
}

View File

@@ -24,9 +24,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from homeassistant.helpers.entity import generate_entity_id
from .api import ApiAuthImpl, get_feature_access
@@ -91,17 +88,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo
_LOGGER.error("Configuration error in %s: %s", YAML_DEVICES, str(err))
return False
try:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
# Force a token refresh to fix a bug where tokens were persisted with
# expires_in (relative time delta) and expires_at (absolute time) swapped.

View File

@@ -57,11 +57,6 @@
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"options": {
"step": {
"init": {

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.3.0"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.1.0"]
}

View File

@@ -16,10 +16,6 @@ from homeassistant.components.climate import (
ATTR_TARGET_TEMP_LOW,
DEFAULT_MAX_TEMP,
DEFAULT_MIN_TEMP,
PRESET_AWAY,
PRESET_HOME,
PRESET_NONE,
PRESET_SLEEP,
ClimateEntity,
ClimateEntityDescription,
ClimateEntityFeature,
@@ -46,18 +42,6 @@ HVAC_SYSTEM_MODE_MAP = {
HVACMode.FAN_ONLY: 7,
}
# Map of Matter PresetScenarioEnum to HA standard preset constants or custom names
# This ensures presets are translated correctly using HA's translation system.
# kUserDefined scenarios always use device-provided names.
PRESET_SCENARIO_TO_HA_PRESET: dict[int, str] = {
clusters.Thermostat.Enums.PresetScenarioEnum.kOccupied: PRESET_HOME,
clusters.Thermostat.Enums.PresetScenarioEnum.kUnoccupied: PRESET_AWAY,
clusters.Thermostat.Enums.PresetScenarioEnum.kSleep: PRESET_SLEEP,
clusters.Thermostat.Enums.PresetScenarioEnum.kWake: "wake",
clusters.Thermostat.Enums.PresetScenarioEnum.kVacation: "vacation",
clusters.Thermostat.Enums.PresetScenarioEnum.kGoingToSleep: "going_to_sleep",
}
SINGLE_SETPOINT_DEVICES: set[tuple[int, int]] = {
# Some devices only have a single setpoint while the matter spec
# assumes that you need separate setpoints for heating and cooling.
@@ -175,6 +159,7 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
}
SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum
ControlSequenceEnum = clusters.Thermostat.Enums.ControlSequenceOfOperationEnum
ThermostatFeature = clusters.Thermostat.Bitmaps.Feature
@@ -210,22 +195,10 @@ class MatterClimate(MatterEntity, ClimateEntity):
_attr_temperature_unit: str = UnitOfTemperature.CELSIUS
_attr_hvac_mode: HVACMode = HVACMode.OFF
_matter_presets: list[clusters.Thermostat.Structs.PresetStruct]
_attr_preset_mode: str | None = None
_attr_preset_modes: list[str] | None = None
_feature_map: int | None = None
_platform_translation_key = "thermostat"
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the climate entity."""
# Initialize preset handle mapping as instance attribute before calling super().__init__()
# because MatterEntity.__init__() calls _update_from_device() which needs this attribute
self._matter_presets = []
self._preset_handle_by_name: dict[str, bytes | None] = {}
self._preset_name_by_handle: dict[bytes | None, str] = {}
super().__init__(*args, **kwargs)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE)
@@ -270,34 +243,6 @@ class MatterClimate(MatterEntity, ClimateEntity):
matter_attribute=clusters.Thermostat.Attributes.OccupiedCoolingSetpoint,
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
preset_handle = self._preset_handle_by_name[preset_mode]
command = clusters.Thermostat.Commands.SetActivePresetRequest(
presetHandle=preset_handle
)
await self.send_device_command(command)
# Optimistic update is required because Matter devices usually confirm
# preset changes asynchronously via a later attribute subscription.
# Additionally, some devices based on connectedhomeip do not send a
# subscription report for ActivePresetHandle after SetActivePresetRequest
# because thermostat-server-presets.cpp/SetActivePreset() updates the
# value without notifying the reporting engine. Keep this optimistic
# update as a workaround for that SDK bug and for normal report delays.
# Reference: project-chip/connectedhomeip,
# src/app/clusters/thermostat-server/thermostat-server-presets.cpp.
self._attr_preset_mode = preset_mode
self.async_write_ha_state()
# Keep the local ActivePresetHandle in sync until subscription update.
active_preset_path = create_attribute_path_from_attribute(
endpoint_id=self._endpoint.endpoint_id,
attribute=clusters.Thermostat.Attributes.ActivePresetHandle,
)
self._endpoint.set_attribute_value(active_preset_path, preset_handle)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
@@ -322,10 +267,10 @@ class MatterClimate(MatterEntity, ClimateEntity):
def _update_from_device(self) -> None:
"""Update from device."""
self._calculate_features()
self._attr_current_temperature = self._get_temperature_in_degrees(
clusters.Thermostat.Attributes.LocalTemperature
)
self._attr_current_humidity = (
int(raw_measured_humidity) / HUMIDITY_SCALING_FACTOR
if (
@@ -337,81 +282,6 @@ class MatterClimate(MatterEntity, ClimateEntity):
else None
)
self._update_presets()
self._update_hvac_mode_and_action()
self._update_target_temperatures()
self._update_temperature_limits()
@callback
def _update_presets(self) -> None:
"""Update preset modes and active preset."""
# Check if the device supports presets feature before attempting to load.
# Use the already computed supported features instead of re-reading
# the FeatureMap attribute to keep a single source of truth and avoid
# casting None when the attribute is temporarily unavailable.
supported_features = self._attr_supported_features or 0
if not (supported_features & ClimateEntityFeature.PRESET_MODE):
# Device does not support presets, skip preset update
self._preset_handle_by_name.clear()
self._preset_name_by_handle.clear()
self._attr_preset_modes = []
self._attr_preset_mode = None
return
self._matter_presets = (
self.get_matter_attribute_value(clusters.Thermostat.Attributes.Presets)
or []
)
# Build preset mapping: use device-provided name if available, else generate unique name
self._preset_handle_by_name.clear()
self._preset_name_by_handle.clear()
if self._matter_presets:
used_names = set()
for i, preset in enumerate(self._matter_presets, start=1):
preset_translation = PRESET_SCENARIO_TO_HA_PRESET.get(
preset.presetScenario
)
if preset_translation:
preset_name = preset_translation.lower()
else:
name = str(preset.name) if preset.name is not None else ""
name = name.strip()
if name:
preset_name = name
else:
# Ensure fallback name is unique
j = i
preset_name = f"Preset{j}"
while preset_name in used_names:
j += 1
preset_name = f"Preset{j}"
used_names.add(preset_name)
preset_handle = (
preset.presetHandle
if isinstance(preset.presetHandle, (bytes, type(None)))
else None
)
self._preset_handle_by_name[preset_name] = preset_handle
self._preset_name_by_handle[preset_handle] = preset_name
# Always include PRESET_NONE to allow users to clear the preset
self._preset_handle_by_name[PRESET_NONE] = None
self._preset_name_by_handle[None] = PRESET_NONE
self._attr_preset_modes = list(self._preset_handle_by_name)
# Update active preset mode
active_preset_handle = self.get_matter_attribute_value(
clusters.Thermostat.Attributes.ActivePresetHandle
)
self._attr_preset_mode = self._preset_name_by_handle.get(
active_preset_handle, PRESET_NONE
)
@callback
def _update_hvac_mode_and_action(self) -> None:
"""Update HVAC mode and action from device."""
if self.get_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False:
# special case: the appliance has a dedicated Power switch on the OnOff cluster
# if the mains power is off - treat it as if the HVAC mode is off
@@ -463,10 +333,7 @@ class MatterClimate(MatterEntity, ClimateEntity):
self._attr_hvac_action = HVACAction.FAN
else:
self._attr_hvac_action = HVACAction.OFF
@callback
def _update_target_temperatures(self) -> None:
"""Update target temperature or temperature range."""
# update target temperature high/low
supports_range = (
self._attr_supported_features
& ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
@@ -492,9 +359,6 @@ class MatterClimate(MatterEntity, ClimateEntity):
clusters.Thermostat.Attributes.OccupiedHeatingSetpoint
)
@callback
def _update_temperature_limits(self) -> None:
"""Update min and max temperature limits."""
# update min_temp
if self._attr_hvac_mode == HVACMode.COOL:
attribute = clusters.Thermostat.Attributes.AbsMinCoolSetpointLimit
@@ -534,9 +398,6 @@ class MatterClimate(MatterEntity, ClimateEntity):
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF
)
if feature_map & ThermostatFeature.kPresets:
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
# determine supported hvac modes
if feature_map & ThermostatFeature.kHeating:
self._attr_hvac_modes.append(HVACMode.HEAT)
if feature_map & ThermostatFeature.kCooling:
@@ -579,13 +440,9 @@ DISCOVERY_SCHEMAS = [
optional_attributes=(
clusters.Thermostat.Attributes.FeatureMap,
clusters.Thermostat.Attributes.ControlSequenceOfOperation,
clusters.Thermostat.Attributes.NumberOfPresets,
clusters.Thermostat.Attributes.Occupancy,
clusters.Thermostat.Attributes.OccupiedCoolingSetpoint,
clusters.Thermostat.Attributes.OccupiedHeatingSetpoint,
clusters.Thermostat.Attributes.Presets,
clusters.Thermostat.Attributes.PresetTypes,
clusters.Thermostat.Attributes.ActivePresetHandle,
clusters.Thermostat.Attributes.SystemMode,
clusters.Thermostat.Attributes.ThermostatRunningMode,
clusters.Thermostat.Attributes.ThermostatRunningState,

View File

@@ -145,16 +145,7 @@
},
"climate": {
"thermostat": {
"name": "Thermostat",
"state_attributes": {
"preset_mode": {
"state": {
"going_to_sleep": "Going to sleep",
"vacation": "Vacation",
"wake": "Wake"
}
}
}
"name": "Thermostat"
}
},
"cover": {

View File

@@ -8,8 +8,11 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTR_AFFECTED_AREAS,
@@ -25,13 +28,13 @@ from .const import (
ATTR_WEB,
CONF_MESSAGE_SLOTS,
CONF_REGIONS,
DOMAIN,
)
from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator
from .entity import NinaEntity
async def async_setup_entry(
_: HomeAssistant,
hass: HomeAssistant,
config_entry: NinaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
@@ -43,7 +46,7 @@ async def async_setup_entry(
message_slots: int = config_entry.data[CONF_MESSAGE_SLOTS]
async_add_entities(
NINAMessage(coordinator, ent, regions[ent], i + 1)
NINAMessage(coordinator, ent, regions[ent], i + 1, config_entry)
for ent in coordinator.data
for i in range(message_slots)
)
@@ -52,7 +55,7 @@ async def async_setup_entry(
PARALLEL_UPDATES = 0
class NINAMessage(NinaEntity, BinarySensorEntity):
class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEntity):
"""Representation of an NINA warning."""
_attr_device_class = BinarySensorDeviceClass.SAFETY
@@ -64,20 +67,31 @@ class NINAMessage(NinaEntity, BinarySensorEntity):
region: str,
region_name: str,
slot_id: int,
config_entry: ConfigEntry,
) -> None:
"""Initialize."""
super().__init__(coordinator, region, region_name, slot_id)
super().__init__(coordinator)
self._attr_translation_key = "warning"
self._region = region
self._warning_index = slot_id - 1
self._attr_name = f"Warning: {region_name} {slot_id}"
self._attr_unique_id = f"{region}-{slot_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="NINA",
entry_type=DeviceEntryType.SERVICE,
)
@property
def is_on(self) -> bool:
"""Return the state of the sensor."""
if self._get_active_warnings_count() <= self._warning_index:
if len(self.coordinator.data[self._region]) <= self._warning_index:
return False
return self._get_warning_data().is_valid
data = self.coordinator.data[self._region][self._warning_index]
return data.is_valid
@property
def extra_state_attributes(self) -> dict[str, Any]:
@@ -85,7 +99,7 @@ class NINAMessage(NinaEntity, BinarySensorEntity):
if not self.is_on:
return {}
data = self._get_warning_data()
data = self.coordinator.data[self._region][self._warning_index]
return {
ATTR_HEADLINE: data.headline,

View File

@@ -12,7 +12,6 @@ from pynina import ApiError, Nina
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
@@ -65,12 +64,6 @@ class NINADataUpdateCoordinator(
]
self.area_filter: str = config_entry.data[CONF_FILTERS][CONF_AREA_FILTER]
self.device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="NINA",
entry_type=DeviceEntryType.SERVICE,
)
regions: dict[str, str] = config_entry.data[CONF_REGIONS]
for region in regions:
self._nina.add_region(region)

View File

@@ -1,36 +0,0 @@
"""NINA common entity."""
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import NINADataUpdateCoordinator, NinaWarningData
class NinaEntity(CoordinatorEntity[NINADataUpdateCoordinator]):
"""Base class for NINA entities."""
def __init__(
self,
coordinator: NINADataUpdateCoordinator,
region: str,
region_name: str,
slot_id: int,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._region = region
self._warning_index = slot_id - 1
self._attr_translation_placeholders = {
"region_name": region_name,
"slot_id": str(slot_id),
}
self._attr_device_info = coordinator.device_info
def _get_active_warnings_count(self) -> int:
"""Return the number of active warnings for the region."""
return len(self.coordinator.data[self._region])
def _get_warning_data(self) -> NinaWarningData:
"""Return warning data."""
return self.coordinator.data[self._region][self._warning_index]

View File

@@ -45,13 +45,6 @@
}
}
},
"entity": {
"binary_sensor": {
"warning": {
"name": "Warning: {region_name} {slot_id}"
}
}
},
"options": {
"abort": {
"no_fetch": "[%key:component::nina::config::abort::no_fetch%]",

View File

@@ -88,9 +88,6 @@
},
"charge_set_schedules": {
"service": "mdi:calendar-clock"
},
"charge_start": {
"service": "mdi:ev-station"
}
}
}

View File

@@ -165,11 +165,9 @@ class RenaultVehicleProxy:
return await self._vehicle.set_charge_mode(charge_mode)
@with_error_wrapping
async def set_charge_start(
self, when: datetime | None = None
) -> models.KamereonVehicleChargingStartActionData:
async def set_charge_start(self) -> models.KamereonVehicleChargingStartActionData:
"""Start vehicle charge."""
return await self._vehicle.set_charge_start(when)
return await self._vehicle.set_charge_start()
@with_error_wrapping
async def set_charge_stop(self) -> models.KamereonVehicleChargingStartActionData:

View File

@@ -36,11 +36,6 @@ SERVICE_AC_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
vol.Optional(ATTR_WHEN): cv.datetime,
}
)
SERVICE_CHARGE_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
{
vol.Optional(ATTR_WHEN): cv.datetime,
}
)
SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA = vol.Schema(
{
vol.Required("startTime"): cv.string,
@@ -118,16 +113,6 @@ async def ac_start(service_call: ServiceCall) -> None:
LOGGER.debug("A/C start result: %s", result.raw_data)
async def charge_start(service_call: ServiceCall) -> None:
"""Start Charging with optional delay."""
when: datetime | None = service_call.data.get(ATTR_WHEN)
proxy = get_vehicle_proxy(service_call)
LOGGER.debug("Charge start attempt, when: %s", when)
result = await proxy.set_charge_start(when)
LOGGER.debug("Charge start result: %s", result.raw_data)
async def charge_set_schedules(service_call: ServiceCall) -> None:
"""Set charge schedules."""
schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
@@ -211,12 +196,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
ac_start,
schema=SERVICE_AC_START_SCHEMA,
)
hass.services.async_register(
DOMAIN,
"charge_start",
charge_start,
schema=SERVICE_CHARGE_START_SCHEMA,
)
hass.services.async_register(
DOMAIN,
"charge_set_schedules",

View File

@@ -54,18 +54,6 @@ ac_set_schedules:
selector:
object:
charge_start:
fields:
vehicle:
required: true
selector:
device:
integration: renault
when:
example: "2026-03-01T17:45:00"
selector:
datetime:
charge_set_schedules:
fields:
vehicle:

View File

@@ -276,20 +276,6 @@
}
},
"name": "Update charge schedule"
},
"charge_start": {
"description": "Starts charging on vehicle.",
"fields": {
"vehicle": {
"description": "[%key:component::renault::services::ac_start::fields::vehicle::description%]",
"name": "Vehicle"
},
"when": {
"description": "Timestamp for charging to start (optional - defaults to now).",
"name": "When"
}
},
"name": "Start charging"
}
}
}

View File

@@ -19,7 +19,6 @@ PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.LIGHT,
Platform.REMOTE,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,

View File

@@ -1,70 +0,0 @@
"""Remote platform for SLZB-Ultima."""
import asyncio
from collections.abc import Iterable
from typing import Any
from pysmlight.exceptions import SmlightError
from pysmlight.models import IRPayload
from homeassistant.components.remote import (
ATTR_DELAY_SECS,
ATTR_NUM_REPEATS,
DEFAULT_DELAY_SECS,
DEFAULT_NUM_REPEATS,
RemoteEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SmConfigEntry, SmDataUpdateCoordinator
from .entity import SmEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: SmConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize remote for SLZB-Ultima device."""
coordinator = entry.runtime_data.data
if coordinator.data.info.has_peripherals:
async_add_entities([SmRemoteEntity(coordinator)])
class SmRemoteEntity(SmEntity, RemoteEntity):
"""Representation of a SLZB-Ultima remote."""
_attr_translation_key = "remote"
_attr_is_on = True
def __init__(self, coordinator: SmDataUpdateCoordinator) -> None:
"""Initialize the SLZB-Ultima remote."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-remote"
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
"""Send a sequence of commands to a device."""
num_repeats = kwargs.get(ATTR_NUM_REPEATS, DEFAULT_NUM_REPEATS)
delay_secs = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS)
for _ in range(num_repeats):
for cmd in command:
try:
await self.coordinator.async_execute_command(
self.coordinator.client.actions.send_ir_code,
IRPayload(code=cmd),
)
except SmlightError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_ir_code_failed",
translation_placeholders={"error": str(err)},
) from err
await asyncio.sleep(delay_secs)

View File

@@ -84,11 +84,6 @@
"name": "Ambilight"
}
},
"remote": {
"remote": {
"name": "IR Remote"
}
},
"sensor": {
"core_temperature": {
"name": "Core chip temp"
@@ -164,9 +159,6 @@
},
"firmware_update_failed": {
"message": "Firmware update failed for {device_name}."
},
"send_ir_code_failed": {
"message": "Failed to send IR code: {error}."
}
},
"issues": {

View File

@@ -315,6 +315,7 @@ async def make_device_data(
)
devices_data.binary_sensors.append((device, coordinator))
devices_data.sensors.append((device, coordinator))
if isinstance(device, Device) and device.device_type == "AI Art Frame":
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
@@ -322,11 +323,6 @@ async def make_device_data(
devices_data.buttons.append((device, coordinator))
devices_data.sensors.append((device, coordinator))
devices_data.images.append((device, coordinator))
if isinstance(device, Device) and device.device_type == "WeatherStation":
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
)
devices_data.sensors.append((device, coordinator))
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View File

@@ -257,11 +257,6 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
),
"Smart Radiator Thermostat": (BATTERY_DESCRIPTION,),
"AI Art Frame": (BATTERY_DESCRIPTION,),
"WeatherStation": (
BATTERY_DESCRIPTION,
TEMPERATURE_DESCRIPTION,
HUMIDITY_DESCRIPTION,
),
}

View File

@@ -80,7 +80,7 @@ EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = Event
_LOGGER = logging.getLogger(__name__)
STORAGE_VERSION_MAJOR = 1
STORAGE_VERSION_MINOR = 22
STORAGE_VERSION_MINOR = 21
STORAGE_KEY = "core.entity_registry"
CLEANUP_INTERVAL = 3600 * 24
@@ -240,6 +240,7 @@ class RegistryEntry:
# For backwards compatibility, should be removed in the future
compat_aliases: list[str] = attr.ib(factory=list, eq=False)
compat_name: str | None = attr.ib(default=None, eq=False)
# original_name_unprefixed is used to store the result of stripping
# the device name prefix from the original_name, if possible.
@@ -412,7 +413,8 @@ class RegistryEntry:
"has_entity_name": self.has_entity_name,
"labels": list(self.labels),
"modified_at": self.modified_at,
"name": self.name,
"name": self.compat_name,
"name_v2": self.name,
"object_id_base": self.object_id_base,
"options": self.options,
"original_device_class": self.original_device_class,
@@ -469,7 +471,6 @@ def _async_get_full_entity_name(
original_name: str | None,
original_name_unprefixed: str | None | UndefinedType = UNDEFINED,
overridden_name: str | None = None,
use_legacy_naming: bool = False,
) -> str:
"""Get full name for an entity.
@@ -479,7 +480,7 @@ def _async_get_full_entity_name(
if name is None and overridden_name is not None:
name = overridden_name
elif not use_legacy_naming or name is None:
else:
device_name: str | None = None
if (
device_id is not None
@@ -532,7 +533,6 @@ def async_get_full_entity_name(
name=entry.name,
original_name=original_name,
original_name_unprefixed=original_name_unprefixed,
use_legacy_naming=True,
)
@@ -660,6 +660,7 @@ class DeletedRegistryEntry:
# For backwards compatibility, should be removed in the future
compat_aliases: list[str] = attr.ib(factory=list, eq=False)
compat_name: str | None = attr.ib(default=None, eq=False)
_cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False)
@@ -695,7 +696,8 @@ class DeletedRegistryEntry:
"id": self.id,
"labels": list(self.labels),
"modified_at": self.modified_at,
"name": self.name,
"name": self.compat_name,
"name_v2": self.name,
"options": self.options if self.options is not UNDEFINED else {},
"options_undefined": self.options is UNDEFINED,
"orphaned_timestamp": self.orphaned_timestamp,
@@ -848,37 +850,46 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
for entity in data["entities"]:
entity["object_id_base"] = entity["original_name"]
if old_minor_version == 21:
# Version 1.21 has been reverted.
# It migrated entity names to the new format stored in `name_v2`
# field, automatically stripping any device name prefix present.
# The old name was stored in `name` field for backwards compatibility.
# For users who already migrated to v1.21, we restore old names
# but try to preserve any user renames made since that migration.
if old_minor_version < 21:
# Version 1.21 migrates the full name to include device name,
# even if entity name is overwritten by user.
# It also adds support for COMPUTED_NAME in aliases and starts preserving their order.
# To avoid a major version bump, we keep the old name and aliases as-is
# and use new name_v2 and aliases_v2 fields instead.
device_registry = dr.async_get(self.hass)
for entity in data["entities"]:
old_name = entity["name"]
name = entity.pop("name_v2")
alias_to_add: str | None = None
if (
(name != old_name)
(name := entity["name"])
and (device_id := entity["device_id"]) is not None
and (device := device_registry.async_get(device_id)) is not None
and (device_name := device.name_by_user or device.name)
):
name = f"{device_name} {name}"
# Strip the device name prefix from the entity name if present,
# and add the full generated name as an alias.
# If the name doesn't have the device name prefix and the
# entity is exposed to a voice assistant, add the previous
# name as an alias instead to preserve backwards compatibility.
if (
new_name := _async_strip_prefix_from_entity_name(
name, device_name
)
) is not None:
name = new_name
elif any(
entity.get("options", {}).get(key, {}).get("should_expose")
for key in ("conversation", "cloud.google_assistant")
):
alias_to_add = name
entity["name"] = name
if old_minor_version < 22:
# Version 1.22 adds support for COMPUTED_NAME in aliases and starts preserving
# their order.
# To avoid a major version bump, we keep the old aliases as-is and use aliases_v2
# field instead.
for entity in data["entities"]:
entity["aliases_v2"] = [None, *entity["aliases"]]
entity["name_v2"] = name
entity["aliases_v2"] = [alias_to_add, *entity["aliases"]]
for entity in data["deleted_entities"]:
# We don't know what the device name was, so the only thing we can do
# is to clear the overwritten name to not mislead users.
entity["name_v2"] = None
entity["aliases_v2"] = [None, *entity["aliases"]]
if old_major_version > 1:
@@ -1352,6 +1363,7 @@ class EntityRegistry(BaseRegistry):
area_id = deleted_entity.area_id
categories = deleted_entity.categories
compat_aliases = deleted_entity.compat_aliases
compat_name = deleted_entity.compat_name
created_at = deleted_entity.created_at
device_class = deleted_entity.device_class
if deleted_entity.disabled_by is not UNDEFINED:
@@ -1383,6 +1395,7 @@ class EntityRegistry(BaseRegistry):
area_id = None
categories = {}
compat_aliases = []
compat_name = None
device_class = None
icon = None
labels = set()
@@ -1430,6 +1443,7 @@ class EntityRegistry(BaseRegistry):
categories=categories,
capabilities=none_if_undefined(capabilities),
compat_aliases=compat_aliases,
compat_name=compat_name,
config_entry_id=none_if_undefined(config_entry_id),
config_subentry_id=none_if_undefined(config_subentry_id),
created_at=created_at,
@@ -1492,6 +1506,7 @@ class EntityRegistry(BaseRegistry):
area_id=entity.area_id,
categories=entity.categories,
compat_aliases=entity.compat_aliases,
compat_name=entity.compat_name,
config_entry_id=config_entry_id,
config_subentry_id=entity.config_subentry_id,
created_at=entity.created_at,
@@ -1605,27 +1620,14 @@ class EntityRegistry(BaseRegistry):
for entity in entities:
if entity.has_entity_name:
continue
# When a user renames a device, update entity names to reflect
# the new device name.
# An empty name_unprefixed means the entity name equals
# the device name (e.g. a main sensor); a non-empty one
# is appended as a suffix.
name: str | None | UndefinedType = UNDEFINED
if (
by_user
and entity.name is None
and (name_unprefixed := entity.original_name_unprefixed) is not None
):
if not name_unprefixed:
name = device_name
elif device_name:
name = f"{device_name} {name_unprefixed}"
name = (
entity.original_name_unprefixed
if by_user and entity.name is None
else UNDEFINED
)
original_name_unprefixed = _async_strip_prefix_from_entity_name(
entity.original_name, device_name
)
self._async_update_entity(
entity.entity_id,
name=name,
@@ -1993,6 +1995,7 @@ class EntityRegistry(BaseRegistry):
categories=entity["categories"],
capabilities=entity["capabilities"],
compat_aliases=entity["aliases"],
compat_name=entity["name"],
config_entry_id=entity["config_entry_id"],
config_subentry_id=entity["config_subentry_id"],
created_at=datetime.fromisoformat(entity["created_at"]),
@@ -2013,7 +2016,7 @@ class EntityRegistry(BaseRegistry):
has_entity_name=entity["has_entity_name"],
labels=set(entity["labels"]),
modified_at=datetime.fromisoformat(entity["modified_at"]),
name=entity["name"],
name=entity["name_v2"],
object_id_base=entity.get("object_id_base"),
options=entity["options"],
original_device_class=entity["original_device_class"],
@@ -2064,6 +2067,7 @@ class EntityRegistry(BaseRegistry):
area_id=entity["area_id"],
categories=entity["categories"],
compat_aliases=entity["aliases"],
compat_name=entity["name"],
config_entry_id=entity["config_entry_id"],
config_subentry_id=entity["config_subentry_id"],
created_at=datetime.fromisoformat(entity["created_at"]),
@@ -2083,7 +2087,7 @@ class EntityRegistry(BaseRegistry):
id=entity["id"],
labels=set(entity["labels"]),
modified_at=datetime.fromisoformat(entity["modified_at"]),
name=entity["name"],
name=entity["name_v2"],
options=entity["options"]
if not entity["options_undefined"]
else UNDEFINED,

View File

@@ -39,7 +39,7 @@ habluetooth==5.11.1
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20260325.2
home-assistant-frontend==20260325.1
home-assistant-intents==2026.3.24
httpx==0.28.1
ifaddr==0.2.0

4
requirements_all.txt generated
View File

@@ -1032,7 +1032,7 @@ gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==2.3.0
gardena-bluetooth==2.1.0
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.14
@@ -1229,7 +1229,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20260325.2
home-assistant-frontend==20260325.1
# homeassistant.components.conversation
home-assistant-intents==2026.3.24

View File

@@ -914,7 +914,7 @@ gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==2.3.0
gardena-bluetooth==2.1.0
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.14
@@ -1093,7 +1093,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20260325.2
home-assistant-frontend==20260325.1
# homeassistant.components.conversation
home-assistant-intents==2026.3.24

View File

@@ -17,7 +17,7 @@ DOCKERFILE_TEMPLATE = r"""# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM
FROM ${{BUILD_FROM}}
FROM ${{BUILD_FROM}} as deps
LABEL \
io.hass.type="core" \
@@ -65,6 +65,8 @@ RUN \
--no-build \
-r homeassistant/requirements_all.txt
FROM deps
## Setup Home Assistant Core
COPY . homeassistant/
RUN \

View File

@@ -3,15 +3,22 @@
from __future__ import annotations
import argparse
import io
import json
from pathlib import Path
import subprocess
import time
from typing import Any
from zipfile import ZipFile
from .const import CLI_2_DOCKER_IMAGE, CORE_PROJECT_ID, INTEGRATIONS_DIR
import lokalise
import requests
from .const import CORE_PROJECT_ID, INTEGRATIONS_DIR
from .error import ExitApp
from .util import (
flatten_translations,
get_base_arg_parser,
get_lokalise_token,
load_json_from_path,
substitute_references,
@@ -20,44 +27,95 @@ from .util import (
DOWNLOAD_DIR = Path("build/translations-download").absolute()
def run_download_docker() -> None:
"""Run the Docker image to download the translations."""
print("Running Docker to download latest translations.")
result = subprocess.run(
[
"docker",
"run",
"-v",
f"{DOWNLOAD_DIR}:/opt/dest/locale",
"--rm",
f"lokalise/lokalise-cli-2:{CLI_2_DOCKER_IMAGE}",
# Lokalise command
"lokalise2",
"--token",
get_lokalise_token(),
"--project-id",
CORE_PROJECT_ID,
"file",
"download",
CORE_PROJECT_ID,
"--original-filenames=false",
"--replace-breaks=false",
"--filter-data",
"nonfuzzy",
"--disable-references",
"--export-empty-as",
"skip",
"--format",
"json",
"--unzip-to",
"/opt/dest",
],
check=False,
)
print()
POLL_INTERVAL = 5
if result.returncode != 0:
raise ExitApp("Failed to download translations")
def get_arguments() -> argparse.Namespace:
"""Get parsed passed in arguments."""
parser = get_base_arg_parser()
parser.add_argument(
"--async-start",
action="store_true",
help="Start an async download and return the process ID.",
)
parser.add_argument(
"--process-id",
type=str,
help="Process ID to wait for, then download and unzip the result.",
)
return parser.parse_args()
def get_client() -> lokalise.Client:
"""Get an authenticated Lokalise client."""
return lokalise.Client(get_lokalise_token())
def start_async_download(client: lokalise.Client) -> str:
"""Start an async download and return the process ID."""
process = client.download_files_async(
CORE_PROJECT_ID,
{
"format": "json",
"original_filenames": False,
"replace_breaks": False,
"filter_data": "nonfuzzy",
"disable_references": True,
"export_empty_as": "skip",
},
)
return process.process_id
def wait_for_process(client: lokalise.Client, process_id: str) -> str:
"""Wait for a queued process to complete and return the process download URL."""
while True:
process_info = client.queued_process(CORE_PROJECT_ID, process_id)
# Current status of the process. Can be queued, pre_processing, running,
# post_processing, cancelled, finished or failed.
status = process_info.status
additional_info = ""
if process_info.details is not None and (details := dict(process_info.details)):
if (
status == "running"
and (done := details.get("items_processed")) is not None
and (total := details.get("items_to_process")) is not None
):
additional_info = f" ({done}/{total})"
elif status == "finished":
additional_info = f" total_keys={details.get('total_number_of_keys')}"
else:
additional_info = f" details={details}"
print(f"Process {process_id}: status={status}{additional_info}")
if status == "finished":
return process_info.details["download_url"]
if status in ("cancelled", "failed"):
raise ExitApp(
f"Process {process_id} ended with status: {status}{additional_info}"
)
time.sleep(POLL_INTERVAL)
def download_and_unzip(bundle_url: str) -> None:
"""Download a zip bundle and extract it to the download directory."""
print("Downloading translations from lokalise...")
response = requests.get(bundle_url, timeout=120)
response.raise_for_status()
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
with ZipFile(io.BytesIO(response.content)) as zf:
zf.extractall(DOWNLOAD_DIR)
print(f"Extracted translations to {DOWNLOAD_DIR}")
def fetch_translations(client: lokalise.Client, process_id: str) -> None:
"""Wait for a process to finish, then download and unzip the bundle."""
download_url = wait_for_process(client, process_id)
download_and_unzip(download_url)
def save_json(filename: Path, data: list | dict) -> None:
@@ -136,14 +194,23 @@ def delete_old_translations() -> None:
fil.unlink()
def run() -> None:
def run() -> int:
"""Run the script."""
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
args = get_arguments()
client = get_client()
run_download_docker()
process_id = args.process_id
if not process_id:
# if no process ID provided, start a new async download and print the process ID
process_id = start_async_download(client)
print(f"Async download started. Process ID: {process_id}")
if args.async_start:
# If --async-start is provided, exit after starting the download
return 0
fetch_translations(client, process_id)
delete_old_translations()
save_integrations_translations()
return 0

2
script/translations/requirements.txt generated Normal file
View File

@@ -0,0 +1,2 @@
python-lokalise-api==4.0.4
requests

View File

@@ -21,9 +21,6 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import UTC, utcnow
@@ -905,20 +902,3 @@ async def test_remove_entry(
assert await hass.config_entries.async_remove(entry.entry_id)
assert entry.state is ConfigEntryState.NOT_LOADED
async def test_oauth_implementation_not_available(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
config_entry.add_to_hass(hass)
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
side_effect=ImplementationUnavailableError,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -501,7 +501,7 @@
}
],
"1/513/74": 8,
"1/513/78": "AQ==",
"1/513/78": null,
"1/513/80": [
{
"0": "AQ==",

View File

@@ -210,16 +210,6 @@
]),
'max_temp': 30.0,
'min_temp': 10.0,
'preset_modes': list([
'home',
'away',
'sleep',
'wake',
'vacation',
'going_to_sleep',
'Eco',
'none',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
@@ -245,7 +235,7 @@
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 401>,
'supported_features': <ClimateEntityFeature: 385>,
'translation_key': 'thermostat',
'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-MatterThermostat-513-0',
'unit_of_measurement': None,
@@ -262,18 +252,7 @@
]),
'max_temp': 30.0,
'min_temp': 10.0,
'preset_mode': 'home',
'preset_modes': list([
'home',
'away',
'sleep',
'wake',
'vacation',
'going_to_sleep',
'Eco',
'none',
]),
'supported_features': <ClimateEntityFeature: 401>,
'supported_features': <ClimateEntityFeature: 385>,
'temperature': 17.5,
}),
'context': <ANY>,
@@ -511,11 +490,6 @@
]),
'max_temp': 32.0,
'min_temp': 7.0,
'preset_modes': list([
'home',
'away',
'none',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
@@ -541,7 +515,7 @@
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 403>,
'supported_features': <ClimateEntityFeature: 387>,
'translation_key': 'thermostat',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-MatterThermostat-513-0',
'unit_of_measurement': None,
@@ -561,13 +535,7 @@
]),
'max_temp': 32.0,
'min_temp': 7.0,
'preset_mode': 'none',
'preset_modes': list([
'home',
'away',
'none',
]),
'supported_features': <ClimateEntityFeature: 403>,
'supported_features': <ClimateEntityFeature: 387>,
'target_temp_high': 26.0,
'target_temp_low': 20.0,
'temperature': None,

View File

@@ -8,15 +8,9 @@ from matter_server.common.helpers.util import create_attribute_path_from_attribu
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.climate import (
PRESET_NONE,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.components.climate import ClimateEntityFeature, HVACAction, HVACMode
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
from .common import (
@@ -322,35 +316,6 @@ async def test_thermostat_service_calls(
)
matter_client.write_attribute.reset_mock()
# test changing only target_temp_high when target_temp_low stays the same
set_node_attribute(matter_node, 1, 513, 18, 1000)
set_node_attribute(matter_node, 1, 513, 17, 2500)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.longan_link_hvac")
assert state
assert state.attributes["target_temp_high"] == 25
assert state.attributes["target_temp_low"] == 10
await hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": "climate.longan_link_hvac",
"target_temp_low": 10, # Same as current
"target_temp_high": 28, # Different from current
},
blocking=True,
)
# Only target_temp_high should be written since target_temp_low hasn't changed
assert matter_client.write_attribute.call_count == 1
assert matter_client.write_attribute.call_args == call(
node_id=matter_node.node_id,
attribute_path="1/513/17",
value=2800,
)
matter_client.write_attribute.reset_mock()
# test change HAVC mode to heat
await hass.services.async_call(
"climate",
@@ -454,292 +419,3 @@ async def test_room_airconditioner(
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("climate.room_airconditioner")
assert state.attributes["supported_features"] & ClimateEntityFeature.TURN_ON
@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"])
async def test_eve_thermo_v5_presets(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test Eve Thermo v5 thermostat presets attributes and state updates."""
# test entity attributes
entity_id = "climate.eve_thermo_20ecd1701"
state = hass.states.get(entity_id)
assert state
# test supported features correctly parsed
mask = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.PRESET_MODE
)
assert state.attributes["supported_features"] & mask == mask
# Test preset modes parsed correctly from Eve Thermo v5
# Should use HA standard presets for known ones, original names for others
# PRESET_NONE is always included to allow users to clear the preset
assert state.attributes["preset_modes"] == [
"home",
"away",
"sleep",
"wake",
"vacation",
"going_to_sleep",
"Eco",
PRESET_NONE,
]
assert state.attributes["preset_mode"] == "home"
# Get presets from the node for dynamic testing
presets_attribute = matter_node.endpoints[1].get_attribute_value(
513,
clusters.Thermostat.Attributes.Presets.attribute_id,
)
preset_by_name = {preset.name: preset.presetHandle for preset in presets_attribute}
# test set_preset_mode with "home" preset (HA standard)
await hass.services.async_call(
"climate",
"set_preset_mode",
{
"entity_id": entity_id,
"preset_mode": "home",
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.Thermostat.Commands.SetActivePresetRequest(
presetHandle=preset_by_name["Home"]
),
)
# Verify preset_mode is optimistically updated
state = hass.states.get(entity_id)
assert state
assert state.attributes["preset_mode"] == "home"
matter_client.send_device_command.reset_mock()
# test set_preset_mode with "away" preset (HA standard)
await hass.services.async_call(
"climate",
"set_preset_mode",
{
"entity_id": entity_id,
"preset_mode": "away",
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.Thermostat.Commands.SetActivePresetRequest(
presetHandle=preset_by_name["Away"]
),
)
# Verify preset_mode is optimistically updated
state = hass.states.get(entity_id)
assert state
assert state.attributes["preset_mode"] == "away"
matter_client.send_device_command.reset_mock()
# test set_preset_mode with "eco" preset (custom, device-provided name)
await hass.services.async_call(
"climate",
"set_preset_mode",
{
"entity_id": entity_id,
"preset_mode": "Eco",
},
blocking=True,
)
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.Thermostat.Commands.SetActivePresetRequest(
presetHandle=preset_by_name["Eco"]
),
)
matter_client.send_device_command.reset_mock()
# test set_preset_mode with invalid preset mode
# The climate platform validates preset modes before calling our method
# Get current state to derive expected modes
state = hass.states.get(entity_id)
assert state
expected_modes = ", ".join(state.attributes["preset_modes"])
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
"climate",
"set_preset_mode",
{
"entity_id": entity_id,
"preset_mode": "InvalidPreset",
},
blocking=True,
)
assert err.value.translation_key == "not_valid_preset_mode"
assert err.value.translation_placeholders == {
"mode": "InvalidPreset",
"modes": expected_modes,
}
# Ensure no command was sent for invalid preset
assert matter_client.send_device_command.call_count == 0
# Test that preset_mode is updated when ActivePresetHandle is set from device
set_node_attribute(
matter_node,
1,
513,
clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id,
preset_by_name["Home"],
)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.attributes["preset_mode"] == "home"
# Test that preset_mode is updated when ActivePresetHandle changes to different preset
set_node_attribute(
matter_node,
1,
513,
clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id,
preset_by_name["Away"],
)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.attributes["preset_mode"] == "away"
# Test that preset_mode is PRESET_NONE when ActivePresetHandle is cleared
set_node_attribute(
matter_node,
1,
513,
clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id,
None,
)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.attributes["preset_mode"] == PRESET_NONE
# Test that users can set preset_mode to PRESET_NONE to clear the active preset
matter_client.send_device_command.reset_mock()
# First set a preset so we have something to clear
await hass.services.async_call(
"climate",
"set_preset_mode",
{
"entity_id": entity_id,
"preset_mode": "home",
},
blocking=True,
)
matter_client.send_device_command.reset_mock()
# Now call set_preset_mode with PRESET_NONE to clear it
await hass.services.async_call(
"climate",
"set_preset_mode",
{
"entity_id": entity_id,
"preset_mode": PRESET_NONE,
},
blocking=True,
)
# Verify the command was sent with null value to clear the preset
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.Thermostat.Commands.SetActivePresetRequest(presetHandle=None),
)
# Verify preset_mode is optimistically updated to PRESET_NONE
state = hass.states.get(entity_id)
assert state
assert state.attributes["preset_mode"] == PRESET_NONE
@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"])
async def test_preset_mode_with_unnamed_preset(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test preset mode when a preset has no name or empty name.
This tests the fallback preset naming case where a preset does not have
a mapped presetScenario and also has no device-provided name, requiring
the fallback Preset{i} naming pattern.
"""
entity_id = "climate.eve_thermo_20ecd1701"
# Get current presets from the node
presets_attribute = matter_node.endpoints[1].get_attribute_value(
513,
clusters.Thermostat.Attributes.Presets.attribute_id,
)
assert presets_attribute is not None
# Add a new preset with unmapped scenario (e.g., 255) and no name
new_preset = clusters.Thermostat.Structs.PresetStruct(
presetHandle=b"\xff",
presetScenario=255, # Unmapped scenario
name="", # Empty name
)
presets_attribute.append(new_preset)
# Update the node with the new preset list
set_node_attribute(
matter_node,
1,
513,
clusters.Thermostat.Attributes.Presets.attribute_id,
presets_attribute,
)
# Trigger subscription callback to update entity
await trigger_subscription_callback(hass, matter_client)
# Verify the preset was added with the fallback name "Preset8"
state = hass.states.get(entity_id)
assert state
assert "Preset8" in state.attributes["preset_modes"]
# Test that the unnamed preset can be set as active
await hass.services.async_call(
"climate",
"set_preset_mode",
{
"entity_id": entity_id,
"preset_mode": "Preset8",
},
blocking=True,
)
state = hass.states.get(entity_id)
assert state
assert state.attributes["preset_mode"] == "Preset8"
# Test that preset_mode is PRESET_NONE when ActivePresetHandle is cleared
set_node_attribute(
matter_node,
1,
513,
clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id,
None,
)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.attributes["preset_mode"] == PRESET_NONE

View File

@@ -31,7 +31,7 @@
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'warning',
'translation_key': None,
'unique_id': '095760000000-1',
'unit_of_measurement': None,
})
@@ -93,7 +93,7 @@
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'warning',
'translation_key': None,
'unique_id': '095760000000-2',
'unit_of_measurement': None,
})
@@ -144,7 +144,7 @@
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'warning',
'translation_key': None,
'unique_id': '095760000000-3',
'unit_of_measurement': None,
})
@@ -195,7 +195,7 @@
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'warning',
'translation_key': None,
'unique_id': '095760000000-4',
'unit_of_measurement': None,
})
@@ -246,7 +246,7 @@
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'warning',
'translation_key': None,
'unique_id': '095760000000-5',
'unit_of_measurement': None,
})

View File

@@ -116,7 +116,7 @@ async def test_button_start_charge(
with patch(
"renault_api.renault_vehicle.RenaultVehicle.set_charge_start",
return_value=(
schemas.KamereonVehicleChargingStartActionDataSchema.loads(
schemas.KamereonVehicleHvacStartActionDataSchema.loads(
await async_load_fixture(hass, "action.set_charge_start.json", DOMAIN)
)
),
@@ -125,7 +125,7 @@ async def test_button_start_charge(
BUTTON_DOMAIN, SERVICE_PRESS, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == (None,)
assert mock_action.mock_calls[0][1] == ()
@pytest.mark.usefixtures("fixtures_with_data")

View File

@@ -132,60 +132,6 @@ async def test_service_set_ac_start_with_date(
assert mock_action.mock_calls[0][1] == (temperature, when)
async def test_service_charge_start_simple(
hass: HomeAssistant, config_entry: ConfigEntry
) -> None:
"""Test that service invokes renault_api with correct data."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
data = {
ATTR_VEHICLE: get_device_id(hass),
}
with patch(
"renault_api.renault_vehicle.RenaultVehicle.set_charge_start",
return_value=(
schemas.KamereonVehicleChargingStartActionDataSchema.loads(
await async_load_fixture(hass, "action.set_charge_start.json", DOMAIN)
)
),
) as mock_action:
await hass.services.async_call(
DOMAIN, "charge_start", service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == (None,)
async def test_service_charge_start_with_date(
hass: HomeAssistant, config_entry: ConfigEntry
) -> None:
"""Test that service invokes renault_api with correct data."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
when = datetime(2025, 8, 23, 17, 12, 45)
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_WHEN: when,
}
with patch(
"renault_api.renault_vehicle.RenaultVehicle.set_charge_start",
return_value=(
schemas.KamereonVehicleChargingStartActionDataSchema.loads(
await async_load_fixture(hass, "action.set_charge_start.json", DOMAIN)
)
),
) as mock_action:
await hass.services.async_call(
DOMAIN, "charge_start", service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == (when,)
async def test_service_set_charge_schedule(
hass: HomeAssistant, config_entry: ConfigEntry, snapshot: SnapshotAssertion
) -> None:

View File

@@ -1,157 +0,0 @@
"""Tests for SLZB-Ultima remote entity."""
from unittest.mock import MagicMock, patch
from pysmlight import Info
from pysmlight.exceptions import SmlightError
from pysmlight.models import IRPayload
import pytest
from homeassistant.components.remote import (
ATTR_COMMAND,
ATTR_DELAY_SECS,
ATTR_NUM_REPEATS,
DOMAIN as REMOTE_DOMAIN,
SERVICE_SEND_COMMAND,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .conftest import setup_integration
from tests.common import MockConfigEntry
@pytest.fixture
def platforms() -> list[Platform]:
"""Platforms, which should be loaded during the test."""
return [Platform.REMOTE]
MOCK_ULTIMA = Info(
MAC="AA:BB:CC:DD:EE:FF",
model="SLZB-Ultima3",
)
async def test_remote_setup_ultima(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_smlight_client: MagicMock,
) -> None:
"""Test remote entity is created for Ultima devices."""
mock_smlight_client.get_info.side_effect = None
mock_smlight_client.get_info.return_value = MOCK_ULTIMA
await setup_integration(hass, mock_config_entry)
state = hass.states.get("remote.mock_title_ir_remote")
assert state is not None
async def test_remote_not_created_non_ultima(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_smlight_client: MagicMock,
) -> None:
"""Test remote entity is not created for non-Ultima devices."""
mock_smlight_client.get_info.side_effect = None
mock_smlight_client.get_info.return_value = Info(
MAC="AA:BB:CC:DD:EE:FF",
model="SLZB-MR1",
)
await setup_integration(hass, mock_config_entry)
state = hass.states.get("remote.mock_title_ir_remote")
assert state is None
async def test_remote_send_command(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_smlight_client: MagicMock,
) -> None:
"""Test sending IR command."""
mock_smlight_client.get_info.side_effect = None
mock_smlight_client.get_info.return_value = MOCK_ULTIMA
await setup_integration(hass, mock_config_entry)
entity_id = "remote.mock_title_ir_remote"
state = hass.states.get(entity_id)
assert state is not None
await hass.services.async_call(
REMOTE_DOMAIN,
SERVICE_SEND_COMMAND,
{
ATTR_ENTITY_ID: entity_id,
ATTR_COMMAND: ["my_code", "another_code"],
ATTR_DELAY_SECS: 0,
},
blocking=True,
)
assert mock_smlight_client.actions.send_ir_code.call_count == 2
mock_smlight_client.actions.send_ir_code.assert_any_call(IRPayload(code="my_code"))
mock_smlight_client.actions.send_ir_code.assert_any_call(
IRPayload(code="another_code")
)
async def test_remote_send_command_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_smlight_client: MagicMock,
) -> None:
"""Test connection error handling."""
mock_smlight_client.get_info.side_effect = None
mock_smlight_client.get_info.return_value = MOCK_ULTIMA
await setup_integration(hass, mock_config_entry)
entity_id = "remote.mock_title_ir_remote"
state = hass.states.get(entity_id)
assert state is not None
mock_smlight_client.actions.send_ir_code.side_effect = SmlightError("Failed")
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
REMOTE_DOMAIN,
SERVICE_SEND_COMMAND,
{ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: ["my_code"]},
blocking=True,
)
assert exc_info.value.translation_key == "send_ir_code_failed"
@patch("homeassistant.components.smlight.remote.asyncio.sleep")
async def test_remote_send_command_repeats(
mock_sleep: MagicMock,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_smlight_client: MagicMock,
) -> None:
"""Test sending IR command with repeats and delay."""
mock_smlight_client.get_info.side_effect = None
mock_smlight_client.get_info.return_value = MOCK_ULTIMA
await setup_integration(hass, mock_config_entry)
entity_id = "remote.mock_title_ir_remote"
state = hass.states.get(entity_id)
assert state is not None
await hass.services.async_call(
REMOTE_DOMAIN,
SERVICE_SEND_COMMAND,
{
ATTR_ENTITY_ID: entity_id,
ATTR_COMMAND: ["my_code", "another_code"],
ATTR_NUM_REPEATS: 2,
ATTR_DELAY_SECS: 0.5,
},
blocking=True,
)
assert mock_smlight_client.actions.send_ir_code.call_count == 4
assert mock_sleep.call_count == 5
mock_sleep.assert_called_with(0.5)

View File

@@ -1,172 +1,4 @@
# serializer version: 1
# name: test_no_coordinator_data[Climate Panel][sensor.meter_1_battery-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.meter_1_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
'original_icon': None,
'original_name': 'Battery',
'platform': 'switchbot_cloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'meter-id-1_battery',
'unit_of_measurement': '%',
})
# ---
# name: test_no_coordinator_data[Climate Panel][sensor.meter_1_battery-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'meter-1 Battery',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.meter_1_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_no_coordinator_data[Climate Panel][sensor.meter_1_humidity-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.meter_1_humidity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'original_icon': None,
'original_name': 'Humidity',
'platform': 'switchbot_cloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'meter-id-1_humidity',
'unit_of_measurement': '%',
})
# ---
# name: test_no_coordinator_data[Climate Panel][sensor.meter_1_humidity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'humidity',
'friendly_name': 'meter-1 Humidity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.meter_1_humidity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_no_coordinator_data[Climate Panel][sensor.meter_1_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.meter_1_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'switchbot_cloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'meter-id-1_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_no_coordinator_data[Climate Panel][sensor.meter_1_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'meter-1 Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.meter_1_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_no_coordinator_data[Meter][sensor.meter_1_battery-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -570,171 +402,3 @@
'state': 'unknown',
})
# ---
# name: test_no_coordinator_data[WeatherStation][sensor.meter_1_battery-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.meter_1_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
'original_icon': None,
'original_name': 'Battery',
'platform': 'switchbot_cloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'meter-id-1_battery',
'unit_of_measurement': '%',
})
# ---
# name: test_no_coordinator_data[WeatherStation][sensor.meter_1_battery-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'meter-1 Battery',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.meter_1_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_no_coordinator_data[WeatherStation][sensor.meter_1_humidity-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.meter_1_humidity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'original_icon': None,
'original_name': 'Humidity',
'platform': 'switchbot_cloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'meter-id-1_humidity',
'unit_of_measurement': '%',
})
# ---
# name: test_no_coordinator_data[WeatherStation][sensor.meter_1_humidity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'humidity',
'friendly_name': 'meter-1 Humidity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.meter_1_humidity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_no_coordinator_data[WeatherStation][sensor.meter_1_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.meter_1_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'switchbot_cloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'meter-id-1_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_no_coordinator_data[WeatherStation][sensor.meter_1_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'meter-1 Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.meter_1_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -79,7 +79,10 @@ async def test_plug_mini_eu(
@pytest.mark.parametrize(
"device_model",
["Meter", "Plug Mini (EU)", "Climate Panel", "WeatherStation"],
[
"Meter",
"Plug Mini (EU)",
],
)
async def test_no_coordinator_data(
hass: HomeAssistant,

View File

@@ -801,7 +801,7 @@ async def test_existing_node_not_replaced_when_not_ready(
await hass.async_block_till_done()
state = hass.states.get(custom_entity)
assert state
assert state.name == "Custom Entity Name"
assert state.name == "Custom Device Name Custom Entity Name"
assert not hass.states.get(motion_entity)
node_state = deepcopy(zp3111_not_ready_state)
@@ -835,7 +835,7 @@ async def test_existing_node_not_replaced_when_not_ready(
state = hass.states.get(custom_entity)
assert state
assert state.name == "Custom Entity Name"
assert state.name == "Custom Device Name Custom Entity Name"
event = Event(
type="ready",
@@ -866,7 +866,7 @@ async def test_existing_node_not_replaced_when_not_ready(
state = hass.states.get(custom_entity)
assert state
assert state.state != STATE_UNAVAILABLE
assert state.name == "Custom Entity Name"
assert state.name == "Custom Device Name Custom Entity Name"
@pytest.mark.usefixtures("client")
@@ -1857,7 +1857,7 @@ async def test_node_model_change(
assert not hass.states.get(motion_entity)
state = hass.states.get(custom_entity)
assert state
assert state.name == "Custom Entity Name"
assert state.name == "Custom Device Name Custom Entity Name"
# Unload the integration
assert await hass.config_entries.async_unload(integration.entry_id)
@@ -1887,7 +1887,7 @@ async def test_node_model_change(
assert not hass.states.get(motion_entity)
state = hass.states.get(custom_entity)
assert state
assert state.name == "Custom Entity Name"
assert state.name == "Custom Device Name Custom Entity Name"
@pytest.mark.usefixtures("zp3111", "integration")

View File

@@ -684,6 +684,7 @@ async def test_load_bad_data(
"labels": [],
"modified_at": "2024-02-14T12:00:00.900075+00:00",
"name": None,
"name_v2": None,
"object_id_base": None,
"options": None,
"original_device_class": None,
@@ -718,6 +719,7 @@ async def test_load_bad_data(
"labels": [],
"modified_at": "2024-02-14T12:00:00.900075+00:00",
"name": None,
"name_v2": None,
"object_id_base": None,
"options": None,
"original_device_class": None,
@@ -752,6 +754,7 @@ async def test_load_bad_data(
"labels": [],
"modified_at": "2024-02-14T12:00:00.900075+00:00",
"name": None,
"name_v2": None,
"options": None,
"options_undefined": False,
"orphaned_timestamp": None,
@@ -777,6 +780,7 @@ async def test_load_bad_data(
"labels": [],
"modified_at": "2024-02-14T12:00:00.900075+00:00",
"name": None,
"name_v2": None,
"options": None,
"options_undefined": False,
"orphaned_timestamp": None,
@@ -1131,6 +1135,7 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any])
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": None,
"name_v2": None,
"object_id_base": None,
"options": {},
"original_device_class": "best_class",
@@ -1326,6 +1331,7 @@ async def test_migration_1_11(
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": None,
"name_v2": None,
"object_id_base": None,
"options": {},
"original_device_class": "best_class",
@@ -1361,6 +1367,7 @@ async def test_migration_1_11(
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": None,
"name_v2": None,
"options": {},
"options_undefined": True,
"orphaned_timestamp": None,
@@ -1493,6 +1500,7 @@ async def test_migration_1_18(
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": None,
"name_v2": None,
"object_id_base": "Test Entity",
"options": {},
"original_device_class": "best_class",
@@ -1528,6 +1536,7 @@ async def test_migration_1_18(
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": None,
"name_v2": None,
"options": {},
"options_undefined": False,
"orphaned_timestamp": None,
@@ -1545,14 +1554,10 @@ async def test_migration_1_18(
@pytest.mark.parametrize("load_registries", [False])
async def test_migration_1_21(
hass: HomeAssistant,
hass_storage: dict[str, Any],
async def test_migration_1_20(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test migration from version 1.21.
Version 1.21 stored entity names in a new format, but was reverted.
"""
"""Test migration from version 1.20."""
hass_storage[dr.STORAGE_KEY] = {
"version": dr.STORAGE_VERSION_MAJOR,
"minor_version": dr.STORAGE_VERSION_MINOR,
@@ -1560,25 +1565,24 @@ async def test_migration_1_21(
"devices": [
{
"area_id": None,
"config_entries": ["mock_entry"],
"config_entries_subentries": {"mock_entry": [None]},
"config_entries": ["mock-config-entry"],
"config_entries_subentries": {"mock-config-entry": [None]},
"configuration_url": None,
"connections": [],
"created_at": "1970-01-01T00:00:00+00:00",
"disabled_by": None,
"disabled_by_undefined": False,
"entry_type": None,
"hw_version": None,
"id": "device_1234",
"identifiers": [["test", "device_1"]],
"id": "device-1",
"identifiers": [["test", "device-1"]],
"labels": [],
"manufacturer": None,
"model": None,
"model_id": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name_by_user": None,
"name": "My Device",
"primary_config_entry": "mock_entry",
"name_by_user": None,
"primary_config_entry": "mock-config-entry",
"serial_number": None,
"sw_version": None,
"via_device_id": None,
@@ -1587,121 +1591,238 @@ async def test_migration_1_21(
"deleted_devices": [],
},
}
dr.async_setup(hass)
await dr.async_load(hass)
entity_base = {
"aliases": [],
"area_id": None,
"capabilities": {},
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00",
"device_id": "device_1234",
"disabled_by": None,
"entity_category": None,
"has_entity_name": False,
"hidden_by": None,
"icon": None,
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"object_id_base": "Temperature",
"options": {},
"original_device_class": "temperature",
"original_icon": None,
"original_name": "Temperature",
"platform": "super_platform",
"previous_unique_id": None,
"suggested_object_id": None,
"supported_features": 0,
"translation_key": None,
"unit_of_measurement": None,
"device_class": None,
}
# Entity registry data at version 1.20
hass_storage[er.STORAGE_KEY] = {
"version": 1,
"minor_version": 21,
"minor_version": 20,
"data": {
"entities": [
{
**entity_base,
# Entity with name=None
# name should be preserved
# should add None to aliases
"aliases": [],
"area_id": None,
"capabilities": {},
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00",
"device_id": "device-1",
"disabled_by": None,
"entity_category": None,
"entity_id": "test.entity_name_no_custom",
"has_entity_name": True,
"hidden_by": None,
"icon": None,
"id": "entity-1",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": None,
"object_id_base": "Test entity",
"options": {},
"original_device_class": None,
"original_icon": None,
"original_name": "Test entity",
"platform": "test_platform",
"previous_unique_id": None,
"suggested_object_id": None,
"supported_features": 0,
"translation_key": None,
"unique_id": "unique-1",
"unit_of_measurement": None,
"device_class": None,
},
{
# Entity with no device_id
# name should be preserved
# should add None to aliases
"aliases": [],
"area_id": None,
"capabilities": {},
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00",
"device_id": None,
"disabled_by": None,
"entity_category": None,
"entity_id": "test.no_device",
"has_entity_name": True,
"hidden_by": None,
"icon": None,
"id": "entity-2",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "Standalone Sensor",
"object_id_base": "Test entity",
"options": {},
"original_device_class": None,
"original_icon": None,
"original_name": "Test entity",
"platform": "test_platform",
"previous_unique_id": None,
"suggested_object_id": None,
"supported_features": 0,
"translation_key": None,
"unique_id": "unique-2",
"unit_of_measurement": None,
"device_class": None,
},
{
# Entity with name starting with device name
# name should be stripped to remove device name prefix
# should add None to aliases
"aliases": [],
"area_id": None,
"capabilities": {},
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00",
"device_id": "device-1",
"disabled_by": None,
"entity_category": None,
"entity_id": "test.name_with_device_prefix",
"has_entity_name": True,
"hidden_by": None,
"icon": None,
"id": "entity-3",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "My device temperature",
"object_id_base": "Test entity",
"options": {},
"original_device_class": None,
"original_icon": None,
"original_name": "Test entity",
"platform": "test_platform",
"previous_unique_id": None,
"suggested_object_id": None,
"supported_features": 0,
"translation_key": None,
"unique_id": "unique-3",
"unit_of_measurement": None,
"device_class": None,
},
{
# Entity with custom name not starting with device name
# not exposed to any voice assistant
# name should be preserved
# should add None to aliases
"aliases": [],
"area_id": None,
"capabilities": {},
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00",
"device_id": "device-1",
"disabled_by": None,
"entity_category": None,
"entity_id": "test.custom_name",
"id": "entity_custom_name",
"unique_id": "custom_name",
"name": "My Custom Name",
"name_v2": "My Custom Name",
"has_entity_name": True,
"hidden_by": None,
"icon": None,
"id": "entity-4",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "Living Room Light",
"object_id_base": "Test entity",
"options": {},
"original_device_class": None,
"original_icon": None,
"original_name": "Test entity",
"platform": "test_platform",
"previous_unique_id": None,
"suggested_object_id": None,
"supported_features": 0,
"translation_key": None,
"unique_id": "unique-4",
"unit_of_measurement": None,
"device_class": None,
},
{
**entity_base,
"entity_id": "test.stripped",
"id": "entity_stripped",
"unique_id": "stripped",
"name": "My Device Temperature",
"name_v2": "Temperature",
},
{
**entity_base,
"entity_id": "test.stripped_and_renamed",
"id": "entity_stripped_and_renamed",
"unique_id": "stripped_and_renamed",
"name": "My Device Temperature",
"name_v2": "Heat",
# Entity with custom name not starting with device name
# exposed to conversation assistant
# name should be preserved
# should add name to aliases
"aliases": [],
"area_id": None,
"capabilities": {},
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00",
"device_id": "device-1",
"disabled_by": None,
"entity_category": None,
"entity_id": "test.custom_name_exposed",
"has_entity_name": True,
"hidden_by": None,
"icon": None,
"id": "entity-5",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "Living Room Light",
"object_id_base": "Test entity",
"options": {
"conversation": {"should_expose": True},
},
"original_device_class": None,
"original_icon": None,
"original_name": "Test entity",
"platform": "test_platform",
"previous_unique_id": None,
"suggested_object_id": None,
"supported_features": 0,
"translation_key": None,
"unique_id": "unique-5",
"unit_of_measurement": None,
"device_class": None,
},
],
"deleted_entities": [],
"deleted_entities": [
{
# Deleted entity
# name should be reset to None
# should add None to aliases
"aliases": ["deleted_alias"],
"area_id": None,
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00",
"device_class": None,
"disabled_by": None,
"disabled_by_undefined": False,
"entity_id": "test.deleted_entity",
"hidden_by": None,
"hidden_by_undefined": False,
"icon": None,
"id": "deleted-1",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "Deleted Name",
"options": {},
"options_undefined": False,
"orphaned_timestamp": None,
"platform": "test_platform",
"unique_id": "deleted-unique",
}
],
},
}
await er.async_load(hass)
registry = er.async_get(hass)
entry = registry.async_get_or_create("test", "super_platform", "custom_name")
assert entry.name == "My Custom Name"
entry = registry.async_get_or_create("test", "super_platform", "stripped")
assert entry.name == "My Device Temperature"
entry = registry.async_get_or_create(
"test", "super_platform", "stripped_and_renamed"
)
assert entry.name == "My Device Heat"
# Check migrated data
await flush_store(registry._store)
migrated_data = hass_storage[er.STORAGE_KEY]
migrated_entity_base = {
"aliases": [],
"aliases_v2": [None],
"area_id": None,
"capabilities": {},
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00",
"device_id": "device_1234",
"disabled_by": None,
"entity_category": None,
"has_entity_name": False,
"hidden_by": None,
"icon": None,
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"object_id_base": "Temperature",
"options": {},
"original_device_class": "temperature",
"original_icon": None,
"original_name": "Temperature",
"platform": "super_platform",
"previous_unique_id": None,
"suggested_object_id": None,
"supported_features": 0,
"translation_key": None,
"unit_of_measurement": None,
"device_class": None,
}
assert migrated_data == {
"version": er.STORAGE_VERSION_MAJOR,
"minor_version": er.STORAGE_VERSION_MINOR,
@@ -1709,28 +1830,211 @@ async def test_migration_1_21(
"data": {
"entities": [
{
**migrated_entity_base,
"aliases": [],
"aliases_v2": [None],
"area_id": None,
"capabilities": {},
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00",
"device_id": "device-1",
"disabled_by": None,
"entity_category": None,
"entity_id": "test.entity_name_no_custom",
"has_entity_name": True,
"hidden_by": None,
"icon": None,
"id": "entity-1",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": None,
"name_v2": None,
"object_id_base": "Test entity",
"options": {},
"original_device_class": None,
"original_icon": None,
"original_name": "Test entity",
"platform": "test_platform",
"previous_unique_id": None,
"suggested_object_id": None,
"supported_features": 0,
"translation_key": None,
"unique_id": "unique-1",
"unit_of_measurement": None,
"device_class": None,
},
{
"aliases": [],
"aliases_v2": [None],
"area_id": None,
"capabilities": {},
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00",
"device_id": None,
"disabled_by": None,
"entity_category": None,
"entity_id": "test.no_device",
"has_entity_name": True,
"hidden_by": None,
"icon": None,
"id": "entity-2",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "Standalone Sensor",
"name_v2": "Standalone Sensor",
"object_id_base": "Test entity",
"options": {},
"original_device_class": None,
"original_icon": None,
"original_name": "Test entity",
"platform": "test_platform",
"previous_unique_id": None,
"suggested_object_id": None,
"supported_features": 0,
"translation_key": None,
"unique_id": "unique-2",
"unit_of_measurement": None,
"device_class": None,
},
{
"aliases": [],
"aliases_v2": [None],
"area_id": None,
"capabilities": {},
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00",
"device_id": "device-1",
"disabled_by": None,
"entity_category": None,
"entity_id": "test.name_with_device_prefix",
"has_entity_name": True,
"hidden_by": None,
"icon": None,
"id": "entity-3",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "My device temperature",
"name_v2": "Temperature",
"object_id_base": "Test entity",
"options": {},
"original_device_class": None,
"original_icon": None,
"original_name": "Test entity",
"platform": "test_platform",
"previous_unique_id": None,
"suggested_object_id": None,
"supported_features": 0,
"translation_key": None,
"unique_id": "unique-3",
"unit_of_measurement": None,
"device_class": None,
},
{
"aliases": [],
"aliases_v2": [None],
"area_id": None,
"capabilities": {},
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00",
"device_id": "device-1",
"disabled_by": None,
"entity_category": None,
"entity_id": "test.custom_name",
"id": "entity_custom_name",
"unique_id": "custom_name",
"name": "My Custom Name",
"has_entity_name": True,
"hidden_by": None,
"icon": None,
"id": "entity-4",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "Living Room Light",
"name_v2": "Living Room Light",
"object_id_base": "Test entity",
"options": {},
"original_device_class": None,
"original_icon": None,
"original_name": "Test entity",
"platform": "test_platform",
"previous_unique_id": None,
"suggested_object_id": None,
"supported_features": 0,
"translation_key": None,
"unique_id": "unique-4",
"unit_of_measurement": None,
"device_class": None,
},
{
**migrated_entity_base,
"entity_id": "test.stripped",
"id": "entity_stripped",
"unique_id": "stripped",
"name": "My Device Temperature",
},
{
**migrated_entity_base,
"entity_id": "test.stripped_and_renamed",
"id": "entity_stripped_and_renamed",
"unique_id": "stripped_and_renamed",
"name": "My Device Heat",
"aliases": [],
"aliases_v2": ["Living Room Light"],
"area_id": None,
"capabilities": {},
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00",
"device_id": "device-1",
"disabled_by": None,
"entity_category": None,
"entity_id": "test.custom_name_exposed",
"has_entity_name": True,
"hidden_by": None,
"icon": None,
"id": "entity-5",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "Living Room Light",
"name_v2": "Living Room Light",
"object_id_base": "Test entity",
"options": {
"conversation": {"should_expose": True},
},
"original_device_class": None,
"original_icon": None,
"original_name": "Test entity",
"platform": "test_platform",
"previous_unique_id": None,
"suggested_object_id": None,
"supported_features": 0,
"translation_key": None,
"unique_id": "unique-5",
"unit_of_measurement": None,
"device_class": None,
},
],
"deleted_entities": [
{
"aliases": ["deleted_alias"],
"aliases_v2": [None, "deleted_alias"],
"area_id": None,
"categories": {},
"config_entry_id": None,
"config_subentry_id": None,
"created_at": "1970-01-01T00:00:00+00:00",
"device_class": None,
"disabled_by": None,
"disabled_by_undefined": False,
"entity_id": "test.deleted_entity",
"hidden_by": None,
"hidden_by_undefined": False,
"icon": None,
"id": "deleted-1",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "Deleted Name",
"name_v2": None,
"options": {},
"options_undefined": False,
"orphaned_timestamp": None,
"platform": "test_platform",
"unique_id": "deleted-unique",
},
],
"deleted_entities": [],
},
}
@@ -3049,7 +3353,7 @@ async def test_has_entity_name_false_device_name_changes(
assert updated.original_name_unprefixed == "Light Temperature"
updated2 = entity_registry.async_get(entry2.entity_id)
assert updated2.name == "Hue Brightness"
assert updated2.name == "Brightness"
assert updated2.original_name_unprefixed is None
updated3 = entity_registry.async_get(entry3.entity_id)

View File

@@ -203,6 +203,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer):
)
serialized.pop("categories")
serialized.pop("compat_aliases")
serialized.pop("compat_name")
serialized.pop("original_name_unprefixed")
serialized.pop("_cache")
serialized["aliases"] = er._serialize_aliases(serialized["aliases"])