Compare commits

...

32 Commits

Author SHA1 Message Date
Paulus Schoutsen
02a70123c1 Add integration_type to HomeWizard Energy manifest (#157680)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 00:04:08 -05:00
Paulus Schoutsen
5f6d2f537a Add integration_type to Tessie manifest (#157676)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-01 23:50:35 -05:00
Paulus Schoutsen
5e04e9f04d Add integration_type to Home Connect manifest (#157668)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-01 23:49:39 -05:00
Paulus Schoutsen
56515ad7b5 Add integration_type to Sonos manifest (#157674)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 05:03:53 +01:00
Paulus Schoutsen
a1fe2bf4fa Add integration_type to HomeKit Device manifest (#157671)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 05:02:59 +01:00
Paulus Schoutsen
b8fa8efd91 Add integration_type to Apple TV manifest (#157664)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 05:01:56 +01:00
Jesse Hills
03557b5ef2 Bump aioesphomeapi to 42.10.0 (#157678) 2025-12-01 20:59:35 -05:00
Paulus Schoutsen
dafec8ce58 Add integration_type to Reolink manifest (#157672)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-01 20:58:51 -05:00
Paulus Schoutsen
6ff3f74347 Add integration_type to Nuki Bridge manifest (#157683)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-01 20:58:03 -05:00
karwosts
ddd8cf7fde Fix a bad script error message (#157654) 2025-12-01 15:19:30 -05:00
TheJulianJES
1356eea52f Set Matter integration type to "hub" (#157657) 2025-12-01 15:18:55 -05:00
TheJulianJES
6188e0e39b Set ZHA integration type to "hub" (#157656) 2025-12-01 15:18:49 -05:00
Aidan Timson
699fa1617d Default area icons for new instances (#157619) 2025-12-01 20:02:38 +01:00
Erik Montnemery
449f0fa5a5 Bump floor registry to version 1.3 and sort floors (#157614)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-12-01 19:54:43 +01:00
andreimoraru
2e008d2bb7 bump yt-dlp to 2025.11.12 (#157645) 2025-12-01 19:44:54 +01:00
Petro31
05dec2619d Ensure platform template does not appear in repair (#157486) 2025-12-01 19:38:49 +01:00
Paul Bottein
25a6778ba8 Fix user store not loaded on restart (#157616) 2025-12-01 19:37:27 +01:00
epenet
f564b8cb44 Remove unnecessary instanciating in Tuya find_dpcode (#157473) 2025-12-01 19:37:06 +01:00
Erik Montnemery
ce6bfdebfc Improve typing of floor registry events (#157624) 2025-12-01 19:36:50 +01:00
Bram Kragten
f00a944ac1 Update frontend to 20251201.0 (#157638) 2025-12-01 19:28:18 +01:00
Petro31
3073a99ce6 Reload config entry templates when labs flag automation.new_triggers_conditions is set (#157637)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-12-01 19:27:30 +01:00
Erik Montnemery
8b04ce1328 Bump area registry to version 1.9 and sort areas (#157634) 2025-12-01 19:26:17 +01:00
Andrew Jackson
39f76787ab Allow multiline post in Mastodon (#157647) 2025-12-01 18:35:59 +01:00
puddly
e8acced335 Disable owning integrations for the entire firmware interaction process (#157082) 2025-12-01 18:18:32 +01:00
Åke Strandberg
758a30eebc Add code mappings for Miele WQ1000 (#157642) 2025-12-01 17:28:02 +01:00
epenet
faf94bea24 Use read_wrapper entity helper in Tuya (#157632) 2025-12-01 17:00:08 +01:00
epenet
ff91c57228 Adjust Tuya wrapper to return a command list (#157622) 2025-12-01 16:59:26 +01:00
epenet
3d2b506997 Rename Tuya method (#157640) 2025-12-01 16:56:46 +01:00
Kevin Stillhammer
d3c1c28605 Add integration fressnapf_tracker (#157480)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-01 16:48:30 +01:00
mettolen
d4e1f7741d Add sensor entities to Saunum integration (#157342)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-01 16:47:00 +01:00
mettolen
e713632eed Add reauth flow to Airobot integration (#157501) 2025-12-01 16:28:10 +01:00
Maciej Bieniek
060ad35ddc Bump aioshelly to version 13.22.0 (#157629) 2025-12-01 15:47:53 +01:00
94 changed files with 2856 additions and 392 deletions

2
CODEOWNERS generated
View File

@@ -539,6 +539,8 @@ build.json @home-assistant/supervisor
/tests/components/freebox/ @hacf-fr @Quentame
/homeassistant/components/freedompro/ @stefano055415
/tests/components/freedompro/ @stefano055415
/homeassistant/components/fressnapf_tracker/ @eifinger
/tests/components/fressnapf_tracker/ @eifinger
/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
/homeassistant/components/fritzbox/ @mib1185 @flabbamann

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
import logging
from typing import Any
@@ -174,6 +175,56 @@ class AirobotConfigFlow(BaseConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication dialog."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
# Combine existing data with new password
data = {
CONF_HOST: reauth_entry.data[CONF_HOST],
CONF_USERNAME: reauth_entry.data[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
}
try:
await validate_input(self.hass, data)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
),
description_placeholders={
"username": reauth_entry.data[CONF_USERNAME],
"host": reauth_entry.data[CONF_HOST],
},
errors=errors,
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""

View File

@@ -11,6 +11,7 @@ from pyairobotrest.exceptions import AirobotAuthError, AirobotConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -53,7 +54,15 @@ class AirobotDataUpdateCoordinator(DataUpdateCoordinator[AirobotData]):
try:
status = await self.client.get_statuses()
settings = await self.client.get_settings()
except (AirobotAuthError, AirobotConnectionError) as err:
raise UpdateFailed(f"Failed to communicate with device: {err}") from err
except AirobotAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_failed",
) from err
except AirobotConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="connection_failed",
) from err
return AirobotData(status=status, settings=settings)

View File

@@ -12,6 +12,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyairobotrest"],
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["pyairobotrest==0.1.0"]
}

View File

@@ -34,7 +34,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold

View File

@@ -1,7 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -14,15 +15,24 @@
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "The thermostat password."
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
},
"description": "Airobot thermostat {device_id} discovered at {host}. Enter the password to complete setup. Find the password in the thermostat settings menu under Connectivity → Mobile app."
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
},
"description": "The authentication for Airobot thermostat at {host} (Device ID: {username}) has expired. Please enter the password to reauthenticate. Find the password in the thermostat settings menu under Connectivity → Mobile app."
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
"username": "Device ID"
},
"data_description": {
"host": "The hostname or IP address of your Airobot thermostat.",
@@ -34,6 +44,12 @@
}
},
"exceptions": {
"authentication_failed": {
"message": "Authentication failed, please reauthenticate."
},
"connection_failed": {
"message": "Failed to communicate with device."
},
"set_preset_mode_failed": {
"message": "Failed to set preset mode to {preset_mode}."
},

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["zeroconf"],
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyatv", "srptools"],
"requirements": ["pyatv==0.16.1;python_version<'3.14'"],

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==42.9.0",
"aioesphomeapi==42.10.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0"
],

View File

@@ -0,0 +1,49 @@
"""The Fressnapf Tracker integration."""
from fressnapftracker import AuthClient
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_USER_ID
from .coordinator import (
FressnapfTrackerConfigEntry,
FressnapfTrackerDataUpdateCoordinator,
)
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
async def async_setup_entry(
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
) -> bool:
"""Set up Fressnapf Tracker from a config entry."""
auth_client = AuthClient(client=get_async_client(hass))
devices = await auth_client.get_devices(
user_id=entry.data[CONF_USER_ID],
user_access_token=entry.data[CONF_ACCESS_TOKEN],
)
coordinators: list[FressnapfTrackerDataUpdateCoordinator] = []
for device in devices:
coordinator = FressnapfTrackerDataUpdateCoordinator(
hass,
entry,
device,
)
await coordinator.async_config_entry_first_refresh()
coordinators.append(coordinator)
entry.runtime_data = coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,193 @@
"""Config flow for the Fressnapf Tracker integration."""
import logging
from typing import Any
from fressnapftracker import (
AuthClient,
FressnapfTrackerInvalidPhoneNumberError,
FressnapfTrackerInvalidTokenError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_PHONE_NUMBER, CONF_SMS_CODE, CONF_USER_ID, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PHONE_NUMBER): str,
}
)
STEP_SMS_CODE_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_SMS_CODE): int,
}
)
class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Fressnapf Tracker."""
VERSION = 1
def __init__(self) -> None:
"""Init Config Flow."""
self._context: dict[str, Any] = {}
self._auth_client: AuthClient | None = None
@property
def auth_client(self) -> AuthClient:
"""Return the auth client, creating it if needed."""
if self._auth_client is None:
self._auth_client = AuthClient(client=get_async_client(self.hass))
return self._auth_client
async def _async_request_sms_code(
self, phone_number: str
) -> tuple[dict[str, str], bool]:
"""Request SMS code and return errors dict and success flag."""
errors: dict[str, str] = {}
try:
response = await self.auth_client.request_sms_code(
phone_number=phone_number
)
except FressnapfTrackerInvalidPhoneNumberError:
errors["base"] = "invalid_phone_number"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
_LOGGER.debug("SMS code request response: %s", response)
self._context[CONF_USER_ID] = response.id
self._context[CONF_PHONE_NUMBER] = phone_number
return errors, True
return errors, False
async def _async_verify_sms_code(
self, sms_code: int
) -> tuple[dict[str, str], str | None]:
"""Verify SMS code and return errors and access_token."""
errors: dict[str, str] = {}
try:
verification_response = await self.auth_client.verify_phone_number(
user_id=self._context[CONF_USER_ID],
sms_code=sms_code,
)
except FressnapfTrackerInvalidTokenError:
errors["base"] = "invalid_sms_code"
except Exception:
_LOGGER.exception("Unexpected exception during SMS code verification")
errors["base"] = "unknown"
else:
_LOGGER.debug(
"Phone number verification response: %s", verification_response
)
return errors, verification_response.user_token.access_token
return errors, None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(
{CONF_PHONE_NUMBER: user_input[CONF_PHONE_NUMBER]}
)
errors, success = await self._async_request_sms_code(
user_input[CONF_PHONE_NUMBER]
)
if success:
await self.async_set_unique_id(str(self._context[CONF_USER_ID]))
self._abort_if_unique_id_configured()
return await self.async_step_sms_code()
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_sms_code(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the SMS code step."""
errors: dict[str, str] = {}
if user_input is not None:
errors, access_token = await self._async_verify_sms_code(
user_input[CONF_SMS_CODE]
)
if access_token:
return self.async_create_entry(
title=self._context[CONF_PHONE_NUMBER],
data={
CONF_PHONE_NUMBER: self._context[CONF_PHONE_NUMBER],
CONF_USER_ID: self._context[CONF_USER_ID],
CONF_ACCESS_TOKEN: access_token,
},
)
return self.async_show_form(
step_id="sms_code",
data_schema=STEP_SMS_CODE_DATA_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
errors, success = await self._async_request_sms_code(
user_input[CONF_PHONE_NUMBER]
)
if success:
if reconfigure_entry.data[CONF_USER_ID] != self._context[CONF_USER_ID]:
errors["base"] = "account_change_not_allowed"
else:
return await self.async_step_reconfigure_sms_code()
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(
{
vol.Required(
CONF_PHONE_NUMBER,
default=reconfigure_entry.data.get(CONF_PHONE_NUMBER),
): str,
}
),
errors=errors,
)
async def async_step_reconfigure_sms_code(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the SMS code step during reconfiguration."""
errors: dict[str, str] = {}
if user_input is not None:
errors, access_token = await self._async_verify_sms_code(
user_input[CONF_SMS_CODE]
)
if access_token:
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data={
CONF_PHONE_NUMBER: self._context[CONF_PHONE_NUMBER],
CONF_USER_ID: self._context[CONF_USER_ID],
CONF_ACCESS_TOKEN: access_token,
},
)
return self.async_show_form(
step_id="reconfigure_sms_code",
data_schema=STEP_SMS_CODE_DATA_SCHEMA,
errors=errors,
)

View File

@@ -0,0 +1,6 @@
"""Constants for the Fressnapf Tracker integration."""
DOMAIN = "fressnapf_tracker"
CONF_PHONE_NUMBER = "phone_number"
CONF_SMS_CODE = "sms_code"
CONF_USER_ID = "user_id"

View File

@@ -0,0 +1,50 @@
"""Data update coordinator for Fressnapf Tracker integration."""
from datetime import timedelta
import logging
from fressnapftracker import ApiClient, Device, FressnapfTrackerError, Tracker
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type FressnapfTrackerConfigEntry = ConfigEntry[
list[FressnapfTrackerDataUpdateCoordinator]
]
class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
"""Class to manage fetching data from the API."""
def __init__(
self,
hass: HomeAssistant,
config_entry: FressnapfTrackerConfigEntry,
device: Device,
) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=15),
config_entry=config_entry,
)
self.device = device
self.client = ApiClient(
serial_number=device.serialnumber,
device_token=device.token,
client=get_async_client(hass),
)
async def _async_update_data(self) -> Tracker:
try:
return await self.client.get_tracker()
except FressnapfTrackerError as exception:
raise UpdateFailed(exception) from exception

View File

@@ -0,0 +1,69 @@
"""Device tracker platform for fressnapf_tracker."""
from homeassistant.components.device_tracker import SourceType
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FressnapfTrackerConfigEntry, FressnapfTrackerDataUpdateCoordinator
from .entity import FressnapfTrackerBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: FressnapfTrackerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the fressnapf_tracker device_trackers."""
async_add_entities(
FressnapfTrackerDeviceTracker(coordinator) for coordinator in entry.runtime_data
)
class FressnapfTrackerDeviceTracker(FressnapfTrackerBaseEntity, TrackerEntity):
"""fressnapf_tracker device tracker."""
_attr_name = None
_attr_translation_key = "pet"
def __init__(
self,
coordinator: FressnapfTrackerDataUpdateCoordinator,
) -> None:
"""Initialize the device tracker."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.device.serialnumber
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.data.position is not None
@property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
if self.coordinator.data.position is not None:
return self.coordinator.data.position.lat
return None
@property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
if self.coordinator.data.position is not None:
return self.coordinator.data.position.lng
return None
@property
def source_type(self) -> SourceType:
"""Return the source type, eg gps or router, of the device."""
return SourceType.GPS
@property
def location_accuracy(self) -> float:
"""Return the location accuracy of the device.
Value in meters.
"""
if self.coordinator.data.position is not None:
return float(self.coordinator.data.position.accuracy)
return 0

View File

@@ -0,0 +1,27 @@
"""fressnapf_tracker class."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FressnapfTrackerDataUpdateCoordinator
from .const import DOMAIN
class FressnapfTrackerBaseEntity(
CoordinatorEntity[FressnapfTrackerDataUpdateCoordinator]
):
"""Base entity for Fressnapf Tracker."""
_attr_has_entity_name = True
def __init__(self, coordinator: FressnapfTrackerDataUpdateCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.id = coordinator.device.serialnumber
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(self.id))},
name=str(self.coordinator.data.name),
model=str(self.coordinator.data.tracker_settings.generation),
manufacturer="Fressnapf",
serial_number=str(self.id),
)

View File

@@ -0,0 +1,9 @@
{
"entity": {
"device_tracker": {
"pet": {
"default": "mdi:paw"
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"domain": "fressnapf_tracker",
"name": "Fressnapf Tracker",
"codeowners": ["@eifinger"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fressnapf_tracker",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["fressnapftracker==0.1.2"]
}

View File

@@ -0,0 +1,68 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
No custom actions are defined.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
No custom actions are defined.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: todo
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations:
status: exempt
comment: No entities to translate
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,49 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"account_change_not_allowed": "Reconfiguring to a different account is not allowed. Please create a new entry instead.",
"invalid_phone_number": "Please enter a valid phone number.",
"invalid_sms_code": "The SMS code you entered is invalid.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reconfigure": {
"data": {
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data::phone_number%]"
},
"data_description": {
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data_description::phone_number%]"
},
"description": "Re-authenticate with your Fressnapf Tracker account to refresh your credentials."
},
"reconfigure_sms_code": {
"data": {
"sms_code": "[%key:component::fressnapf_tracker::config::step::sms_code::data::sms_code%]"
},
"data_description": {
"sms_code": "[%key:component::fressnapf_tracker::config::step::sms_code::data_description::sms_code%]"
}
},
"sms_code": {
"data": {
"sms_code": "SMS code"
},
"data_description": {
"sms_code": "Enter the SMS code you received on your phone."
}
},
"user": {
"data": {
"phone_number": "Phone number"
},
"data_description": {
"phone_number": "Enter your phone number in international format (e.g., +4917612345678)."
}
}
}
}
}

View File

@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251127.0"]
"requirements": ["home-assistant-frontend==20251201.0"]
}

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
from functools import wraps
from typing import Any
@@ -15,7 +16,9 @@ from homeassistant.helpers import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util.hass_dict import HassKey
DATA_STORAGE: HassKey[dict[str, UserStore]] = HassKey("frontend_storage")
DATA_STORAGE: HassKey[dict[str, asyncio.Future[UserStore]]] = HassKey(
"frontend_storage"
)
DATA_SYSTEM_STORAGE: HassKey[SystemStore] = HassKey("frontend_system_storage")
STORAGE_VERSION_USER_DATA = 1
STORAGE_VERSION_SYSTEM_DATA = 1
@@ -34,11 +37,18 @@ async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore:
"""Access a user store."""
stores = hass.data.setdefault(DATA_STORAGE, {})
if (store := stores.get(user_id)) is None:
store = stores[user_id] = UserStore(hass, user_id)
await store.async_load()
if (future := stores.get(user_id)) is None:
future = stores[user_id] = hass.loop.create_future()
store = UserStore(hass, user_id)
try:
await store.async_load()
except BaseException as ex:
del stores[user_id]
future.set_exception(ex)
raise
future.set_result(store)
return store
return await future
class UserStore:

View File

@@ -19,6 +19,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/home_connect",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"quality_scale": "platinum",

View File

@@ -33,13 +33,14 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
from .const import OTBR_DOMAIN, Z2M_EMBER_DOCS_URL, ZHA_DOMAIN
from .const import DOMAIN, OTBR_DOMAIN, Z2M_EMBER_DOCS_URL, ZHA_DOMAIN
from .util import (
ApplicationType,
FirmwareInfo,
OwningAddon,
OwningIntegration,
ResetTarget,
async_firmware_flashing_context,
async_flash_silabs_firmware,
get_otbr_addon_manager,
guess_firmware_info,
@@ -228,83 +229,95 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# Keep track of the firmware we're working with, for error messages
self.installing_firmware_name = firmware_name
# Installing new firmware is only truly required if the wrong type is
# installed: upgrading to the latest release of the current firmware type
# isn't strictly necessary for functionality.
self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
)
firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
)
session = async_get_clientsession(self.hass)
client = FirmwareUpdateClient(fw_update_url, session)
try:
manifest = await client.async_update_data()
fw_manifest = next(
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
# For the duration of firmware flashing, hint to other integrations (i.e. ZHA)
# that the hardware is in use and should not be accessed. This is separate from
# locking the serial port itself, since a momentary release of the port may
# still allow for ZHA to reclaim the device.
async with async_firmware_flashing_context(self.hass, self._device, DOMAIN):
# Installing new firmware is only truly required if the wrong type is
# installed: upgrading to the latest release of the current firmware type
# isn't strictly necessary for functionality.
self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
)
except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err:
_LOGGER.warning("Failed to fetch firmware update manifest", exc_info=True)
# Not having internet access should not prevent setup
if not firmware_install_required:
_LOGGER.debug("Skipping firmware upgrade due to index download failure")
return
firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type
!= expected_installed_firmware_type
)
raise AbortFlow(
reason="fw_download_failed",
description_placeholders=self._get_translation_placeholders(),
) from err
session = async_get_clientsession(self.hass)
client = FirmwareUpdateClient(fw_update_url, session)
if not firmware_install_required:
assert self._probed_firmware_info is not None
# Make sure we do not downgrade the firmware
fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
fw_version = fw_metadata.get_public_version()
probed_fw_version = Version(self._probed_firmware_info.firmware_version)
if probed_fw_version >= fw_version:
_LOGGER.debug(
"Not downgrading firmware, installed %s is newer than available %s",
probed_fw_version,
fw_version,
try:
manifest = await client.async_update_data()
fw_manifest = next(
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
)
except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err:
_LOGGER.warning(
"Failed to fetch firmware update manifest", exc_info=True
)
return
try:
fw_data = await client.async_fetch_firmware(fw_manifest)
except (TimeoutError, ClientError, ValueError) as err:
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
# Not having internet access should not prevent setup
if not firmware_install_required:
_LOGGER.debug(
"Skipping firmware upgrade due to index download failure"
)
return
raise AbortFlow(
reason="fw_download_failed",
description_placeholders=self._get_translation_placeholders(),
) from err
# If we cannot download new firmware, we shouldn't block setup
if not firmware_install_required:
_LOGGER.debug("Skipping firmware upgrade due to image download failure")
return
assert self._probed_firmware_info is not None
# Otherwise, fail
raise AbortFlow(
reason="fw_download_failed",
description_placeholders=self._get_translation_placeholders(),
) from err
# Make sure we do not downgrade the firmware
fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
fw_version = fw_metadata.get_public_version()
probed_fw_version = Version(self._probed_firmware_info.firmware_version)
self._probed_firmware_info = await async_flash_silabs_firmware(
hass=self.hass,
device=self._device,
fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=lambda offset, total: self.async_update_progress(
offset / total
),
)
if probed_fw_version >= fw_version:
_LOGGER.debug(
"Not downgrading firmware, installed %s is newer than available %s",
probed_fw_version,
fw_version,
)
return
try:
fw_data = await client.async_fetch_firmware(fw_manifest)
except (TimeoutError, ClientError, ValueError) as err:
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
# If we cannot download new firmware, we shouldn't block setup
if not firmware_install_required:
_LOGGER.debug(
"Skipping firmware upgrade due to image download failure"
)
return
# Otherwise, fail
raise AbortFlow(
reason="fw_download_failed",
description_placeholders=self._get_translation_placeholders(),
) from err
self._probed_firmware_info = await async_flash_silabs_firmware(
hass=self.hass,
device=self._device,
fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=lambda offset, total: self.async_update_progress(
offset / total
),
)
async def _configure_and_start_otbr_addon(self) -> None:
"""Configure and start the OTBR addon."""

View File

@@ -26,6 +26,7 @@ from .util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
async_firmware_flashing_context,
async_flash_silabs_firmware,
)
@@ -274,16 +275,18 @@ class BaseFirmwareUpdateEntity(
)
try:
firmware_info = await async_flash_silabs_firmware(
hass=self.hass,
device=self._current_device,
fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=self._update_progress,
domain=self._config_entry.domain,
)
async with async_firmware_flashing_context(
self.hass, self._current_device, self._config_entry.domain
):
firmware_info = await async_flash_silabs_firmware(
hass=self.hass,
device=self._current_device,
fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=self._update_progress,
)
finally:
self._attr_in_progress = False
self.async_write_ha_state()

View File

@@ -26,7 +26,6 @@ from homeassistant.helpers.singleton import singleton
from . import DATA_COMPONENT
from .const import (
DOMAIN,
OTBR_ADDON_MANAGER_DATA,
OTBR_ADDON_NAME,
OTBR_ADDON_SLUG,
@@ -366,6 +365,22 @@ async def probe_silabs_firmware_type(
return fw_info.firmware_type
@asynccontextmanager
async def async_firmware_flashing_context(
hass: HomeAssistant, device: str, source_domain: str
) -> AsyncIterator[None]:
"""Register a device as having its firmware being actively interacted with."""
async with async_firmware_update_context(hass, device, source_domain):
firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Guessed firmware info before update: %s", firmware_info)
async with AsyncExitStack() as stack:
for owner in firmware_info.owners:
await stack.enter_async_context(owner.temporarily_stop(hass))
yield
async def async_flash_silabs_firmware(
hass: HomeAssistant,
device: str,
@@ -374,10 +389,11 @@ async def async_flash_silabs_firmware(
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
progress_callback: Callable[[int, int], None] | None = None,
*,
domain: str = DOMAIN,
) -> FirmwareInfo:
"""Flash firmware to the SiLabs device."""
"""Flash firmware to the SiLabs device.
This function is meant to be used within a firmware update context.
"""
if not any(
method == expected_installed_firmware_type
for method, _ in application_probe_methods
@@ -387,54 +403,44 @@ async def async_flash_silabs_firmware(
f" not in application probe methods {application_probe_methods!r}"
)
async with async_firmware_update_context(hass, device, domain):
firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info)
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
flasher = Flasher(
device=device,
probe_methods=tuple(
(m.as_flasher_application_type(), baudrate)
for m, baudrate in application_probe_methods
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
)
flasher = Flasher(
device=device,
probe_methods=tuple(
(m.as_flasher_application_type(), baudrate)
for m, baudrate in application_probe_methods
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
)
try:
# Enter the bootloader with indeterminate progress
await flasher.enter_bootloader()
async with AsyncExitStack() as stack:
for owner in firmware_info.owners:
await stack.enter_async_context(owner.temporarily_stop(hass))
# Flash the firmware, with progress
await flasher.flash_firmware(fw_image, progress_callback=progress_callback)
except PermissionError as err:
raise HomeAssistantError(
"Failed to flash firmware: Device is used by another application"
) from err
except Exception as err:
raise HomeAssistantError("Failed to flash firmware") from err
try:
# Enter the bootloader with indeterminate progress
await flasher.enter_bootloader()
probed_firmware_info = await probe_silabs_firmware_info(
device,
bootloader_reset_methods=bootloader_reset_methods,
# Only probe for the expected installed firmware type
application_probe_methods=[
(method, baudrate)
for method, baudrate in application_probe_methods
if method == expected_installed_firmware_type
],
)
# Flash the firmware, with progress
await flasher.flash_firmware(
fw_image, progress_callback=progress_callback
)
except PermissionError as err:
raise HomeAssistantError(
"Failed to flash firmware: Device is used by another application"
) from err
except Exception as err:
raise HomeAssistantError("Failed to flash firmware") from err
if probed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
probed_firmware_info = await probe_silabs_firmware_info(
device,
bootloader_reset_methods=bootloader_reset_methods,
# Only probe for the expected installed firmware type
application_probe_methods=[
(method, baudrate)
for method, baudrate in application_probe_methods
if method == expected_installed_firmware_type
],
)
if probed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
return probed_firmware_info
return probed_firmware_info

View File

@@ -12,6 +12,7 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters", "zeroconf"],
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"],
"requirements": ["aiohomekit==3.2.20"],

View File

@@ -9,6 +9,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/homewizard",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",

View File

@@ -9,6 +9,7 @@ post:
required: true
selector:
text:
multiline: true
visibility:
selector:
select:

View File

@@ -6,6 +6,7 @@
"config_flow": true,
"dependencies": ["websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/matter",
"integration_type": "hub",
"iot_class": "local_push",
"requirements": ["python-matter-server==8.1.0"],
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]

View File

@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
"requirements": ["yt-dlp[default]==2025.10.22"],
"requirements": ["yt-dlp[default]==2025.11.12"],
"single_config_entry": true
}

View File

@@ -174,14 +174,14 @@ class ProgramPhaseWashingMachine(MieleEnum, missing_to_none=True):
not_running = 0, 256, 65535
pre_wash = 257, 259
soak = 258
main_wash = 260
rinse = 261
main_wash = 260, 11004
rinse = 261, 11005
rinse_hold = 262
cleaning = 263
cooling_down = 264
drain = 265
spin = 266
anti_crease = 267
spin = 266, 11010
anti_crease = 267, 11029
finished = 268
venting = 269
starch_stop = 270
@@ -483,6 +483,7 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True):
cottons_eco = 133
quick_power_wash = 146
eco_40_60 = 190
normal = 10001
class DishWasherProgramId(MieleEnum, missing_to_none=True):

View File

@@ -10,6 +10,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/nuki",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pynuki"],
"requirements": ["pynuki==1.6.3"]

View File

@@ -1,5 +1,16 @@
"""Constants for the onboarding component."""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class DefaultArea:
"""Default area definition."""
key: str
icon: str
DOMAIN = "onboarding"
STEP_USER = "user"
STEP_CORE_CONFIG = "core_config"
@@ -8,4 +19,8 @@ STEP_ANALYTICS = "analytics"
STEPS = [STEP_USER, STEP_CORE_CONFIG, STEP_ANALYTICS, STEP_INTEGRATION]
DEFAULT_AREAS = ("living_room", "kitchen", "bedroom")
DEFAULT_AREAS = (
DefaultArea(key="living_room", icon="mdi:sofa"),
DefaultArea(key="kitchen", icon="mdi:stove"),
DefaultArea(key="bedroom", icon="mdi:bed"),
)

View File

@@ -208,11 +208,11 @@ class UserOnboardingView(_BaseOnboardingStepView):
area_registry = ar.async_get(hass)
for area in DEFAULT_AREAS:
name = translations[f"component.onboarding.area.{area}"]
name = translations[f"component.onboarding.area.{area.key}"]
# Guard because area might have been created by an automatically
# set up integration.
if not area_registry.async_get_area_by_name(name):
area_registry.async_create(name)
area_registry.async_create(name, icon=area.icon)
await self._async_mark_done(hass)

View File

@@ -16,6 +16,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/reolink",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",

View File

@@ -14,6 +14,7 @@ from .coordinator import LeilSaunaCoordinator
PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.LIGHT,
Platform.SENSOR,
]
type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator]

View File

@@ -0,0 +1,9 @@
{
"entity": {
"sensor": {
"heater_elements_active": {
"default": "mdi:radiator"
}
}
}
}

View File

@@ -60,10 +60,10 @@ rules:
comment: Integration controls a single device; no dynamic device discovery needed.
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: todo
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: exempt
@@ -74,5 +74,7 @@ rules:
# Platinum
async-dependency: todo
inject-websession: todo
inject-websession:
status: exempt
comment: Integration uses Modbus TCP protocol and does not make HTTP requests.
strict-typing: todo

View File

@@ -0,0 +1,99 @@
"""Sensor platform for Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from pysaunum import SaunumData
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LeilSaunaConfigEntry
from .entity import LeilSaunaEntity
if TYPE_CHECKING:
from .coordinator import LeilSaunaCoordinator
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class LeilSaunaSensorEntityDescription(SensorEntityDescription):
"""Describes Leil Sauna sensor entity."""
value_fn: Callable[[SaunumData], float | int | None]
SENSORS: tuple[LeilSaunaSensorEntityDescription, ...] = (
LeilSaunaSensorEntityDescription(
key="current_temperature",
translation_key="current_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.current_temperature,
),
LeilSaunaSensorEntityDescription(
key="heater_elements_active",
translation_key="heater_elements_active",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.heater_elements_active,
),
LeilSaunaSensorEntityDescription(
key="on_time",
translation_key="on_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
device_class=SensorDeviceClass.DURATION,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda data: data.on_time,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: LeilSaunaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Saunum Leil Sauna sensors from a config entry."""
coordinator = entry.runtime_data
async_add_entities(
LeilSaunaSensorEntity(coordinator, description)
for description in SENSORS
if description.value_fn(coordinator.data) is not None
)
class LeilSaunaSensorEntity(LeilSaunaEntity, SensorEntity):
"""Representation of a Saunum Leil Sauna sensor."""
entity_description: LeilSaunaSensorEntityDescription
def __init__(
self,
coordinator: LeilSaunaCoordinator,
description: LeilSaunaSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{description.key}"
self.entity_description = description
@property
def native_value(self) -> float | int | None:
"""Return the value reported by the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -34,6 +34,15 @@
"light": {
"name": "[%key:component::light::title%]"
}
},
"sensor": {
"heater_elements_active": {
"name": "Heater elements active",
"unit_of_measurement": "heater elements"
},
"on_time": {
"name": "Total time turned on"
}
}
},
"exceptions": {

View File

@@ -17,7 +17,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "platinum",
"requirements": ["aioshelly==13.21.0"],
"requirements": ["aioshelly==13.22.0"],
"zeroconf": [
{
"name": "shelly*",

View File

@@ -6,6 +6,7 @@
"config_flow": true,
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/sonos",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["soco", "sonos_websocket"],
"quality_scale": "bronze",

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Coroutine
from functools import partial
import logging
from typing import Any
@@ -133,6 +134,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(
entry, (entry.options["template_type"],)
)
entry.async_on_unload(
async_labs_listen(
hass,
AUTOMATION_DOMAIN,
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG,
partial(hass.config_entries.async_schedule_reload, entry.entry_id),
)
)
return True

View File

@@ -17,6 +17,7 @@ from homeassistant.const import (
CONF_ICON,
CONF_ICON_TEMPLATE,
CONF_NAME,
CONF_PLATFORM,
CONF_STATE,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
@@ -257,6 +258,7 @@ def create_legacy_template_issue(
deprecation_list.append(issue_id)
try:
config.pop(CONF_PLATFORM, None)
modified_yaml = format_migration_config(config)
yaml_config = yaml_util.dump({DOMAIN: [{domain: [modified_yaml]}]})
# Format to show up properly in a numbered bullet on the repair.

View File

@@ -4,6 +4,7 @@
"codeowners": ["@Bre77"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tessie",
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["tessie", "tesla-fleet-api"],
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.5"]

View File

@@ -184,20 +184,20 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
"""Last change triggered by."""
if self._changed_by_wrapper is None:
return None
return self._changed_by_wrapper.read_device_status(self.device)
return self._read_wrapper(self._changed_by_wrapper)
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send Disarm command."""
await self._async_send_dpcode_update(self._mode_wrapper, "disarm")
await self._async_send_wrapper_updates(self._mode_wrapper, "disarm")
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send Home command."""
await self._async_send_dpcode_update(self._mode_wrapper, "arm_home")
await self._async_send_wrapper_updates(self._mode_wrapper, "arm_home")
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send Arm command."""
await self._async_send_dpcode_update(self._mode_wrapper, "arm_away")
await self._async_send_wrapper_updates(self._mode_wrapper, "arm_away")
async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Send SOS command."""
await self._async_send_dpcode_update(self._mode_wrapper, "trigger")
await self._async_send_wrapper_updates(self._mode_wrapper, "trigger")

View File

@@ -372,7 +372,7 @@ class _CustomDPCodeWrapper(DPCodeWrapper):
_valid_values: set[bool | float | int | str]
def __init__(
self, dpcode: DPCode, valid_values: set[bool | float | int | str]
self, dpcode: str, valid_values: set[bool | float | int | str]
) -> None:
"""Init CustomDPCodeBooleanWrapper."""
super().__init__(dpcode)
@@ -390,7 +390,7 @@ def _get_dpcode_wrapper(
description: TuyaBinarySensorEntityDescription,
) -> DPCodeWrapper | None:
"""Get DPCode wrapper for an entity description."""
dpcode = description.dpcode or DPCode(description.key)
dpcode = description.dpcode or description.key
if description.bitmap_key is not None:
return DPCodeBitmapBitWrapper.find_dpcode(
device, dpcode, bitmap_key=description.bitmap_key
@@ -461,4 +461,4 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity):
@property
def is_on(self) -> bool | None:
"""Return true if sensor is on."""
return self._dpcode_wrapper.read_device_status(self.device)
return self._read_wrapper(self._dpcode_wrapper)

View File

@@ -117,4 +117,4 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity):
async def async_press(self) -> None:
"""Press the button."""
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
await self._async_send_wrapper_updates(self._dpcode_wrapper, True)

View File

@@ -118,8 +118,8 @@ class TuyaCameraEntity(TuyaEntity, CameraEntity):
async def async_enable_motion_detection(self) -> None:
"""Enable motion detection in the camera."""
await self._async_send_dpcode_update(self._motion_detection_switch, True)
await self._async_send_wrapper_updates(self._motion_detection_switch, True)
async def async_disable_motion_detection(self) -> None:
"""Disable motion detection in camera."""
await self._async_send_dpcode_update(self._motion_detection_switch, False)
await self._async_send_wrapper_updates(self._motion_detection_switch, False)

View File

@@ -345,14 +345,14 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
"""Set new target hvac mode."""
commands = []
if self._switch_wrapper:
commands.append(
self._switch_wrapper.get_update_command(
commands.extend(
self._switch_wrapper.get_update_commands(
self.device, hvac_mode != HVACMode.OFF
)
)
if self._hvac_mode_wrapper and hvac_mode in self._hvac_to_tuya:
commands.append(
self._hvac_mode_wrapper.get_update_command(
commands.extend(
self._hvac_mode_wrapper.get_update_commands(
self.device, self._hvac_to_tuya[hvac_mode]
)
)
@@ -360,34 +360,34 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target preset mode."""
await self._async_send_dpcode_update(self._hvac_mode_wrapper, preset_mode)
await self._async_send_wrapper_updates(self._hvac_mode_wrapper, preset_mode)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
await self._async_send_dpcode_update(self._fan_mode_wrapper, fan_mode)
await self._async_send_wrapper_updates(self._fan_mode_wrapper, fan_mode)
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
await self._async_send_dpcode_update(self._target_humidity_wrapper, humidity)
await self._async_send_wrapper_updates(self._target_humidity_wrapper, humidity)
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new target swing operation."""
commands = []
if self._swing_wrapper:
commands.append(
self._swing_wrapper.get_update_command(
commands.extend(
self._swing_wrapper.get_update_commands(
self.device, swing_mode == SWING_ON
)
)
if self._swing_v_wrapper:
commands.append(
self._swing_v_wrapper.get_update_command(
commands.extend(
self._swing_v_wrapper.get_update_commands(
self.device, swing_mode in (SWING_BOTH, SWING_VERTICAL)
)
)
if self._swing_h_wrapper:
commands.append(
self._swing_h_wrapper.get_update_command(
commands.extend(
self._swing_h_wrapper.get_update_commands(
self.device, swing_mode in (SWING_BOTH, SWING_HORIZONTAL)
)
)
@@ -396,7 +396,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
await self._async_send_dpcode_update(
await self._async_send_wrapper_updates(
self._set_temperature, kwargs[ATTR_TEMPERATURE]
)
@@ -475,8 +475,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
async def async_turn_on(self) -> None:
"""Turn the device on, retaining current HVAC (if supported)."""
await self._async_send_dpcode_update(self._switch_wrapper, True)
await self._async_send_wrapper_updates(self._switch_wrapper, True)
async def async_turn_off(self) -> None:
"""Turn the device on, retaining current HVAC (if supported)."""
await self._async_send_dpcode_update(self._switch_wrapper, False)
await self._async_send_wrapper_updates(self._switch_wrapper, False)

View File

@@ -421,7 +421,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
if self._set_position is not None:
await self._async_send_commands(
[self._set_position.get_update_command(self.device, 100)]
self._set_position.get_update_commands(self.device, 100)
)
async def async_close_cover(self, **kwargs: Any) -> None:
@@ -434,12 +434,14 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
if self._set_position is not None:
await self._async_send_commands(
[self._set_position.get_update_command(self.device, 0)]
self._set_position.get_update_commands(self.device, 0)
)
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
await self._async_send_dpcode_update(self._set_position, kwargs[ATTR_POSITION])
await self._async_send_wrapper_updates(
self._set_position, kwargs[ATTR_POSITION]
)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
@@ -450,6 +452,6 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
await self._async_send_dpcode_update(
await self._async_send_wrapper_updates(
self._tilt_position, kwargs[ATTR_TILT_POSITION]
)

View File

@@ -76,7 +76,7 @@ class TuyaEntity(Entity):
return None
return dpcode_wrapper.read_device_status(self.device)
async def _async_send_dpcode_update(
async def _async_send_wrapper_updates(
self, dpcode_wrapper: DPCodeWrapper | None, value: Any
) -> None:
"""Send command to the device."""
@@ -84,5 +84,5 @@ class TuyaEntity(Entity):
return
await self.hass.async_add_executor_job(
self._send_command,
[dpcode_wrapper.get_update_command(self.device, value)],
dpcode_wrapper.get_update_commands(self.device, value),
)

View File

@@ -209,19 +209,19 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
await self._async_send_dpcode_update(self._mode_wrapper, preset_mode)
await self._async_send_wrapper_updates(self._mode_wrapper, preset_mode)
async def async_set_direction(self, direction: str) -> None:
"""Set the direction of the fan."""
await self._async_send_dpcode_update(self._direction_wrapper, direction)
await self._async_send_wrapper_updates(self._direction_wrapper, direction)
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
await self._async_send_dpcode_update(self._speed_wrapper, percentage)
await self._async_send_wrapper_updates(self._speed_wrapper, percentage)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
await self._async_send_dpcode_update(self._switch_wrapper, False)
await self._async_send_wrapper_updates(self._switch_wrapper, False)
async def async_turn_on(
self,
@@ -233,24 +233,22 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
if self._switch_wrapper is None:
return
commands: list[dict[str, str | bool | int]] = [
self._switch_wrapper.get_update_command(self.device, True)
]
commands = self._switch_wrapper.get_update_commands(self.device, True)
if percentage is not None and self._speed_wrapper is not None:
commands.append(
self._speed_wrapper.get_update_command(self.device, percentage)
commands.extend(
self._speed_wrapper.get_update_commands(self.device, percentage)
)
if preset_mode is not None and self._mode_wrapper:
commands.append(
self._mode_wrapper.get_update_command(self.device, preset_mode)
commands.extend(
self._mode_wrapper.get_update_commands(self.device, preset_mode)
)
await self._async_send_commands(commands)
async def async_oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan."""
await self._async_send_dpcode_update(self._oscillate_wrapper, oscillating)
await self._async_send_wrapper_updates(self._oscillate_wrapper, oscillating)
@property
def is_on(self) -> bool | None:

View File

@@ -49,9 +49,9 @@ def _has_a_valid_dpcode(
device: CustomerDevice, description: TuyaHumidifierEntityDescription
) -> bool:
"""Check if the device has at least one valid DP code."""
properties_to_check: list[DPCode | tuple[DPCode, ...] | None] = [
properties_to_check: list[str | tuple[str, ...] | None] = [
# Main control switch
description.dpcode or DPCode(description.key),
description.dpcode or description.key,
# Other humidity properties
description.current_humidity,
description.humidity,
@@ -107,7 +107,7 @@ async def async_setup_entry(
),
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
device,
description.dpcode or DPCode(description.key),
description.dpcode or description.key,
prefer_function=True,
),
target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
@@ -192,7 +192,7 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
self.device,
self.entity_description.dpcode or self.entity_description.key,
)
await self._async_send_dpcode_update(self._switch_wrapper, True)
await self._async_send_wrapper_updates(self._switch_wrapper, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
@@ -201,7 +201,7 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
self.device,
self.entity_description.dpcode or self.entity_description.key,
)
await self._async_send_dpcode_update(self._switch_wrapper, False)
await self._async_send_wrapper_updates(self._switch_wrapper, False)
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
@@ -210,8 +210,8 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
self.device,
self.entity_description.humidity,
)
await self._async_send_dpcode_update(self._target_humidity_wrapper, humidity)
await self._async_send_wrapper_updates(self._target_humidity_wrapper, humidity)
async def async_set_mode(self, mode: str) -> None:
"""Set new target preset mode."""
await self._async_send_dpcode_update(self._mode_wrapper, mode)
await self._async_send_wrapper_updates(self._mode_wrapper, mode)

View File

@@ -720,25 +720,23 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
def turn_on(self, **kwargs: Any) -> None:
"""Turn on or control the light."""
commands = [
self._switch_wrapper.get_update_command(self.device, True),
]
commands = self._switch_wrapper.get_update_commands(self.device, True)
if self._color_mode_wrapper and (
ATTR_WHITE in kwargs or ATTR_COLOR_TEMP_KELVIN in kwargs
):
commands += [
self._color_mode_wrapper.get_update_command(
commands.extend(
self._color_mode_wrapper.get_update_commands(
self.device, WorkMode.WHITE
),
]
)
if self._color_temp_wrapper and ATTR_COLOR_TEMP_KELVIN in kwargs:
commands += [
self._color_temp_wrapper.get_update_command(
commands.extend(
self._color_temp_wrapper.get_update_commands(
self.device, kwargs[ATTR_COLOR_TEMP_KELVIN]
)
]
)
if self._color_data_wrapper and (
ATTR_HS_COLOR in kwargs
@@ -750,11 +748,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
)
):
if self._color_mode_wrapper:
commands += [
self._color_mode_wrapper.get_update_command(
commands.extend(
self._color_mode_wrapper.get_update_commands(
self.device, WorkMode.COLOUR
),
]
)
if not (brightness := kwargs.get(ATTR_BRIGHTNESS)):
brightness = self.brightness or 0
@@ -762,11 +760,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
if not (color := kwargs.get(ATTR_HS_COLOR)):
color = self.hs_color or (0, 0)
commands += [
self._color_data_wrapper.get_update_command(
commands.extend(
self._color_data_wrapper.get_update_commands(
self.device, (color, brightness)
),
]
)
elif self._brightness_wrapper and (
ATTR_BRIGHTNESS in kwargs or ATTR_WHITE in kwargs
@@ -776,15 +774,15 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
else:
brightness = kwargs[ATTR_WHITE]
commands += [
self._brightness_wrapper.get_update_command(self.device, brightness),
]
commands.extend(
self._brightness_wrapper.get_update_commands(self.device, brightness),
)
self._send_command(commands)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
await self._async_send_dpcode_update(self._switch_wrapper, False)
await self._async_send_wrapper_updates(self._switch_wrapper, False)
@property
def brightness(self) -> int | None:

View File

@@ -10,7 +10,7 @@ from tuya_sharing import CustomerDevice
from homeassistant.util.json import json_loads, json_loads_object
from .const import LOGGER, DPCode, DPType
from .const import LOGGER, DPType
from .util import parse_dptype, remap_value
# Dictionary to track logged warnings to avoid spamming logs
@@ -39,11 +39,11 @@ class TypeInformation:
As provided by the SDK, from `device.function` / `device.status_range`.
"""
dpcode: DPCode
dpcode: str
type_data: str | None = None
@classmethod
def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None:
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
"""Load JSON string and return a TypeInformation object."""
return cls(dpcode=dpcode, type_data=type_data)
@@ -102,7 +102,7 @@ class IntegerTypeData(TypeInformation):
return remap_value(value, from_min, from_max, self.min, self.max, reverse)
@classmethod
def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None:
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
"""Load JSON string and return a IntegerTypeData object."""
if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))):
return None
@@ -125,7 +125,7 @@ class BitmapTypeInformation(TypeInformation):
label: list[str]
@classmethod
def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None:
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
"""Load JSON string and return a BitmapTypeInformation object."""
if not (parsed := json_loads_object(type_data)):
return None
@@ -143,7 +143,7 @@ class EnumTypeData(TypeInformation):
range: list[str]
@classmethod
def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None:
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
"""Load JSON string and return a EnumTypeData object."""
if not (parsed := json_loads_object(type_data)):
return None
@@ -175,7 +175,7 @@ class DPCodeWrapper:
native_unit: str | None = None
suggested_unit: str | None = None
def __init__(self, dpcode: DPCode) -> None:
def __init__(self, dpcode: str) -> None:
"""Init DPCodeWrapper."""
self.dpcode = dpcode
@@ -196,20 +196,24 @@ class DPCodeWrapper:
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert a Home Assistant value back to a raw device value.
This is called by `get_update_command` to prepare the value for sending
This is called by `get_update_commands` to prepare the value for sending
back to the device, and should be implemented in concrete classes if needed.
"""
raise NotImplementedError
def get_update_command(self, device: CustomerDevice, value: Any) -> dict[str, Any]:
"""Get the update command for the dpcode.
def get_update_commands(
self, device: CustomerDevice, value: Any
) -> list[dict[str, Any]]:
"""Get the update commands for the dpcode.
The Home Assistant value is converted back to a raw device value.
"""
return {
"code": self.dpcode,
"value": self._convert_value_to_raw_value(device, value),
}
return [
{
"code": self.dpcode,
"value": self._convert_value_to_raw_value(device, value),
}
]
class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
@@ -218,7 +222,7 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
DPTYPE: DPType
type_information: T
def __init__(self, dpcode: DPCode, type_information: T) -> None:
def __init__(self, dpcode: str, type_information: T) -> None:
"""Init DPCodeWrapper."""
super().__init__(dpcode)
self.type_information = type_information
@@ -227,7 +231,7 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
def find_dpcode(
cls,
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
dpcodes: str | tuple[str, ...] | None,
*,
prefer_function: bool = False,
) -> Self | None:
@@ -336,7 +340,7 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeData]):
DPTYPE = DPType.INTEGER
def __init__(self, dpcode: DPCode, type_information: IntegerTypeData) -> None:
def __init__(self, dpcode: str, type_information: IntegerTypeData) -> None:
"""Init DPCodeIntegerWrapper."""
super().__init__(dpcode, type_information)
self.native_unit = type_information.unit
@@ -376,7 +380,7 @@ class DPCodeStringWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
class DPCodeBitmapBitWrapper(DPCodeWrapper):
"""Simple wrapper for a specific bit in bitmap values."""
def __init__(self, dpcode: DPCode, mask: int) -> None:
def __init__(self, dpcode: str, mask: int) -> None:
"""Init DPCodeBitmapWrapper."""
super().__init__(dpcode)
self._mask = mask
@@ -391,7 +395,7 @@ class DPCodeBitmapBitWrapper(DPCodeWrapper):
def find_dpcode(
cls,
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...],
dpcodes: str | tuple[str, ...],
*,
bitmap_key: str,
) -> Self | None:
@@ -408,7 +412,7 @@ class DPCodeBitmapBitWrapper(DPCodeWrapper):
@overload
def find_dpcode(
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
dpcodes: str | tuple[str, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.BITMAP],
@@ -418,7 +422,7 @@ def find_dpcode(
@overload
def find_dpcode(
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
dpcodes: str | tuple[str, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.ENUM],
@@ -428,7 +432,7 @@ def find_dpcode(
@overload
def find_dpcode(
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
dpcodes: str | tuple[str, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.INTEGER],
@@ -438,7 +442,7 @@ def find_dpcode(
@overload
def find_dpcode(
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
dpcodes: str | tuple[str, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.BOOLEAN, DPType.JSON, DPType.RAW],
@@ -447,7 +451,7 @@ def find_dpcode(
def find_dpcode(
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
dpcodes: str | tuple[str, ...] | None,
*,
prefer_function: bool = False,
dptype: DPType,
@@ -459,9 +463,7 @@ def find_dpcode(
if dpcodes is None:
return None
if isinstance(dpcodes, str):
dpcodes = (DPCode(dpcodes),)
elif not isinstance(dpcodes, tuple):
if not isinstance(dpcodes, tuple):
dpcodes = (dpcodes,)
lookup_tuple = (

View File

@@ -551,8 +551,8 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity):
@property
def native_value(self) -> float | None:
"""Return the entity value to represent the entity state."""
return self._dpcode_wrapper.read_device_status(self.device)
return self._read_wrapper(self._dpcode_wrapper)
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
await self._async_send_dpcode_update(self._dpcode_wrapper, value)
await self._async_send_wrapper_updates(self._dpcode_wrapper, value)

View File

@@ -405,8 +405,8 @@ class TuyaSelectEntity(TuyaEntity, SelectEntity):
@property
def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state."""
return self._dpcode_wrapper.read_device_status(self.device)
return self._read_wrapper(self._dpcode_wrapper)
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self._async_send_dpcode_update(self._dpcode_wrapper, option)
await self._async_send_wrapper_updates(self._dpcode_wrapper, option)

View File

@@ -1851,4 +1851,4 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
@property
def native_value(self) -> StateType:
"""Return the value reported by the sensor."""
return self._dpcode_wrapper.read_device_status(self.device)
return self._read_wrapper(self._dpcode_wrapper)

View File

@@ -105,12 +105,12 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity):
@property
def is_on(self) -> bool | None:
"""Return true if siren is on."""
return self._dpcode_wrapper.read_device_status(self.device)
return self._read_wrapper(self._dpcode_wrapper)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the siren on."""
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
await self._async_send_wrapper_updates(self._dpcode_wrapper, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the siren off."""
await self._async_send_dpcode_update(self._dpcode_wrapper, False)
await self._async_send_wrapper_updates(self._dpcode_wrapper, False)

View File

@@ -1038,12 +1038,12 @@ class TuyaSwitchEntity(TuyaEntity, SwitchEntity):
@property
def is_on(self) -> bool | None:
"""Return true if switch is on."""
return self._dpcode_wrapper.read_device_status(self.device)
return self._read_wrapper(self._dpcode_wrapper)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
await self._async_send_wrapper_updates(self._dpcode_wrapper, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._async_send_dpcode_update(self._dpcode_wrapper, False)
await self._async_send_wrapper_updates(self._dpcode_wrapper, False)

View File

@@ -20,16 +20,14 @@ _DPTYPE_MAPPING: dict[str, DPType] = {
def get_dpcode(
device: CustomerDevice, dpcodes: str | DPCode | tuple[DPCode, ...] | None
) -> DPCode | None:
device: CustomerDevice, dpcodes: str | tuple[str, ...] | None
) -> str | None:
"""Get the first matching DPCode from the device or return None."""
if dpcodes is None:
return None
if isinstance(dpcodes, DPCode):
if not isinstance(dpcodes, tuple):
dpcodes = (dpcodes,)
elif isinstance(dpcodes, str):
dpcodes = (DPCode(dpcodes),)
for dpcode in dpcodes:
if (
@@ -70,19 +68,23 @@ class ActionDPCodeNotFoundError(ServiceValidationError):
"""Custom exception for action DP code not found errors."""
def __init__(
self, device: CustomerDevice, expected: str | DPCode | tuple[DPCode, ...] | None
self, device: CustomerDevice, expected: str | tuple[str, ...] | None
) -> None:
"""Initialize the error with device and expected DP codes."""
if expected is None:
expected = () # empty tuple for no expected codes
elif isinstance(expected, str):
expected = (DPCode(expected),)
elif not isinstance(expected, tuple):
expected = (expected,)
super().__init__(
translation_domain=DOMAIN,
translation_key="action_dpcode_not_found",
translation_placeholders={
"expected": str(sorted([dp.value for dp in expected])),
"expected": str(
sorted(
[dp.value if isinstance(dp, DPCode) else dp for dp in expected]
)
),
"available": str(sorted(device.function.keys())),
},
)

View File

@@ -169,11 +169,11 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
async def async_start(self, **kwargs: Any) -> None:
"""Start the device."""
await self._async_send_dpcode_update(self._switch_wrapper, True)
await self._async_send_wrapper_updates(self._switch_wrapper, True)
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the device."""
await self._async_send_dpcode_update(self._switch_wrapper, False)
await self._async_send_wrapper_updates(self._switch_wrapper, False)
async def async_pause(self, **kwargs: Any) -> None:
"""Pause the device."""
@@ -182,19 +182,19 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Return device to dock."""
if self._charge_wrapper:
await self._async_send_dpcode_update(self._charge_wrapper, True)
await self._async_send_wrapper_updates(self._charge_wrapper, True)
else:
await self._async_send_dpcode_update(
await self._async_send_wrapper_updates(
self._mode_wrapper, TUYA_MODE_RETURN_HOME
)
async def async_locate(self, **kwargs: Any) -> None:
"""Locate the device."""
await self._async_send_dpcode_update(self._locate_wrapper, True)
await self._async_send_wrapper_updates(self._locate_wrapper, True)
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set fan speed."""
await self._async_send_dpcode_update(self._fan_speed_wrapper, fan_speed)
await self._async_send_wrapper_updates(self._fan_speed_wrapper, fan_speed)
def send_command(
self,

View File

@@ -133,14 +133,14 @@ class TuyaValveEntity(TuyaEntity, ValveEntity):
@property
def is_closed(self) -> bool | None:
"""Return if the valve is closed."""
if (is_open := self._dpcode_wrapper.read_device_status(self.device)) is None:
if (is_open := self._read_wrapper(self._dpcode_wrapper)) is None:
return None
return not is_open
async def async_open_valve(self) -> None:
"""Open the valve."""
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
await self._async_send_wrapper_updates(self._dpcode_wrapper, True)
async def async_close_valve(self) -> None:
"""Close the valve."""
await self._async_send_dpcode_update(self._dpcode_wrapper, False)
await self._async_send_wrapper_updates(self._dpcode_wrapper, False)

View File

@@ -6,6 +6,7 @@
"config_flow": true,
"dependencies": ["file_upload", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/zha",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": [
"aiosqlite",

View File

@@ -225,6 +225,7 @@ FLOWS = {
"foscam",
"freebox",
"freedompro",
"fressnapf_tracker",
"fritz",
"fritzbox",
"fritzbox_callmonitor",

View File

@@ -400,13 +400,13 @@
"name": "Apple",
"integrations": {
"apple_tv": {
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push",
"name": "Apple TV"
},
"homekit_controller": {
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push"
},
@@ -2169,6 +2169,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"fressnapf_tracker": {
"name": "Fressnapf Tracker",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"frient": {
"name": "Frient",
"iot_standards": [
@@ -2796,7 +2802,7 @@
},
"homewizard": {
"name": "HomeWizard Energy",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
@@ -6749,7 +6755,7 @@
},
"tessie": {
"name": "Tessie",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "cloud_polling"
},

View File

@@ -40,7 +40,7 @@ EVENT_AREA_REGISTRY_UPDATED: EventType[EventAreaRegistryUpdatedData] = EventType
)
STORAGE_KEY = "core.area_registry"
STORAGE_VERSION_MAJOR = 1
STORAGE_VERSION_MINOR = 8
STORAGE_VERSION_MINOR = 9
class _AreaStoreData(TypedDict):
@@ -157,6 +157,13 @@ class AreaRegistryStore(Store[AreasRegistryStoreData]):
area["humidity_entity_id"] = None
area["temperature_entity_id"] = None
if old_minor_version < 9:
# Version 1.9 sorts the areas by name
old_data["areas"] = sorted(
old_data["areas"],
key=lambda area: area["name"].casefold(),
)
if old_major_version > 1:
raise NotImplementedError
return old_data # type: ignore[return-value]
@@ -509,9 +516,9 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
@callback
def _handle_floor_registry_update(event: fr.EventFloorRegistryUpdated) -> None:
"""Update areas that are associated with a floor that has been removed."""
floor_id = event.data.get("floor_id")
if floor_id is None:
return
if TYPE_CHECKING:
assert event.data["action"] == "remove"
floor_id = event.data["floor_id"]
for area in self.areas.get_areas_for_floor(floor_id):
self.async_update(area.id, floor_id=None)

View File

@@ -7,6 +7,7 @@ from collections.abc import Iterable
import dataclasses
from dataclasses import dataclass
from datetime import datetime
import math
from typing import Any, Literal, TypedDict
from homeassistant.core import Event, HomeAssistant, callback
@@ -30,7 +31,7 @@ EVENT_FLOOR_REGISTRY_UPDATED: EventType[EventFloorRegistryUpdatedData] = EventTy
)
STORAGE_KEY = "core.floor_registry"
STORAGE_VERSION_MAJOR = 1
STORAGE_VERSION_MINOR = 2
STORAGE_VERSION_MINOR = 3
class _FloorStoreData(TypedDict):
@@ -51,13 +52,24 @@ class FloorRegistryStoreData(TypedDict):
floors: list[_FloorStoreData]
class EventFloorRegistryUpdatedData(TypedDict):
class _EventFloorRegistryUpdatedData_Create_Remove_Update(TypedDict):
"""Event data for when the floor registry is updated."""
action: Literal["create", "remove", "update", "reorder"]
floor_id: str | None
action: Literal["create", "remove", "update"]
floor_id: str
class _EventFloorRegistryUpdatedData_Reorder(TypedDict):
"""Event data for when the floor registry is updated."""
action: Literal["reorder"]
type EventFloorRegistryUpdatedData = (
_EventFloorRegistryUpdatedData_Create_Remove_Update
| _EventFloorRegistryUpdatedData_Reorder
)
type EventFloorRegistryUpdated = Event[EventFloorRegistryUpdatedData]
@@ -91,6 +103,16 @@ class FloorRegistryStore(Store[FloorRegistryStoreData]):
for floor in old_data["floors"]:
floor["created_at"] = floor["modified_at"] = created_at
if old_minor_version < 3:
# Version 1.3 sorts the floors by their level attribute, then by name
old_data["floors"] = sorted(
old_data["floors"],
key=lambda floor: (
math.inf if floor["level"] is None else -floor["level"],
floor["name"].casefold(),
),
)
return old_data # type: ignore[return-value]
@@ -200,7 +222,9 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]):
self.hass.bus.async_fire_internal(
EVENT_FLOOR_REGISTRY_UPDATED,
EventFloorRegistryUpdatedData(action="create", floor_id=floor_id),
_EventFloorRegistryUpdatedData_Create_Remove_Update(
action="create", floor_id=floor_id
),
)
return floor
@@ -211,7 +235,7 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]):
del self.floors[floor_id]
self.hass.bus.async_fire_internal(
EVENT_FLOOR_REGISTRY_UPDATED,
EventFloorRegistryUpdatedData(
_EventFloorRegistryUpdatedData_Create_Remove_Update(
action="remove",
floor_id=floor_id,
),
@@ -253,7 +277,7 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]):
self.async_schedule_save()
self.hass.bus.async_fire_internal(
EVENT_FLOOR_REGISTRY_UPDATED,
EventFloorRegistryUpdatedData(
_EventFloorRegistryUpdatedData_Create_Remove_Update(
action="update",
floor_id=floor_id,
),
@@ -280,7 +304,7 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]):
self.async_schedule_save()
self.hass.bus.async_fire_internal(
EVENT_FLOOR_REGISTRY_UPDATED,
EventFloorRegistryUpdatedData(action="reorder", floor_id=None),
_EventFloorRegistryUpdatedData_Reorder(action="reorder"),
)
async def async_load(self) -> None:

View File

@@ -999,7 +999,7 @@ class _ScriptRun:
if supports_response == SupportsResponse.NONE and return_response:
raise vol.Invalid(
f"Script does not support '{CONF_RESPONSE_VARIABLE}' for service "
f"'{CONF_RESPONSE_VARIABLE}' which does not support response data."
f"'{params[CONF_DOMAIN]}.{params[CONF_SERVICE]}' which does not support response data."
)
running_script = (

View File

@@ -39,7 +39,7 @@ habluetooth==5.7.0
hass-nabucasa==1.6.2
hassil==3.4.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20251127.0
home-assistant-frontend==20251201.0
home-assistant-intents==2025.11.24
httpx==0.28.1
ifaddr==0.2.0

11
requirements_all.txt generated
View File

@@ -252,7 +252,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==42.9.0
aioesphomeapi==42.10.0
# homeassistant.components.matrix
# homeassistant.components.slack
@@ -393,7 +393,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==13.21.0
aioshelly==13.22.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -1001,6 +1001,9 @@ freebox-api==1.2.2
# homeassistant.components.free_mobile
freesms==0.2.0
# homeassistant.components.fressnapf_tracker
fressnapftracker==0.1.2
# homeassistant.components.fritz
# homeassistant.components.fritzbox_callmonitor
fritzconnection[qr]==1.15.0
@@ -1201,7 +1204,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20251127.0
home-assistant-frontend==20251201.0
# homeassistant.components.conversation
home-assistant-intents==2025.11.24
@@ -3231,7 +3234,7 @@ youless-api==2.2.0
youtubeaio==2.1.1
# homeassistant.components.media_extractor
yt-dlp[default]==2025.10.22
yt-dlp[default]==2025.11.12
# homeassistant.components.zabbix
zabbix-utils==2.0.3

View File

@@ -243,7 +243,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==42.9.0
aioesphomeapi==42.10.0
# homeassistant.components.matrix
# homeassistant.components.slack
@@ -378,7 +378,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==13.21.0
aioshelly==13.22.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -880,6 +880,9 @@ forecast-solar==4.2.0
# homeassistant.components.freebox
freebox-api==1.2.2
# homeassistant.components.fressnapf_tracker
fressnapftracker==0.1.2
# homeassistant.components.fritz
# homeassistant.components.fritzbox_callmonitor
fritzconnection[qr]==1.15.0
@@ -1059,7 +1062,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20251127.0
home-assistant-frontend==20251201.0
# homeassistant.components.conversation
home-assistant-intents==2025.11.24
@@ -2689,7 +2692,7 @@ youless-api==2.2.0
youtubeaio==2.1.1
# homeassistant.components.media_extractor
yt-dlp[default]==2025.10.22
yt-dlp[default]==2025.11.12
# homeassistant.components.zamg
zamg==0.3.6

View File

@@ -81,7 +81,9 @@ async def test_climate_set_temperature_error(
"""Test error handling when setting temperature fails."""
mock_airobot_client.set_home_temperature.side_effect = AirobotError("Device error")
with pytest.raises(ServiceValidationError, match="Failed to set temperature"):
with pytest.raises(
ServiceValidationError, match="Failed to set temperature"
) as exc_info:
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
@@ -92,6 +94,10 @@ async def test_climate_set_temperature_error(
blocking=True,
)
assert exc_info.value.translation_domain == "airobot"
assert exc_info.value.translation_key == "set_temperature_failed"
assert exc_info.value.translation_placeholders == {"temperature": "24.0"}
@pytest.mark.parametrize(
("preset_mode", "method", "arg"),
@@ -160,7 +166,9 @@ async def test_climate_set_preset_mode_error(
"""Test error handling when setting preset mode fails."""
mock_airobot_client.set_boost_mode.side_effect = AirobotError("Device error")
with pytest.raises(ServiceValidationError, match="Failed to set preset mode"):
with pytest.raises(
ServiceValidationError, match="Failed to set preset mode"
) as exc_info:
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
@@ -171,6 +179,10 @@ async def test_climate_set_preset_mode_error(
blocking=True,
)
assert exc_info.value.translation_domain == "airobot"
assert exc_info.value.translation_key == "set_preset_mode_failed"
assert exc_info.value.translation_placeholders == {"preset_mode": "boost"}
async def test_climate_heating_state(
hass: HomeAssistant,

View File

@@ -232,3 +232,78 @@ async def test_dhcp_discovery_duplicate(
# Verify the IP was updated in the existing entry
assert mock_config_entry.data[CONF_HOST] == "192.168.1.101"
async def test_reauth_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_airobot_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauthentication flow."""
mock_config_entry.add_to_hass(hass)
# Trigger reauthentication
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["description_placeholders"]["username"] == "T01A1B2C3"
assert result["description_placeholders"]["host"] == "192.168.1.100"
# Provide new password
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "new-password"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_PASSWORD] == "new-password"
@pytest.mark.parametrize(
("exception", "error_base"),
[
(AirobotAuthError("Invalid credentials"), "invalid_auth"),
(AirobotConnectionError("Connection failed"), "cannot_connect"),
(Exception("Unknown error"), "unknown"),
],
)
async def test_reauth_flow_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_airobot_client: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
error_base: str,
) -> None:
"""Test reauthentication flow with errors."""
mock_config_entry.add_to_hass(hass)
# Trigger reauthentication
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
# First attempt with error
mock_airobot_client.get_statuses.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "wrong-password"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error_base}
# Recover from error
mock_airobot_client.get_statuses.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "new-password"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_PASSWORD] == "new-password"

View File

@@ -26,7 +26,7 @@ async def test_setup_entry_success(
@pytest.mark.parametrize(
("exception", "expected_state"),
[
(AirobotAuthError("Authentication failed"), ConfigEntryState.SETUP_RETRY),
(AirobotAuthError("Authentication failed"), ConfigEntryState.SETUP_ERROR),
(AirobotConnectionError("Connection failed"), ConfigEntryState.SETUP_RETRY),
],
)
@@ -48,6 +48,26 @@ async def test_setup_entry_exceptions(
assert mock_config_entry.state is expected_state
async def test_setup_entry_auth_error_triggers_reauth(
hass: HomeAssistant,
mock_airobot_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setup with auth error triggers reauth flow."""
mock_config_entry.add_to_hass(hass)
mock_airobot_client.get_statuses.side_effect = AirobotAuthError(
"Authentication failed"
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["step_id"] == "reauth_confirm"
@pytest.mark.usefixtures("init_integration")
async def test_unload_entry(
hass: HomeAssistant, mock_config_entry: MockConfigEntry

View File

@@ -0,0 +1 @@
"""Tests for the Fressnapf Tracker integration."""

View File

@@ -0,0 +1,163 @@
"""Common fixtures for the Fressnapf Tracker tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from fressnapftracker import (
Device,
PhoneVerificationResponse,
Position,
SmsCodeResponse,
Tracker,
TrackerFeatures,
TrackerSettings,
UserToken,
)
import pytest
from homeassistant.components.fressnapf_tracker.const import (
CONF_PHONE_NUMBER,
CONF_USER_ID,
DOMAIN,
)
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
MOCK_PHONE_NUMBER = "+491234567890"
MOCK_USER_ID = 12345
MOCK_ACCESS_TOKEN = "mock_access_token"
MOCK_SERIAL_NUMBER = "ABC123456"
MOCK_DEVICE_TOKEN = "mock_device_token"
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.fressnapf_tracker.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_tracker() -> Tracker:
"""Create a mock Tracker object."""
return Tracker(
name="Fluffy",
battery=85,
charging=False,
position=Position(
lat=52.520008,
lng=13.404954,
accuracy=10,
timestamp="2024-01-15T12:00:00Z",
),
tracker_settings=TrackerSettings(
generation="GPS Tracker 2.0",
features=TrackerFeatures(live_tracking=True),
),
led_brightness=None,
deep_sleep=None,
led_activatable=None,
)
@pytest.fixture
def mock_tracker_no_position() -> Tracker:
"""Create a mock Tracker object without position."""
return Tracker(
name="Fluffy",
battery=85,
charging=False,
position=None,
tracker_settings=TrackerSettings(
generation="GPS Tracker 2.0",
features=TrackerFeatures(live_tracking=True),
),
led_brightness=None,
deep_sleep=None,
led_activatable=None,
)
@pytest.fixture
def mock_device() -> Device:
"""Create a mock Device object."""
return Device(
serialnumber=MOCK_SERIAL_NUMBER,
token=MOCK_DEVICE_TOKEN,
)
@pytest.fixture
def mock_auth_client(mock_device: Device) -> Generator[MagicMock]:
"""Mock the AuthClient."""
with (
patch(
"homeassistant.components.fressnapf_tracker.config_flow.AuthClient",
autospec=True,
) as mock_auth_client,
patch(
"homeassistant.components.fressnapf_tracker.AuthClient",
new=mock_auth_client,
),
):
client = mock_auth_client.return_value
client.request_sms_code = AsyncMock(
return_value=SmsCodeResponse(id=MOCK_USER_ID)
)
client.verify_phone_number = AsyncMock(
return_value=PhoneVerificationResponse(
user_token=UserToken(
access_token=MOCK_ACCESS_TOKEN,
refresh_token=None,
)
)
)
client.get_devices = AsyncMock(return_value=[mock_device])
yield client
@pytest.fixture
def mock_api_client(mock_tracker: Tracker) -> Generator[MagicMock]:
"""Mock the ApiClient."""
with patch(
"homeassistant.components.fressnapf_tracker.coordinator.ApiClient"
) as mock_api_client:
client = mock_api_client.return_value
client.get_tracker = AsyncMock(return_value=mock_tracker)
yield client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
title=MOCK_PHONE_NUMBER,
data={
CONF_PHONE_NUMBER: MOCK_PHONE_NUMBER,
CONF_USER_ID: MOCK_USER_ID,
CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN,
},
unique_id=str(MOCK_USER_ID),
)
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_auth_client: MagicMock,
) -> MockConfigEntry:
"""Set up the integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View File

@@ -0,0 +1,84 @@
# serializer version: 1
# name: test_state_entity_device_snapshots[Fluffy-entry]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'fressnapf_tracker',
'ABC123456',
),
}),
'labels': set({
}),
'manufacturer': 'Fressnapf',
'model': 'GPS Tracker 2.0',
'model_id': None,
'name': 'Fluffy',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'ABC123456',
'sw_version': None,
'via_device_id': None,
})
# ---
# name: test_state_entity_device_snapshots[device_tracker.fluffy-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'device_tracker',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'device_tracker.fluffy',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'fressnapf_tracker',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pet',
'unique_id': 'ABC123456',
'unit_of_measurement': None,
})
# ---
# name: test_state_entity_device_snapshots[device_tracker.fluffy-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Fluffy',
'gps_accuracy': 10.0,
'latitude': 52.520008,
'longitude': 13.404954,
'source_type': <SourceType.GPS: 'gps'>,
}),
'context': <ANY>,
'entity_id': 'device_tracker.fluffy',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'not_home',
})
# ---

View File

@@ -0,0 +1,362 @@
"""Test the Fressnapf Tracker config flow."""
from unittest.mock import AsyncMock, MagicMock
from fressnapftracker import (
FressnapfTrackerInvalidPhoneNumberError,
FressnapfTrackerInvalidTokenError,
SmsCodeResponse,
)
import pytest
from homeassistant import config_entries
from homeassistant.components.fressnapf_tracker.const import (
CONF_PHONE_NUMBER,
CONF_SMS_CODE,
CONF_USER_ID,
DOMAIN,
)
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import MOCK_ACCESS_TOKEN, MOCK_PHONE_NUMBER, MOCK_USER_ID
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_auth_client")
async def test_user_flow_success(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Test the full user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
# Submit phone number
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PHONE_NUMBER: MOCK_PHONE_NUMBER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "sms_code"
# Submit SMS code
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: 123456},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == MOCK_PHONE_NUMBER
assert result["data"] == {
CONF_PHONE_NUMBER: MOCK_PHONE_NUMBER,
CONF_USER_ID: MOCK_USER_ID,
CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN,
}
assert result["context"]["unique_id"] == str(MOCK_USER_ID)
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("side_effect", "error"),
[
(FressnapfTrackerInvalidPhoneNumberError, "invalid_phone_number"),
(Exception, "unknown"),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_request_sms_code_errors(
hass: HomeAssistant,
mock_auth_client: MagicMock,
side_effect: Exception,
error: str,
) -> None:
"""Test user flow with errors."""
mock_auth_client.request_sms_code.side_effect = side_effect
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PHONE_NUMBER: "invalid"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": error}
# Recover from error
mock_auth_client.request_sms_code.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PHONE_NUMBER: MOCK_PHONE_NUMBER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "sms_code"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: 123456},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.parametrize(
("side_effect", "error"),
[
(FressnapfTrackerInvalidTokenError, "invalid_sms_code"),
(Exception, "unknown"),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_verify_phone_number_errors(
hass: HomeAssistant,
mock_auth_client: MagicMock,
side_effect: Exception,
error: str,
) -> None:
"""Test user flow with invalid SMS code."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PHONE_NUMBER: MOCK_PHONE_NUMBER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "sms_code"
mock_auth_client.verify_phone_number.side_effect = side_effect
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: 999999},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "sms_code"
assert result["errors"] == {"base": error}
# Recover from error
mock_auth_client.verify_phone_number.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: 123456},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("mock_auth_client")
async def test_user_flow_duplicate_user_id(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test user flow aborts on duplicate user_id."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PHONE_NUMBER: f"{MOCK_PHONE_NUMBER}123"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_auth_client")
async def test_user_flow_duplicate_phone_number(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test user flow aborts on duplicate phone number."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PHONE_NUMBER: MOCK_PHONE_NUMBER},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_api_client")
@pytest.mark.usefixtures("mock_auth_client")
async def test_reconfigure_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the reconfigure flow."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
# Submit phone number
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PHONE_NUMBER: MOCK_PHONE_NUMBER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_sms_code"
# Submit SMS code
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: 123456},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
@pytest.mark.usefixtures("mock_api_client")
async def test_reconfigure_flow_invalid_phone_number(
hass: HomeAssistant,
mock_auth_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure flow with invalid phone number."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await mock_config_entry.start_reconfigure_flow(hass)
mock_auth_client.request_sms_code.side_effect = (
FressnapfTrackerInvalidPhoneNumberError
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PHONE_NUMBER: "invalid"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert result["errors"] == {"base": "invalid_phone_number"}
# Recover from error
mock_auth_client.request_sms_code.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PHONE_NUMBER: MOCK_PHONE_NUMBER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_sms_code"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: 123456},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
@pytest.mark.usefixtures("mock_api_client")
async def test_reconfigure_flow_invalid_sms_code(
hass: HomeAssistant,
mock_auth_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure flow with invalid SMS code."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await mock_config_entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PHONE_NUMBER: MOCK_PHONE_NUMBER},
)
mock_auth_client.verify_phone_number.side_effect = FressnapfTrackerInvalidTokenError
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: 999999},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_sms_code"
assert result["errors"] == {"base": "invalid_sms_code"}
# Recover from error
mock_auth_client.verify_phone_number.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: 123456},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
@pytest.mark.usefixtures("mock_api_client")
async def test_reconfigure_flow_invalid_user_id(
hass: HomeAssistant,
mock_auth_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure flow does not allow to reconfigure to another account."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await mock_config_entry.start_reconfigure_flow(hass)
mock_auth_client.request_sms_code = AsyncMock(
return_value=SmsCodeResponse(id=MOCK_USER_ID + 1)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PHONE_NUMBER: f"{MOCK_PHONE_NUMBER}123"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert result["errors"] == {"base": "account_change_not_allowed"}
# Recover from error
mock_auth_client.request_sms_code = AsyncMock(
return_value=SmsCodeResponse(id=MOCK_USER_ID)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PHONE_NUMBER: MOCK_PHONE_NUMBER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_sms_code"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: 123456},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"

View File

@@ -0,0 +1,57 @@
"""Test the Fressnapf Tracker device tracker platform."""
from unittest.mock import AsyncMock, MagicMock
from fressnapftracker import Tracker
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("init_integration")
async def test_state_entity_device_snapshots(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test device tracker entity is created correctly."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
device_entries = dr.async_entries_for_config_entry(
device_registry, mock_config_entry.entry_id
)
assert device_entries
for device_entry in device_entries:
assert device_entry == snapshot(name=f"{device_entry.name}-entry"), (
f"device entry snapshot failed for {device_entry.name}"
)
@pytest.mark.usefixtures("mock_auth_client")
async def test_device_tracker_no_position(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_tracker_no_position: Tracker,
mock_api_client: MagicMock,
) -> None:
"""Test device tracker is unavailable when position is None."""
mock_config_entry.add_to_hass(hass)
mock_api_client.get_tracker = AsyncMock(return_value=mock_tracker_no_position)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "device_tracker.fluffy"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
assert "latitude" not in state.attributes
assert "longitude" not in state.attributes
assert "gps_accuracy" not in state.attributes

View File

@@ -0,0 +1,61 @@
"""Test the Fressnapf Tracker integration init."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_auth_client")
@pytest.mark.usefixtures("mock_api_client")
async def test_setup_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test successful setup of config entry."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
@pytest.mark.usefixtures("mock_auth_client")
@pytest.mark.usefixtures("mock_api_client")
async def test_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test successful unload of config entry."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.usefixtures("mock_auth_client")
async def test_setup_entry_api_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
) -> None:
"""Test setup fails when API returns error."""
mock_config_entry.add_to_hass(hass)
mock_api_client.get_tracker = AsyncMock(side_effect=Exception("API Error"))
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -1,11 +1,15 @@
"""The tests for frontend storage."""
import asyncio
from typing import Any
from unittest.mock import patch
import pytest
from homeassistant.components.frontend import DOMAIN
from homeassistant.components.frontend.storage import async_user_store
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from homeassistant.setup import async_setup_component
from tests.common import MockUser
@@ -572,3 +576,92 @@ async def test_set_system_data_requires_admin(
assert not res["success"], res
assert res["error"]["code"] == "unauthorized"
assert res["error"]["message"] == "Unauthorized"
async def test_user_store_concurrent_access(
hass: HomeAssistant,
hass_admin_user: MockUser,
hass_storage: dict[str, Any],
) -> None:
"""Test that concurrent access to user store returns loaded data."""
storage_key = f"{DOMAIN}.user_data_{hass_admin_user.id}"
hass_storage[storage_key] = {
"version": 1,
"data": {"test-key": "test-value"},
}
load_count = 0
original_async_load = Store.async_load
async def slow_async_load(self: Store) -> Any:
"""Simulate slow loading to trigger race condition."""
nonlocal load_count
load_count += 1
await asyncio.sleep(0) # Yield to allow other coroutines to run
return await original_async_load(self)
with patch.object(Store, "async_load", slow_async_load):
# Request the same user store concurrently
results = await asyncio.gather(
async_user_store(hass, hass_admin_user.id),
async_user_store(hass, hass_admin_user.id),
async_user_store(hass, hass_admin_user.id),
)
# All results should be the same store instance with loaded data
assert results[0] is results[1] is results[2]
assert results[0].data == {"test-key": "test-value"}
# Store should only be loaded once due to Future synchronization
assert load_count == 1
async def test_user_store_load_error(
hass: HomeAssistant,
hass_admin_user: MockUser,
) -> None:
"""Test that load errors are propagated and allow retry."""
async def failing_async_load(self: Store) -> Any:
"""Simulate a load failure."""
raise OSError("Storage read error")
with (
patch.object(Store, "async_load", failing_async_load),
pytest.raises(OSError, match="Storage read error"),
):
await async_user_store(hass, hass_admin_user.id)
# After error, the future should be removed, allowing retry
# This time without the patch, it should work (empty store)
store = await async_user_store(hass, hass_admin_user.id)
assert store.data == {}
async def test_user_store_concurrent_load_error(
hass: HomeAssistant,
hass_admin_user: MockUser,
) -> None:
"""Test that concurrent callers all receive the same error."""
async def failing_async_load(self: Store) -> Any:
"""Simulate a slow load failure."""
await asyncio.sleep(0) # Yield to allow other coroutines to run
raise OSError("Storage read error")
with patch.object(Store, "async_load", failing_async_load):
results = await asyncio.gather(
async_user_store(hass, hass_admin_user.id),
async_user_store(hass, hass_admin_user.id),
async_user_store(hass, hass_admin_user.id),
return_exceptions=True,
)
# All callers should receive the same OSError
assert len(results) == 3
for result in results:
assert isinstance(result, OSError)
assert str(result) == "Storage read error"
# After error, retry should work
store = await async_user_store(hass, hass_admin_user.id)
assert store.data == {}

View File

@@ -23,9 +23,6 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import
BaseFirmwareConfigFlow,
BaseFirmwareOptionsFlow,
)
from homeassistant.components.homeassistant_hardware.helpers import (
async_firmware_update_context,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
@@ -204,11 +201,6 @@ async def mock_test_firmware_platform(
yield
@pytest.fixture(autouse=True)
async def fixture_mock_supervisor_client(supervisor_client: AsyncMock):
"""Mock supervisor client in tests."""
def delayed_side_effect() -> Callable[..., Awaitable[None]]:
"""Slows down eager tasks by delaying for an event loop tick."""
@@ -307,21 +299,18 @@ def mock_firmware_info(
bootloader_reset_methods: Sequence[ResetTarget] = (),
application_probe_methods: Sequence[tuple[ApplicationType, int]] = (),
progress_callback: Callable[[int, int], None] | None = None,
*,
domain: str = "homeassistant_hardware",
) -> FirmwareInfo:
async with async_firmware_update_context(hass, device, domain):
await asyncio.sleep(0)
progress_callback(0, 100)
await asyncio.sleep(0)
progress_callback(50, 100)
await asyncio.sleep(0)
progress_callback(100, 100)
await asyncio.sleep(0)
progress_callback(0, 100)
await asyncio.sleep(0)
progress_callback(50, 100)
await asyncio.sleep(0)
progress_callback(100, 100)
if flashed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
if flashed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
return flashed_firmware_info
return flashed_firmware_info
with (
patch(
@@ -373,6 +362,7 @@ async def consume_progress_flow(
return result
@pytest.mark.usefixtures("addon_store_info", "addon_info")
async def test_config_flow_zigbee_recommended(hass: HomeAssistant) -> None:
"""Test flow with recommended Zigbee installation type."""
init_result = await hass.config_entries.flow.async_init(
@@ -447,6 +437,7 @@ async def test_config_flow_zigbee_recommended(hass: HomeAssistant) -> None:
}
@pytest.mark.usefixtures("addon_store_info", "addon_info")
async def test_config_flow_zigbee_custom_zha(hass: HomeAssistant) -> None:
"""Test flow with custom Zigbee installation type and ZHA selected."""
init_result = await hass.config_entries.flow.async_init(
@@ -539,6 +530,7 @@ async def test_config_flow_zigbee_custom_zha(hass: HomeAssistant) -> None:
}
@pytest.mark.usefixtures("addon_store_info", "addon_info")
async def test_config_flow_zigbee_custom_other(hass: HomeAssistant) -> None:
"""Test flow with custom Zigbee installation type and Other selected."""
init_result = await hass.config_entries.flow.async_init(
@@ -611,6 +603,7 @@ async def test_config_flow_zigbee_custom_other(hass: HomeAssistant) -> None:
assert flows == []
@pytest.mark.usefixtures("addon_store_info", "addon_info")
async def test_config_flow_firmware_index_download_fails_but_not_required(
hass: HomeAssistant,
) -> None:
@@ -647,6 +640,7 @@ async def test_config_flow_firmware_index_download_fails_but_not_required(
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("addon_store_info", "addon_info")
async def test_config_flow_firmware_download_fails_but_not_required(
hass: HomeAssistant,
) -> None:
@@ -682,6 +676,7 @@ async def test_config_flow_firmware_download_fails_but_not_required(
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("addon_store_info", "addon_info")
async def test_config_flow_doesnt_downgrade(
hass: HomeAssistant,
) -> None:
@@ -720,6 +715,7 @@ async def test_config_flow_doesnt_downgrade(
assert len(mock_async_flash_silabs_firmware.mock_calls) == 0
@pytest.mark.usefixtures("addon_store_info", "addon_info")
async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> None:
"""Test skip installing the firmware if not needed."""
result = await hass.config_entries.flow.async_init(

View File

@@ -31,11 +31,6 @@ from .test_config_flow import (
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
async def fixture_mock_supervisor_client(supervisor_client: AsyncMock):
"""Mock supervisor client in tests."""
@pytest.mark.parametrize(
"ignore_translations_for_mock_domains",
["test_firmware_domain"],
@@ -312,6 +307,7 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non
@pytest.mark.parametrize(
"ignore_translations_for_mock_domains", ["test_firmware_domain"]
)
@pytest.mark.usefixtures("addon_store_info", "addon_info")
async def test_config_flow_firmware_index_download_fails_and_required(
hass: HomeAssistant,
) -> None:
@@ -354,6 +350,7 @@ async def test_config_flow_firmware_index_download_fails_and_required(
@pytest.mark.parametrize(
"ignore_translations_for_mock_domains", ["test_firmware_domain"]
)
@pytest.mark.usefixtures("addon_store_info", "addon_info")
async def test_config_flow_firmware_download_fails_and_required(
hass: HomeAssistant,
) -> None:

View File

@@ -327,8 +327,6 @@ async def test_update_entity_installation(
bootloader_reset_methods: Sequence[ResetTarget] = (),
application_probe_methods: Sequence[tuple[ApplicationType, int]] = (),
progress_callback: Callable[[int, int], None] | None = None,
*,
domain: str = "homeassistant_hardware",
) -> FirmwareInfo:
await asyncio.sleep(0)
progress_callback(0, 100)

View File

@@ -24,6 +24,7 @@ from homeassistant.components.homeassistant_hardware.util import (
OwningAddon,
OwningIntegration,
ResetTarget,
async_firmware_flashing_context,
async_flash_silabs_firmware,
get_otbr_addon_firmware_info,
guess_firmware_info,
@@ -606,18 +607,21 @@ async def test_async_flash_silabs_firmware(hass: HomeAssistant) -> None:
return_value=expected_firmware_info,
),
):
after_flash_info = await async_flash_silabs_firmware(
hass=hass,
device="/dev/ttyUSB0",
fw_data=b"firmware contents",
expected_installed_firmware_type=ApplicationType.SPINEL,
bootloader_reset_methods=[ResetTarget.RTS_DTR],
application_probe_methods=[
(ApplicationType.EZSP, 460800),
(ApplicationType.SPINEL, 460800),
],
progress_callback=progress_callback,
)
async with async_firmware_flashing_context(
hass, "/dev/ttyUSB0", "homeassistant_hardware"
):
after_flash_info = await async_flash_silabs_firmware(
hass=hass,
device="/dev/ttyUSB0",
fw_data=b"firmware contents",
expected_installed_firmware_type=ApplicationType.SPINEL,
bootloader_reset_methods=[ResetTarget.RTS_DTR],
application_probe_methods=[
(ApplicationType.EZSP, 460800),
(ApplicationType.SPINEL, 460800),
],
progress_callback=progress_callback,
)
assert progress_callback.mock_calls == [call(0, 100), call(50, 100), call(100, 100)]
assert after_flash_info == expected_firmware_info
@@ -712,17 +716,20 @@ async def test_async_flash_silabs_firmware_flash_failure(
),
pytest.raises(HomeAssistantError, match=expected_error_msg) as exc,
):
await async_flash_silabs_firmware(
hass=hass,
device="/dev/ttyUSB0",
fw_data=b"firmware contents",
expected_installed_firmware_type=ApplicationType.SPINEL,
bootloader_reset_methods=[ResetTarget.RTS_DTR],
application_probe_methods=[
(ApplicationType.EZSP, 460800),
(ApplicationType.SPINEL, 460800),
],
)
async with async_firmware_flashing_context(
hass, "/dev/ttyUSB0", "homeassistant_hardware"
):
await async_flash_silabs_firmware(
hass=hass,
device="/dev/ttyUSB0",
fw_data=b"firmware contents",
expected_installed_firmware_type=ApplicationType.SPINEL,
bootloader_reset_methods=[ResetTarget.RTS_DTR],
application_probe_methods=[
(ApplicationType.EZSP, 460800),
(ApplicationType.SPINEL, 460800),
],
)
# Both owning integrations/addons are stopped and restarted
assert owner1.temporarily_stop.mock_calls == [
@@ -774,30 +781,33 @@ async def test_async_flash_silabs_firmware_probe_failure(hass: HomeAssistant) ->
),
pytest.raises(
HomeAssistantError, match="Failed to probe the firmware after flashing"
),
) as exc,
):
await async_flash_silabs_firmware(
hass=hass,
device="/dev/ttyUSB0",
fw_data=b"firmware contents",
expected_installed_firmware_type=ApplicationType.SPINEL,
bootloader_reset_methods=[ResetTarget.RTS_DTR],
application_probe_methods=[
(ApplicationType.EZSP, 460800),
(ApplicationType.SPINEL, 460800),
],
)
async with async_firmware_flashing_context(
hass, "/dev/ttyUSB0", "homeassistant_hardware"
):
await async_flash_silabs_firmware(
hass=hass,
device="/dev/ttyUSB0",
fw_data=b"firmware contents",
expected_installed_firmware_type=ApplicationType.SPINEL,
bootloader_reset_methods=[ResetTarget.RTS_DTR],
application_probe_methods=[
(ApplicationType.EZSP, 460800),
(ApplicationType.SPINEL, 460800),
],
)
# Both owning integrations/addons are stopped and restarted
assert owner1.temporarily_stop.mock_calls == [
call(hass),
# pylint: disable-next=unnecessary-dunder-call
call().__aenter__(ANY),
call().__aexit__(ANY, None, None, None),
call().__aexit__(ANY, HomeAssistantError, exc.value, ANY),
]
assert owner2.temporarily_stop.mock_calls == [
call(hass),
# pylint: disable-next=unnecessary-dunder-call
call().__aenter__(ANY),
call().__aexit__(ANY, None, None, None),
call().__aexit__(ANY, HomeAssistantError, exc.value, ANY),
]

View File

@@ -118,9 +118,12 @@ from tests.common import MockConfigEntry
},
),
(
{ATTR_STATUS: "test toot", ATTR_IDEMPOTENCY_KEY: "post_once_only"},
{
"status": "test toot",
ATTR_STATUS: "test toot\nwith idempotency",
ATTR_IDEMPOTENCY_KEY: "post_once_only",
},
{
"status": "test toot\nwith idempotency",
"idempotency_key": "post_once_only",
"language": None,
"spoiler_text": None,

View File

@@ -4159,6 +4159,7 @@
'freshen_up',
'minimum_iron',
'no_program',
'normal',
'outerwear',
'pillows',
'proofing',
@@ -4230,6 +4231,7 @@
'freshen_up',
'minimum_iron',
'no_program',
'normal',
'outerwear',
'pillows',
'proofing',
@@ -6503,6 +6505,7 @@
'freshen_up',
'minimum_iron',
'no_program',
'normal',
'outerwear',
'pillows',
'proofing',
@@ -6574,6 +6577,7 @@
'freshen_up',
'minimum_iron',
'no_program',
'normal',
'outerwear',
'pillows',
'proofing',

View File

@@ -0,0 +1,168 @@
# serializer version: 1
# name: test_entities[sensor.saunum_leil_heater_elements_active-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'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.saunum_leil_heater_elements_active',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Heater elements active',
'platform': 'saunum',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'heater_elements_active',
'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA-heater_elements_active',
'unit_of_measurement': 'heater elements',
})
# ---
# name: test_entities[sensor.saunum_leil_heater_elements_active-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Saunum Leil Heater elements active',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'heater elements',
}),
'context': <ANY>,
'entity_id': 'sensor.saunum_leil_heater_elements_active',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_entities[sensor.saunum_leil_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'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.saunum_leil_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'saunum',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'current_temperature',
'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA-current_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_entities[sensor.saunum_leil_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Saunum Leil Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.saunum_leil_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '75.0',
})
# ---
# name: test_entities[sensor.saunum_leil_total_time_turned_on-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.saunum_leil_total_time_turned_on',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Total time turned on',
'platform': 'saunum',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'on_time',
'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA-on_time',
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
})
# ---
# name: test_entities[sensor.saunum_leil_total_time_turned_on-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'Saunum Leil Total time turned on',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.saunum_leil_total_time_turned_on',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.0',
})
# ---

View File

@@ -0,0 +1,85 @@
"""Test the Saunum sensor platform."""
from __future__ import annotations
from dataclasses import replace
from freezegun.api import FrozenDateTimeFactory
from pysaunum import SaunumException
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.SENSOR]
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_sensor_not_created_when_value_is_none(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_saunum_client,
) -> None:
"""Test sensors are not created when initial value is None."""
base_data = mock_saunum_client.async_get_data.return_value
mock_saunum_client.async_get_data.return_value = replace(
base_data,
current_temperature=None,
heater_elements_active=None,
on_time=None,
)
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("sensor.saunum_leil_temperature") is None
assert hass.states.get("sensor.saunum_leil_heater_elements_active") is None
assert hass.states.get("sensor.saunum_leil_on_time") is None
@pytest.mark.usefixtures("init_integration")
async def test_entity_unavailable_on_update_failure(
hass: HomeAssistant,
mock_saunum_client,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that entity becomes unavailable when coordinator update fails."""
entity_id = "sensor.saunum_leil_temperature"
# Verify entity is initially available
state = hass.states.get(entity_id)
assert state is not None
assert state.state != STATE_UNAVAILABLE
# Make the next update fail
mock_saunum_client.async_get_data.side_effect = SaunumException("Read error")
# Move time forward to trigger a coordinator update (60 seconds)
freezer.tick(60)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Entity should now be unavailable
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE

View File

@@ -597,6 +597,7 @@ async def test_legacy_deprecation(
assert issue.domain == "template"
assert issue.severity == ir.IssueSeverity.WARNING
assert issue.translation_placeholders["breadcrumb"] == breadcrumb
assert "platform: template" not in issue.translation_placeholders["config"]
@pytest.mark.parametrize(

View File

@@ -596,7 +596,7 @@ async def test_fail_non_numerical_number_settings(
)
async def test_reload_when_labs_flag_changes(
async def test_yaml_reload_when_labs_flag_changes(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_admin_user: MockUser,
@@ -690,3 +690,67 @@ async def test_reload_when_labs_flag_changes(
await hass.async_block_till_done()
assert hass.states.get("sensor.bye").state == set_state
last_state = set_state
async def test_config_entry_reload_when_labs_flag_changes(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_admin_user: MockUser,
hass_read_only_user: MockUser,
) -> None:
"""Test templates are reloaded when labs flag changes."""
ws_client = await hass_ws_client(hass)
template_config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={
"name": "hello",
"template_type": "sensor",
"state": "{{ 'foo' }}",
},
title="My template",
)
template_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(template_config_entry.entry_id)
await hass.async_block_till_done()
assert await async_setup_component(hass, labs.DOMAIN, {})
assert hass.states.get("sensor.hello") is not None
assert hass.states.get("sensor.hello").state == "foo"
# Check we reload whenever the labs flag is set, even if it's already enabled
for enabled, set_state in (
(True, "beer"),
(True, "is"),
(False, "very"),
(False, "good"),
):
hass.config_entries.async_update_entry(
template_config_entry,
options={
"name": "hello",
"template_type": "sensor",
"state": f"{{{{ '{set_state}' }}}}",
},
)
with patch(
"homeassistant.config.load_yaml_config_file",
autospec=True,
return_value={},
):
await ws_client.send_json_auto_id(
{
"type": "labs/update",
"domain": "automation",
"preview_feature": "new_triggers_conditions",
"enabled": enabled,
}
)
msg = await ws_client.receive_json()
assert msg["success"]
await hass.async_block_till_done()
assert hass.states.get("sensor.hello") is not None
assert hass.states.get("sensor.hello").state == set_state

View File

@@ -1,6 +1,6 @@
"""Tests for the Area Registry."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from functools import partial
from typing import Any
@@ -438,16 +438,65 @@ async def test_migration_from_1_1(
"""Test migration from version 1.1."""
hass_storage[ar.STORAGE_KEY] = {
"version": 1,
"data": {"areas": [{"id": "12345A", "name": "mock"}]},
"data": {
"areas": [
{"id": "12345A", "name": "AAA"},
{"id": "12345B", "name": "CCC"},
{"id": "12345C", "name": "bbb"},
]
},
}
await ar.async_load(hass)
registry = ar.async_get(hass)
# Test data was loaded
entry = registry.async_get_or_create("mock")
entry = registry.async_get_or_create("AAA")
assert entry.id == "12345A"
# Check sort order
assert list(registry.async_list_areas()) == [
ar.AreaEntry(
name="AAA",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id=None,
humidity_entity_id=None,
icon=None,
id="12345A",
labels=set(),
picture=None,
temperature_entity_id=None,
),
ar.AreaEntry(
name="bbb",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id=None,
humidity_entity_id=None,
icon=None,
id="12345C",
labels=set(),
picture=None,
temperature_entity_id=None,
),
ar.AreaEntry(
name="CCC",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id=None,
humidity_entity_id=None,
icon=None,
id="12345B",
labels=set(),
picture=None,
temperature_entity_id=None,
),
]
# Check we store migrated data
await flush_store(registry._store)
assert hass_storage[ar.STORAGE_KEY] == {
@@ -458,17 +507,43 @@ async def test_migration_from_1_1(
"areas": [
{
"aliases": [],
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": None,
"humidity_entity_id": None,
"icon": None,
"id": "12345A",
"labels": [],
"name": "mock",
"picture": None,
"created_at": "1970-01-01T00:00:00+00:00",
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "AAA",
"picture": None,
"temperature_entity_id": None,
},
{
"aliases": [],
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": None,
"humidity_entity_id": None,
}
"icon": None,
"id": "12345C",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "bbb",
"picture": None,
"temperature_entity_id": None,
},
{
"aliases": [],
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": None,
"humidity_entity_id": None,
"icon": None,
"id": "12345B",
"labels": [],
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "CCC",
"picture": None,
"temperature_entity_id": None,
},
]
},
}

View File

@@ -1,6 +1,6 @@
"""Tests for the floor registry."""
from datetime import datetime
from datetime import UTC, datetime
from functools import partial
import re
from typing import Any
@@ -478,11 +478,88 @@ async def test_migration_from_1_1(
"floors": [
{
"floor_id": "12345A",
"name": "mock",
"name": "AA floor no level floor",
"aliases": [],
"icon": None,
"level": None,
}
},
{
"floor_id": "12345B",
"name": "CC floor no level floor",
"aliases": [],
"icon": None,
"level": None,
},
{
"floor_id": "12345C",
"name": "bb floor no level floor",
"aliases": [],
"icon": None,
"level": None,
},
{
"floor_id": "12345D",
"name": "AA floor level -1",
"aliases": [],
"icon": None,
"level": -1,
},
{
"floor_id": "12345E",
"name": "CC floor level -1",
"aliases": [],
"icon": None,
"level": -1,
},
{
"floor_id": "12345F",
"name": "bb floor level -1",
"aliases": [],
"icon": None,
"level": -1,
},
{
"floor_id": "12345G",
"name": "AA floor level 0",
"aliases": [],
"icon": None,
"level": 0,
},
{
"floor_id": "12345H",
"name": "CC floor level 0",
"aliases": [],
"icon": None,
"level": 0,
},
{
"floor_id": "12345I",
"name": "bb floor level 0",
"aliases": [],
"icon": None,
"level": 0,
},
{
"floor_id": "12345J",
"name": "AA floor level 1",
"aliases": [],
"icon": None,
"level": 1,
},
{
"floor_id": "12345K",
"name": "CC floor level 1",
"aliases": [],
"icon": None,
"level": 1,
},
{
"floor_id": "12345L",
"name": "bb floor level 1",
"aliases": [],
"icon": None,
"level": 1,
},
]
},
}
@@ -491,9 +568,121 @@ async def test_migration_from_1_1(
registry = fr.async_get(hass)
# Test data was loaded
entry = registry.async_get_floor_by_name("mock")
entry = registry.async_get_floor_by_name("AA floor no level floor")
assert entry.floor_id == "12345A"
# Check sort order
assert list(registry.async_list_floors()) == [
fr.FloorEntry(
name="AA floor level 1",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id="12345J",
icon=None,
level=1,
),
fr.FloorEntry(
name="bb floor level 1",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id="12345L",
icon=None,
level=1,
),
fr.FloorEntry(
name="CC floor level 1",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id="12345K",
icon=None,
level=1,
),
fr.FloorEntry(
name="AA floor level 0",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id="12345G",
icon=None,
level=0,
),
fr.FloorEntry(
name="bb floor level 0",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id="12345I",
icon=None,
level=0,
),
fr.FloorEntry(
name="CC floor level 0",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id="12345H",
icon=None,
level=0,
),
fr.FloorEntry(
name="AA floor level -1",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id="12345D",
icon=None,
level=-1,
),
fr.FloorEntry(
name="bb floor level -1",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id="12345F",
icon=None,
level=-1,
),
fr.FloorEntry(
name="CC floor level -1",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id="12345E",
icon=None,
level=-1,
),
fr.FloorEntry(
name="AA floor no level floor",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id="12345A",
icon=None,
level=None,
),
fr.FloorEntry(
name="bb floor no level floor",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id="12345C",
icon=None,
level=None,
),
fr.FloorEntry(
name="CC floor no level floor",
created_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
modified_at=datetime(1970, 1, 1, 0, 0, tzinfo=UTC),
aliases=set(),
floor_id="12345B",
icon=None,
level=None,
),
]
# Check we store migrated data
await flush_store(registry._store)
assert hass_storage[fr.STORAGE_KEY] == {
@@ -504,13 +693,112 @@ async def test_migration_from_1_1(
"floors": [
{
"aliases": [],
"icon": None,
"floor_id": "12345A",
"level": None,
"name": "mock",
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": "12345J",
"icon": None,
"level": 1,
"modified_at": "1970-01-01T00:00:00+00:00",
}
"name": "AA floor level 1",
},
{
"aliases": [],
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": "12345L",
"icon": None,
"level": 1,
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "bb floor level 1",
},
{
"aliases": [],
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": "12345K",
"icon": None,
"level": 1,
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "CC floor level 1",
},
{
"aliases": [],
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": "12345G",
"icon": None,
"level": 0,
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "AA floor level 0",
},
{
"aliases": [],
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": "12345I",
"icon": None,
"level": 0,
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "bb floor level 0",
},
{
"aliases": [],
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": "12345H",
"icon": None,
"level": 0,
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "CC floor level 0",
},
{
"aliases": [],
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": "12345D",
"icon": None,
"level": -1,
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "AA floor level -1",
},
{
"aliases": [],
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": "12345F",
"icon": None,
"level": -1,
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "bb floor level -1",
},
{
"aliases": [],
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": "12345E",
"icon": None,
"level": -1,
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "CC floor level -1",
},
{
"aliases": [],
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": "12345A",
"icon": None,
"level": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "AA floor no level floor",
},
{
"aliases": [],
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": "12345C",
"icon": None,
"level": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "bb floor no level floor",
},
{
"aliases": [],
"created_at": "1970-01-01T00:00:00+00:00",
"floor_id": "12345B",
"icon": None,
"level": None,
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "CC floor no level floor",
},
]
},
}