mirror of
https://github.com/home-assistant/core.git
synced 2026-03-28 10:30:30 +01:00
Compare commits
2 Commits
dev
...
edenhaus-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aae63cb397 | ||
|
|
78aa5b9913 |
94
.github/workflows/builder.yml
vendored
94
.github/workflows/builder.yml
vendored
@@ -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
4
Dockerfile
generated
@@ -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 \
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -57,11 +57,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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%]",
|
||||
|
||||
@@ -88,9 +88,6 @@
|
||||
},
|
||||
"charge_set_schedules": {
|
||||
"service": "mdi:calendar-clock"
|
||||
},
|
||||
"charge_start": {
|
||||
"service": "mdi:ev-station"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.REMOTE,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
|
||||
@@ -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)
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
4
requirements_all.txt
generated
@@ -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
|
||||
|
||||
4
requirements_test_all.txt
generated
4
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
2
script/translations/requirements.txt
generated
Normal file
@@ -0,0 +1,2 @@
|
||||
python-lokalise-api==4.0.4
|
||||
requests
|
||||
@@ -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
|
||||
|
||||
@@ -501,7 +501,7 @@
|
||||
}
|
||||
],
|
||||
"1/513/74": 8,
|
||||
"1/513/78": "AQ==",
|
||||
"1/513/78": null,
|
||||
"1/513/80": [
|
||||
{
|
||||
"0": "AQ==",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user