mirror of
https://github.com/home-assistant/core.git
synced 2025-12-02 05:58:04 +00:00
Compare commits
32 Commits
add_presen
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02a70123c1 | ||
|
|
5f6d2f537a | ||
|
|
5e04e9f04d | ||
|
|
56515ad7b5 | ||
|
|
a1fe2bf4fa | ||
|
|
b8fa8efd91 | ||
|
|
03557b5ef2 | ||
|
|
dafec8ce58 | ||
|
|
6ff3f74347 | ||
|
|
ddd8cf7fde | ||
|
|
1356eea52f | ||
|
|
6188e0e39b | ||
|
|
699fa1617d | ||
|
|
449f0fa5a5 | ||
|
|
2e008d2bb7 | ||
|
|
05dec2619d | ||
|
|
25a6778ba8 | ||
|
|
f564b8cb44 | ||
|
|
ce6bfdebfc | ||
|
|
f00a944ac1 | ||
|
|
3073a99ce6 | ||
|
|
8b04ce1328 | ||
|
|
39f76787ab | ||
|
|
e8acced335 | ||
|
|
758a30eebc | ||
|
|
faf94bea24 | ||
|
|
ff91c57228 | ||
|
|
3d2b506997 | ||
|
|
d3c1c28605 | ||
|
|
d4e1f7741d | ||
|
|
e713632eed | ||
|
|
060ad35ddc |
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyairobotrest"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyairobotrest==0.1.0"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}."
|
||||
},
|
||||
|
||||
@@ -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'"],
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
49
homeassistant/components/fressnapf_tracker/__init__.py
Normal file
49
homeassistant/components/fressnapf_tracker/__init__.py
Normal 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)
|
||||
193
homeassistant/components/fressnapf_tracker/config_flow.py
Normal file
193
homeassistant/components/fressnapf_tracker/config_flow.py
Normal 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,
|
||||
)
|
||||
6
homeassistant/components/fressnapf_tracker/const.py
Normal file
6
homeassistant/components/fressnapf_tracker/const.py
Normal 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"
|
||||
50
homeassistant/components/fressnapf_tracker/coordinator.py
Normal file
50
homeassistant/components/fressnapf_tracker/coordinator.py
Normal 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
|
||||
69
homeassistant/components/fressnapf_tracker/device_tracker.py
Normal file
69
homeassistant/components/fressnapf_tracker/device_tracker.py
Normal 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
|
||||
27
homeassistant/components/fressnapf_tracker/entity.py
Normal file
27
homeassistant/components/fressnapf_tracker/entity.py
Normal 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),
|
||||
)
|
||||
9
homeassistant/components/fressnapf_tracker/icons.json
Normal file
9
homeassistant/components/fressnapf_tracker/icons.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"device_tracker": {
|
||||
"pet": {
|
||||
"default": "mdi:paw"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
homeassistant/components/fressnapf_tracker/manifest.json
Normal file
11
homeassistant/components/fressnapf_tracker/manifest.json
Normal 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"]
|
||||
}
|
||||
@@ -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
|
||||
49
homeassistant/components/fressnapf_tracker/strings.json
Normal file
49
homeassistant/components/fressnapf_tracker/strings.json
Normal 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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251127.0"]
|
||||
"requirements": ["home-assistant-frontend==20251201.0"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -9,6 +9,7 @@ post:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
visibility:
|
||||
selector:
|
||||
select:
|
||||
|
||||
@@ -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."]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -14,6 +14,7 @@ from .coordinator import LeilSaunaCoordinator
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.CLIMATE,
|
||||
Platform.LIGHT,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator]
|
||||
|
||||
9
homeassistant/components/saunum/icons.json
Normal file
9
homeassistant/components/saunum/icons.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"heater_elements_active": {
|
||||
"default": "mdi:radiator"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
99
homeassistant/components/saunum/sensor.py
Normal file
99
homeassistant/components/saunum/sensor.py
Normal 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)
|
||||
@@ -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": {
|
||||
|
||||
@@ -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*",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -225,6 +225,7 @@ FLOWS = {
|
||||
"foscam",
|
||||
"freebox",
|
||||
"freedompro",
|
||||
"fressnapf_tracker",
|
||||
"fritz",
|
||||
"fritzbox",
|
||||
"fritzbox_callmonitor",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
11
requirements_all.txt
generated
@@ -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
|
||||
|
||||
11
requirements_test_all.txt
generated
11
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
1
tests/components/fressnapf_tracker/__init__.py
Normal file
1
tests/components/fressnapf_tracker/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Fressnapf Tracker integration."""
|
||||
163
tests/components/fressnapf_tracker/conftest.py
Normal file
163
tests/components/fressnapf_tracker/conftest.py
Normal 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
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
362
tests/components/fressnapf_tracker/test_config_flow.py
Normal file
362
tests/components/fressnapf_tracker/test_config_flow.py
Normal 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"
|
||||
57
tests/components/fressnapf_tracker/test_device_tracker.py
Normal file
57
tests/components/fressnapf_tracker/test_device_tracker.py
Normal 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
|
||||
61
tests/components/fressnapf_tracker/test_init.py
Normal file
61
tests/components/fressnapf_tracker/test_init.py
Normal 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
|
||||
@@ -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 == {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
168
tests/components/saunum/snapshots/test_sensor.ambr
Normal file
168
tests/components/saunum/snapshots/test_sensor.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
85
tests/components/saunum/test_sensor.py
Normal file
85
tests/components/saunum/test_sensor.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user