Compare commits

...

77 Commits

Author SHA1 Message Date
jbouwh
c3e4676b4c Revert unneeded changes 2025-11-13 07:01:03 +00:00
jbouwh
f852220282 Set member unique ID's during class init 2025-11-13 07:01:03 +00:00
jbouwh
5dd3bf04eb Remove integration domain 2025-11-13 07:01:03 +00:00
jbouwh
b0c2fdc57b Remove invalid import 2025-11-13 07:01:03 +00:00
jbouwh
617d44ffcf Rework with mixin - Light only 2025-11-13 07:01:03 +00:00
jbouwh
8fb8eed1c8 Automatically update the entity propery when a member created, updated or deleted 2025-11-13 07:01:03 +00:00
jbouwh
1ddbd4755b Apply light group icon to all MQTT light schemas 2025-11-13 07:01:02 +00:00
jbouwh
3bd76294dc Allow an MQTT entity to show as a group 2025-11-13 07:01:02 +00:00
jbouwh
bb97822db9 Cleanup 2025-11-13 06:48:59 +00:00
jbouwh
33ffccabd1 Refactor 2025-11-13 06:48:59 +00:00
jbouwh
56de03ce33 Rework private _included_entities attribute 2025-11-13 06:48:59 +00:00
jbouwh
0cbf7002a8 Add docstring 2025-11-13 06:48:59 +00:00
jbouwh
cffceffe04 Move setup code to add_to_platform_finish 2025-11-13 06:48:59 +00:00
jbouwh
253189805e Remove final 2025-11-13 06:48:59 +00:00
jbouwh
2e91725ac0 Use cached_properties 2025-11-13 06:48:58 +00:00
jbouwh
3b54dddc08 Fix attrbute check - make property final 2025-11-13 06:48:58 +00:00
jbouwh
9bc3d83a55 Update docstring 2025-11-13 06:48:58 +00:00
jbouwh
d62a554cbf Remove the need to manually call async_set_included_entities 2025-11-13 06:48:58 +00:00
jbouwh
f071b7cd46 Improve docstring 2025-11-13 06:48:58 +00:00
jbouwh
37f34f6189 Remove _included_entities property 2025-11-13 06:48:58 +00:00
jbouwh
27dc5b6d18 Do not set included entities if no unique IDs are set 2025-11-13 06:48:58 +00:00
jbouwh
0bbc2f49a6 Upfdate docstr 2025-11-13 06:48:58 +00:00
jbouwh
c121fa25e8 Call async_set_included_entities from add_to_platform_finish 2025-11-13 06:48:58 +00:00
jbouwh
660cea8b65 Handle the entity_id attribute in the Entity base class 2025-11-13 06:48:58 +00:00
jbouwh
c7749ebae1 Fix device tracker 2025-11-13 06:48:58 +00:00
jbouwh
a2acb744b3 Use platform name 2025-11-13 06:48:58 +00:00
jbouwh
0d9158689d Fix device tracker state attrs 2025-11-13 06:48:58 +00:00
jbouwh
f85e8d6c1f Also implement as default in base entity 2025-11-13 06:48:58 +00:00
jbouwh
9be4cc5af1 Integrate with base entity component state attributes 2025-11-13 06:48:58 +00:00
jbouwh
a141eedf2c Update docstr 2025-11-13 06:48:58 +00:00
jbouwh
03040c131c Move logic into Entity class 2025-11-13 06:48:58 +00:00
jbouwh
3eef50632c Use platform domain attribute 2025-11-13 06:48:58 +00:00
jbouwh
eff150cd54 Fix typo 2025-11-13 06:48:58 +00:00
jbouwh
6dcc94b0a1 Follow up on code review 2025-11-13 06:48:58 +00:00
jbouwh
7201903877 Implement mixin class and add feature to maintain included entities from unique IDs 2025-11-13 06:48:58 +00:00
jbouwh
5b776307ea Add included_entities attribute to base Entity class 2025-11-13 06:48:57 +00:00
TheJulianJES
3cb414511b Migrate Z-Wave event entity to new discovery schema (#156320) 2025-11-13 07:22:37 +01:00
karwosts
f55c36d42d Update ical to 11.1.0 (#156487) 2025-11-12 20:24:04 -08:00
Erik Montnemery
26bb301cc0 Fix lifx tests opening sockets (#156460) 2025-11-12 21:51:54 +02:00
Erik Montnemery
4159e483ee Fix wiz tests opening sockets (#156468) 2025-11-12 20:11:15 +01:00
Erik Montnemery
7eb6f7cc07 Fix romy tests opening sockets (#156466) 2025-11-12 20:10:46 +01:00
epenet
a7d01b0b03 Use json_loads_object in tuya models (#156455) 2025-11-12 20:08:28 +01:00
epenet
1e5cfddf83 Use json_loads_object in Tuya light (#156452) 2025-11-12 19:34:17 +01:00
epenet
006fc5b10a Remove JSON parsing from tuya diagnostics (#156451) 2025-11-12 19:32:40 +01:00
Erik Montnemery
35a4b685b3 Fix steamist tests opening sockets (#156467) 2025-11-12 12:01:21 -06:00
Janez Urevc
b166818ef4 Bump tesla-wall-connector to 1.1.0 (#156438) 2025-11-12 17:45:08 +01:00
Erik Montnemery
34cd9f11d0 Fix onkyo tests opening sockets (#156461) 2025-11-12 17:32:58 +01:00
Erik Montnemery
0711d62085 Change collation to utf8mb4_bin for MySQL and MariaDB databases (#156297)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-11-12 16:54:58 +01:00
J. Diego Rodríguez Royo
f70aeafb5f Bump aiohomeconnect to version 0.23.1 (#156454) 2025-11-12 15:59:20 +01:00
MoonDevLT
e2279b3589 Bump lunatone-rest-api-client to 0.5.7 (#156356) 2025-11-12 14:44:52 +01:00
Christopher Fenner
87b68e99ec Add compressor, condensor and evaporator sensors in ViCare integration (#156411) 2025-11-12 14:42:26 +01:00
Manu
b6c8b787e8 Add device storage sensor entities to Xbox (#155657) 2025-11-12 13:53:42 +01:00
Franck Nijhof
78f26edc29 Extend base jinja2 extension with limited template errors (#156431) 2025-11-12 13:52:15 +01:00
ehendrix23
5e6a72de90 Bump pyecobee to 0.3.2 (#156421)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-12 13:40:08 +01:00
Erik Montnemery
dcc559f8b6 Fix progress step bugs (#155923) 2025-11-12 13:14:53 +01:00
Manu
eda49cced0 Code quality improvements for Xbox integration (#156395) 2025-11-12 14:09:53 +02:00
Josef Zweck
14e41ab119 Fix lamarzocco update status (#156442) 2025-11-12 13:10:23 +02:00
Timothy
46151456d8 Make sure to clean register callbacks when mobile_app reloads (#156028) 2025-11-12 12:03:05 +01:00
cdnninja
39773a022a Bump pyvesync to 3.2.2 (#156423)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-12 11:59:45 +01:00
Christopher Fenner
5f49a6450f Add air quality sensors in ViCare integration (#156417) 2025-11-12 11:45:04 +01:00
Christopher Fenner
dc8425c580 Add icon for pm4 sensor (#156432) 2025-11-12 11:38:33 +01:00
Josef Zweck
910bd371e4 Remove wsproto from exceptions (#156434) 2025-11-12 11:16:36 +01:00
Tom Matheussen
802a225e11 Clean alarm control panel platform for Satel Integra (#156357) 2025-11-12 11:09:48 +01:00
Josef Zweck
84f66fa689 Fix aussie-broadband tests (#156441) 2025-11-12 10:54:23 +01:00
wollew
0b7e88d0e0 add parallel_updates for button entity (#156437) 2025-11-12 11:49:32 +02:00
puddly
1fcaf95df5 Bump universal-silabs-flasher to v0.1.0 (#156291)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-12 10:44:33 +01:00
Erik Montnemery
6c7434531f Fix tado tests opening sockets (#156386) 2025-11-12 10:08:15 +01:00
Åke Strandberg
5ec1c2b68b Use runtime_data in Senz (#156408) 2025-11-12 10:06:45 +01:00
Christopher Fenner
d8636d8346 Bump PyViCare to 2.55.0 (#156426) 2025-11-12 09:57:49 +01:00
Brett Adams
434763c74d Fix update progress in Teslemetry (#156422) 2025-11-12 09:55:09 +01:00
Petar Petrov
8cd2c1b43b Add power configuration to Energy dashboard (#153809)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-12 09:21:33 +01:00
Daniel Hjelseth Høyer
44711787a4 Update pyMill to 0.14.1 (#156396) 2025-11-12 09:15:59 +01:00
TheJulianJES
98fd0ee683 Exempt wsproto from license check (#156418) 2025-11-12 08:45:11 +01:00
Joost Lekkerkerker
303e4ce961 Add mac address to Velux device (#156376) 2025-11-12 09:45:02 +02:00
Paul Bottein
76f29298cd Add home panel (#156269) 2025-11-12 09:09:39 +02:00
Will Moss
17f5d0a69f Use common string for the remaining oauth2 error messages (#156407) 2025-11-12 04:43:12 +01:00
johanzander
90561de438 Refactor Growatt Server integration tests (#156413)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-12 00:32:25 +01:00
146 changed files with 9745 additions and 7008 deletions

View File

@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 1
CACHE_VERSION: 2
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.12"

View File

@@ -9,7 +9,7 @@
},
"iot_class": "cloud_polling",
"loggers": ["pyecobee"],
"requirements": ["python-ecobee-api==0.2.20"],
"requirements": ["python-ecobee-api==0.3.2"],
"single_config_entry": true,
"zeroconf": [
{

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
from collections import Counter
from collections.abc import Awaitable, Callable
from typing import Literal, TypedDict
from typing import Literal, NotRequired, TypedDict
import voluptuous as vol
@@ -29,7 +29,7 @@ async def async_get_manager(hass: HomeAssistant) -> EnergyManager:
class FlowFromGridSourceType(TypedDict):
"""Dictionary describing the 'from' stat for the grid source."""
# statistic_id of a an energy meter (kWh)
# statistic_id of an energy meter (kWh)
stat_energy_from: str
# statistic_id of costs ($) incurred from the energy meter
@@ -58,6 +58,14 @@ class FlowToGridSourceType(TypedDict):
number_energy_price: float | None # Price for energy ($/kWh)
class GridPowerSourceType(TypedDict):
"""Dictionary holding the source of grid power consumption."""
# statistic_id of a power meter (kW)
# negative values indicate grid return
stat_rate: str
class GridSourceType(TypedDict):
"""Dictionary holding the source of grid energy consumption."""
@@ -65,6 +73,7 @@ class GridSourceType(TypedDict):
flow_from: list[FlowFromGridSourceType]
flow_to: list[FlowToGridSourceType]
power: NotRequired[list[GridPowerSourceType]]
cost_adjustment_day: float
@@ -75,6 +84,7 @@ class SolarSourceType(TypedDict):
type: Literal["solar"]
stat_energy_from: str
stat_rate: NotRequired[str]
config_entry_solar_forecast: list[str] | None
@@ -85,6 +95,8 @@ class BatterySourceType(TypedDict):
stat_energy_from: str
stat_energy_to: str
# positive when discharging, negative when charging
stat_rate: NotRequired[str]
class GasSourceType(TypedDict):
@@ -136,12 +148,15 @@ class DeviceConsumption(TypedDict):
# This is an ever increasing value
stat_consumption: str
# Instantaneous rate of flow: W, L/min or m³/h
stat_rate: NotRequired[str]
# An optional custom name for display in energy graphs
name: str | None
# An optional statistic_id identifying a device
# that includes this device's consumption in its total
included_in_stat: str | None
included_in_stat: NotRequired[str]
class EnergyPreferences(TypedDict):
@@ -194,6 +209,12 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
}
)
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("stat_rate"): str,
}
)
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
"""Generate a validator that ensures a value is only used once."""
@@ -224,6 +245,10 @@ GRID_SOURCE_SCHEMA = vol.Schema(
[FLOW_TO_GRID_SOURCE_SCHEMA],
_generate_unique_value_validator("stat_energy_to"),
),
vol.Optional("power"): vol.All(
[GRID_POWER_SOURCE_SCHEMA],
_generate_unique_value_validator("stat_rate"),
),
vol.Required("cost_adjustment_day"): vol.Coerce(float),
}
)
@@ -231,6 +256,7 @@ SOLAR_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("type"): "solar",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
}
)
@@ -239,6 +265,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
vol.Required("type"): "battery",
vol.Required("stat_energy_from"): str,
vol.Required("stat_energy_to"): str,
vol.Optional("stat_rate"): str,
}
)
GAS_SOURCE_SCHEMA = vol.Schema(
@@ -294,6 +321,7 @@ ENERGY_SOURCE_SCHEMA = vol.All(
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
{
vol.Required("stat_consumption"): str,
vol.Optional("stat_rate"): str,
vol.Optional("name"): str,
vol.Optional("included_in_stat"): str,
}

View File

@@ -12,6 +12,7 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfEnergy,
UnitOfPower,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, callback, valid_entity_id
@@ -23,12 +24,17 @@ ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
}
POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,)
POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = {
sensor.SensorDeviceClass.POWER: tuple(UnitOfPower)
}
ENERGY_PRICE_UNITS = tuple(
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
)
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price"
POWER_UNIT_ERROR = "entity_unexpected_unit_power"
GAS_USAGE_DEVICE_CLASSES = (
sensor.SensorDeviceClass.ENERGY,
sensor.SensorDeviceClass.GAS,
@@ -82,6 +88,10 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] |
f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS
),
}
if issue_type == POWER_UNIT_ERROR:
return {
"power_units": ", ".join(POWER_USAGE_UNITS[sensor.SensorDeviceClass.POWER]),
}
if issue_type == GAS_UNIT_ERROR:
return {
"energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
@@ -159,7 +169,7 @@ class EnergyPreferencesValidation:
@callback
def _async_validate_usage_stat(
def _async_validate_stat_common(
hass: HomeAssistant,
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
stat_id: str,
@@ -167,37 +177,41 @@ def _async_validate_usage_stat(
allowed_units: Mapping[str, Sequence[str]],
unit_error: str,
issues: ValidationIssues,
) -> None:
"""Validate a statistic."""
check_negative: bool = False,
) -> str | None:
"""Validate common aspects of a statistic.
Returns the entity_id if validation succeeds, None otherwise.
"""
if stat_id not in metadata:
issues.add_issue(hass, "statistics_not_defined", stat_id)
has_entity_source = valid_entity_id(stat_id)
if not has_entity_source:
return
return None
entity_id = stat_id
if not recorder.is_entity_recorded(hass, entity_id):
issues.add_issue(hass, "recorder_untracked", entity_id)
return
return None
if (state := hass.states.get(entity_id)) is None:
issues.add_issue(hass, "entity_not_defined", entity_id)
return
return None
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
return
return None
try:
current_value: float | None = float(state.state)
except ValueError:
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
return
return None
if current_value is not None and current_value < 0:
if check_negative and current_value is not None and current_value < 0:
issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
@@ -211,6 +225,36 @@ def _async_validate_usage_stat(
if device_class and unit not in allowed_units.get(device_class, []):
issues.add_issue(hass, unit_error, entity_id, unit)
return entity_id
@callback
def _async_validate_usage_stat(
hass: HomeAssistant,
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
stat_id: str,
allowed_device_classes: Sequence[str],
allowed_units: Mapping[str, Sequence[str]],
unit_error: str,
issues: ValidationIssues,
) -> None:
"""Validate a statistic."""
entity_id = _async_validate_stat_common(
hass,
metadata,
stat_id,
allowed_device_classes,
allowed_units,
unit_error,
issues,
check_negative=True,
)
if entity_id is None:
return
state = hass.states.get(entity_id)
assert state is not None
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
allowed_state_classes = [
@@ -255,6 +299,39 @@ def _async_validate_price_entity(
issues.add_issue(hass, unit_error, entity_id, unit)
@callback
def _async_validate_power_stat(
hass: HomeAssistant,
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
stat_id: str,
allowed_device_classes: Sequence[str],
allowed_units: Mapping[str, Sequence[str]],
unit_error: str,
issues: ValidationIssues,
) -> None:
"""Validate a power statistic."""
entity_id = _async_validate_stat_common(
hass,
metadata,
stat_id,
allowed_device_classes,
allowed_units,
unit_error,
issues,
check_negative=False,
)
if entity_id is None:
return
state = hass.states.get(entity_id)
assert state is not None
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
if state_class != sensor.SensorStateClass.MEASUREMENT:
issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class)
@callback
def _async_validate_cost_stat(
hass: HomeAssistant,
@@ -434,6 +511,21 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
)
)
for power_stat in source.get("power", []):
wanted_statistics_metadata.add(power_stat["stat_rate"])
validate_calls.append(
functools.partial(
_async_validate_power_stat,
hass,
statistics_metadata,
power_stat["stat_rate"],
POWER_USAGE_DEVICE_CLASSES,
POWER_USAGE_UNITS,
POWER_UNIT_ERROR,
source_result,
)
)
elif source["type"] == "gas":
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(

View File

@@ -481,6 +481,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
sidebar_title="climate",
sidebar_default_visible=False,
)
async_register_built_in_panel(
hass,
"home",
sidebar_icon="mdi:home",
sidebar_title="home",
sidebar_default_visible=False,
)
async_register_built_in_panel(hass, "profile")

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.0.0"]
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.1.0"]
}

View File

@@ -22,6 +22,6 @@
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.23.0"],
"requirements": ["aiohomeconnect==0.23.1"],
"zeroconf": ["_homeconnect._tcp.local."]
}

View File

@@ -412,8 +412,8 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
"""Set the program value."""
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
self._attr_current_option = (
PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value))
if event
PROGRAMS_TRANSLATION_KEYS_MAP.get(ProgramKey(event_value))
if event and isinstance(event_value := event.value, str)
else None
)

View File

@@ -556,8 +556,11 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
self._update_native_value(status)
def _update_native_value(self, status: str | float) -> None:
def _update_native_value(self, status: str | float | None) -> None:
"""Set the value of the sensor based on the given value."""
if status is None:
self._attr_native_value = None
return
match self.device_class:
case SensorDeviceClass.TIMESTAMP:
self._attr_native_value = dt_util.utcnow() + timedelta(

View File

@@ -76,9 +76,18 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
context: ConfigFlowContext
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
ZIGBEE_BAUDRATE = 460800
# Early ZBT-2 samples used RTS/DTR to trigger the bootloader, later ones use the
# baudrate method. Since the two are mutually exclusive we just use both.
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
]
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:

View File

@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.update import UpdateDeviceClass
from homeassistant.const import EntityCategory
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantConnectZBT2ConfigEntry
from .config_flow import ZBT2FirmwareMixin
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER
_LOGGER = logging.getLogger(__name__)
@@ -134,7 +134,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Connect ZBT-2 firmware update entity."""
bootloader_reset_methods = [ResetTarget.RTS_DTR]
BOOTLOADER_RESET_METHODS = ZBT2FirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = ZBT2FirmwareMixin.APPLICATION_PROBE_METHODS
def __init__(
self,

View File

@@ -81,6 +81,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]] = []
_picked_firmware_type: PickedFirmwareType
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
@@ -230,7 +231,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# 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)
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
@@ -295,6 +300,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
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
),

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
"universal-silabs-flasher==0.0.37",
"universal-silabs-flasher==0.1.0",
"ha-silabs-firmware-client==0.3.0"
]
}

View File

@@ -86,7 +86,8 @@ class BaseFirmwareUpdateEntity(
# Subclasses provide the mapping between firmware types and entity descriptions
entity_description: FirmwareUpdateEntityDescription
bootloader_reset_methods: list[ResetTarget] = []
BOOTLOADER_RESET_METHODS: list[ResetTarget]
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]]
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
@@ -278,7 +279,8 @@ class BaseFirmwareUpdateEntity(
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,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=self._update_progress,
domain=self._config_entry.domain,
)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
from collections.abc import AsyncIterator, Callable, Sequence
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from enum import StrEnum
@@ -309,15 +309,20 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
async def probe_silabs_firmware_info(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
device: str,
*,
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
) -> FirmwareInfo | None:
"""Probe the running firmware on a SiLabs device."""
flasher = Flasher(
device=device,
**(
{"probe_methods": [m.as_flasher_application_type() for m in probe_methods]}
if probe_methods
else {}
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
),
)
@@ -343,11 +348,18 @@ async def probe_silabs_firmware_info(
async def probe_silabs_firmware_type(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
device: str,
*,
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
) -> ApplicationType | None:
"""Probe the running firmware type on a SiLabs device."""
fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods)
fw_info = await probe_silabs_firmware_info(
device,
bootloader_reset_methods=bootloader_reset_methods,
application_probe_methods=application_probe_methods,
)
if fw_info is None:
return None
@@ -359,12 +371,22 @@ async def async_flash_silabs_firmware(
device: str,
fw_data: bytes,
expected_installed_firmware_type: ApplicationType,
bootloader_reset_methods: Sequence[ResetTarget] = (),
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."""
if not any(
method == expected_installed_firmware_type
for method, _ in application_probe_methods
):
raise ValueError(
f"Expected installed firmware type {expected_installed_firmware_type!r}"
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)
@@ -373,11 +395,9 @@ async def async_flash_silabs_firmware(
flasher = Flasher(
device=device,
probe_methods=(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
ApplicationType.EZSP.as_flasher_application_type(),
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
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
@@ -401,7 +421,13 @@ async def async_flash_silabs_firmware(
probed_firmware_info = await probe_silabs_firmware_info(
device,
probe_methods=(expected_installed_firmware_type,),
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:

View File

@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.helpers import (
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.usb import (
usb_service_info_from_device,
@@ -79,6 +80,20 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
context: ConfigFlowContext
ZIGBEE_BAUDRATE = 115200
# There is no hardware bootloader trigger
BOOTLOADER_RESET_METHODS: list[ResetTarget] = []
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
# CPC baudrates can be removed once multiprotocol is removed
(ApplicationType.CPC, 115200),
(ApplicationType.CPC, 230400),
(ApplicationType.CPC, 460800),
(ApplicationType.ROUTER, 115200),
]
def _get_translation_placeholders(self) -> dict[str, str]:
"""Shared translation placeholders."""
placeholders = {

View File

@@ -23,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantSkyConnectConfigEntry
from .config_flow import SkyConnectFirmwareMixin
from .const import (
DOMAIN,
FIRMWARE,
@@ -151,8 +152,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""SkyConnect firmware update entity."""
# The ZBT-1 does not have a hardware bootloader trigger
bootloader_reset_methods = []
BOOTLOADER_RESET_METHODS = SkyConnectFirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = SkyConnectFirmwareMixin.APPLICATION_PROBE_METHODS
def __init__(
self,

View File

@@ -82,7 +82,18 @@ else:
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Yellow firmware methods."""
ZIGBEE_BAUDRATE = 115200
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
# CPC baudrates can be removed once multiprotocol is removed
(ApplicationType.CPC, 115200),
(ApplicationType.CPC, 230400),
(ApplicationType.CPC, 460800),
(ApplicationType.ROUTER, 115200),
]
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
@@ -146,7 +157,11 @@ class HomeAssistantYellowConfigFlow(
assert self._device is not None
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
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,
)
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
if (

View File

@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.update import UpdateDeviceClass
from homeassistant.const import EntityCategory
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantYellowConfigEntry
from .config_flow import YellowFirmwareMixin
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
_LOGGER = logging.getLogger(__name__)
@@ -150,7 +150,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Yellow firmware update entity."""
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset
BOOTLOADER_RESET_METHODS = YellowFirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = YellowFirmwareMixin.APPLICATION_PROBE_METHODS
def __init__(
self,

View File

@@ -125,7 +125,7 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
await self.coordinator.device.update_firmware()
while (
update_progress := await self.coordinator.device.get_firmware()
).command_status is UpdateStatus.IN_PROGRESS:
).command_status is not UpdateStatus.UPDATED:
if counter >= MAX_UPDATE_WAIT:
_raise_timeout_error()
self._attr_update_percentage = update_progress.progress_percentage

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling",
"loggers": ["ical"],
"requirements": ["ical==11.0.0"]
"requirements": ["ical==11.1.0"]
}

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling",
"requirements": ["ical==11.0.0"]
"requirements": ["ical==11.1.0"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["lunatone-rest-api-client==0.5.3"]
"requirements": ["lunatone-rest-api-client==0.5.7"]
}

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/mill",
"iot_class": "local_polling",
"loggers": ["mill", "mill_local"],
"requirements": ["millheater==0.14.0", "mill-local==0.3.0"]
"requirements": ["millheater==0.14.1", "mill-local==0.3.0"]
}

View File

@@ -61,10 +61,12 @@ async def async_setup_entry(
async_add_entities([MobileAppBinarySensor(data, config_entry)])
async_dispatcher_connect(
hass,
f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
)
)

View File

@@ -72,10 +72,12 @@ async def async_setup_entry(
async_add_entities([MobileAppSensor(data, config_entry)])
async_dispatcher_connect(
hass,
f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
)
)

View File

@@ -73,6 +73,7 @@ ABBREVIATIONS = {
"fan_mode_stat_t": "fan_mode_state_topic",
"frc_upd": "force_update",
"g_tpl": "green_template",
"grp": "group",
"hs_cmd_t": "hs_command_topic",
"hs_cmd_tpl": "hs_command_template",
"hs_stat_t": "hs_state_topic",

View File

@@ -10,6 +10,7 @@ from homeassistant.helpers import config_validation as cv
from .const import (
CONF_COMMAND_TOPIC,
CONF_ENCODING,
CONF_GROUP,
CONF_QOS,
CONF_RETAIN,
CONF_STATE_TOPIC,
@@ -23,6 +24,7 @@ from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
SCHEMA_BASE = {
vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema,
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
vol.Optional(CONF_GROUP): vol.All(cv.ensure_list, [cv.string]),
}
MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE)

View File

@@ -110,6 +110,7 @@ CONF_FLASH_TIME_SHORT = "flash_time_short"
CONF_GET_POSITION_TEMPLATE = "position_template"
CONF_GET_POSITION_TOPIC = "position_topic"
CONF_GREEN_TEMPLATE = "green_template"
CONF_GROUP = "group"
CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
CONF_HS_COMMAND_TOPIC = "hs_command_topic"
CONF_HS_STATE_TOPIC = "hs_state_topic"

View File

@@ -79,6 +79,7 @@ from .const import (
CONF_ENABLED_BY_DEFAULT,
CONF_ENCODING,
CONF_ENTITY_PICTURE,
CONF_GROUP,
CONF_HW_VERSION,
CONF_IDENTIFIERS,
CONF_JSON_ATTRS_TEMPLATE,
@@ -136,6 +137,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
"device_class",
"device_info",
"entity_category",
"entity_id",
"entity_picture",
"entity_registry_enabled_default",
"extra_state_attributes",
@@ -475,6 +477,8 @@ class MqttAttributesMixin(Entity):
def __init__(self, config: ConfigType) -> None:
"""Initialize the JSON attributes mixin."""
self._attributes_sub_state: dict[str, EntitySubscription] = {}
if CONF_GROUP in config:
self._attr_included_unique_ids = config[CONF_GROUP]
self._attributes_config = config
async def async_added_to_hass(self) -> None:
@@ -546,7 +550,7 @@ class MqttAttributesMixin(Entity):
_LOGGER.warning("Erroneous JSON: %s", payload)
else:
if isinstance(json_dict, dict):
filtered_dict = {
filtered_dict: dict[str, Any] = {
k: v
for k, v in json_dict.items()
if k not in MQTT_ATTRIBUTES_BLOCKED

View File

@@ -109,7 +109,7 @@
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
},
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"update_failed": {
"message": "Failed to update drive state"

View File

@@ -36,7 +36,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -12,7 +12,7 @@ from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.attributes import InstrumentedAttribute
from ..const import SupportedDialect
from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE
from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE, MYSQL_COLLATE
from ..util import session_scope
if TYPE_CHECKING:
@@ -105,12 +105,13 @@ def _validate_table_schema_has_correct_collation(
or dialect_kwargs.get("mariadb_collate")
or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] # noqa: SLF001
)
if collate and collate != "utf8mb4_unicode_ci":
if collate and collate != MYSQL_COLLATE:
_LOGGER.debug(
"Database %s collation is not utf8mb4_unicode_ci",
"Database %s collation is not %s",
table,
MYSQL_COLLATE,
)
schema_errors.add(f"{table}.utf8mb4_unicode_ci")
schema_errors.add(f"{table}.{MYSQL_COLLATE}")
return schema_errors
@@ -240,7 +241,7 @@ def correct_db_schema_utf8(
table_name = table_object.__tablename__
if (
f"{table_name}.4-byte UTF-8" in schema_errors
or f"{table_name}.utf8mb4_unicode_ci" in schema_errors
or f"{table_name}.{MYSQL_COLLATE}" in schema_errors
):
from ..migration import ( # noqa: PLC0415
_correct_table_character_set_and_collation,

View File

@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration."""
SCHEMA_VERSION = 52
SCHEMA_VERSION = 53
_LOGGER = logging.getLogger(__name__)
@@ -128,7 +128,7 @@ LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX = "ix_states_entity_id_last_update
LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36
CONTEXT_ID_BIN_MAX_LENGTH = 16
MYSQL_COLLATE = "utf8mb4_unicode_ci"
MYSQL_COLLATE = "utf8mb4_bin"
MYSQL_DEFAULT_CHARSET = "utf8mb4"
MYSQL_ENGINE = "InnoDB"

View File

@@ -1361,7 +1361,7 @@ class _SchemaVersion20Migrator(_SchemaVersionMigrator, target_version=20):
class _SchemaVersion21Migrator(_SchemaVersionMigrator, target_version=21):
def _apply_update(self) -> None:
"""Version specific update method."""
# Try to change the character set of the statistic_meta table
# Try to change the character set of events, states and statistics_meta tables
if self.engine.dialect.name == SupportedDialect.MYSQL:
for table in ("events", "states", "statistics_meta"):
_correct_table_character_set_and_collation(table, self.session_maker)
@@ -2125,6 +2125,23 @@ class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
)
class _SchemaVersion53Migrator(_SchemaVersionMigrator, target_version=53):
def _apply_update(self) -> None:
"""Version specific update method."""
# Try to change the character set of events, states and statistics_meta tables
if self.engine.dialect.name == SupportedDialect.MYSQL:
for table in (
"events",
"event_data",
"states",
"state_attributes",
"statistics",
"statistics_meta",
"statistics_short_term",
):
_correct_table_character_set_and_collation(table, self.session_maker)
def _migrate_statistics_columns_to_timestamp_removing_duplicates(
hass: HomeAssistant,
instance: Recorder,
@@ -2167,8 +2184,10 @@ def _correct_table_character_set_and_collation(
"""Correct issues detected by validate_db_schema."""
# Attempt to convert the table to utf8mb4
_LOGGER.warning(
"Updating character set and collation of table %s to utf8mb4. %s",
"Updating table %s to character set %s and collation %s. %s",
table,
MYSQL_DEFAULT_CHARSET,
MYSQL_COLLATE,
MIGRATION_NOTE_MINUTES,
)
with (

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["ical"],
"quality_scale": "silver",
"requirements": ["ical==11.0.0"]
"requirements": ["ical==11.1.0"]
}

View File

@@ -3,10 +3,9 @@
from __future__ import annotations
import asyncio
from collections import OrderedDict
import logging
from satel_integra.satel_integra import AlarmState
from satel_integra.satel_integra import AlarmState, AsyncSatel
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
@@ -16,17 +15,31 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_ARM_HOME_MODE,
CONF_PARTITION_NUMBER,
DOMAIN,
SIGNAL_PANEL_MESSAGE,
SUBENTRY_TYPE_PARTITION,
SatelConfigEntry,
)
ALARM_STATE_MAP = {
AlarmState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
AlarmState.TRIGGERED_FIRE: AlarmControlPanelState.TRIGGERED,
AlarmState.ENTRY_TIME: AlarmControlPanelState.PENDING,
AlarmState.ARMED_MODE3: AlarmControlPanelState.ARMED_HOME,
AlarmState.ARMED_MODE2: AlarmControlPanelState.ARMED_HOME,
AlarmState.ARMED_MODE1: AlarmControlPanelState.ARMED_HOME,
AlarmState.ARMED_MODE0: AlarmControlPanelState.ARMED_AWAY,
AlarmState.EXIT_COUNTDOWN_OVER_10: AlarmControlPanelState.ARMING,
AlarmState.EXIT_COUNTDOWN_UNDER_10: AlarmControlPanelState.ARMING,
}
_LOGGER = logging.getLogger(__name__)
@@ -45,9 +58,9 @@ async def async_setup_entry(
)
for subentry in partition_subentries:
partition_num = subentry.data[CONF_PARTITION_NUMBER]
zone_name = subentry.data[CONF_NAME]
arm_home_mode = subentry.data[CONF_ARM_HOME_MODE]
partition_num: int = subentry.data[CONF_PARTITION_NUMBER]
zone_name: str = subentry.data[CONF_NAME]
arm_home_mode: int = subentry.data[CONF_ARM_HOME_MODE]
async_add_entities(
[
@@ -73,20 +86,31 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
| AlarmControlPanelEntityFeature.ARM_AWAY
)
_attr_has_entity_name = True
_attr_name = None
def __init__(
self, controller, name, arm_home_mode, partition_id, config_entry_id
self,
controller: AsyncSatel,
device_name: str,
arm_home_mode: int,
partition_id: int,
config_entry_id: str,
) -> None:
"""Initialize the alarm panel."""
self._attr_name = name
self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}"
self._arm_home_mode = arm_home_mode
self._partition_id = partition_id
self._satel = controller
self._attr_device_info = DeviceInfo(
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
)
async def async_added_to_hass(self) -> None:
"""Update alarm status and register callbacks for future updates."""
_LOGGER.debug("Starts listening for panel messages")
self._update_alarm_status()
self._attr_alarm_state = self._read_alarm_state()
self.async_on_remove(
async_dispatcher_connect(
self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status
@@ -94,55 +118,29 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
)
@callback
def _update_alarm_status(self):
def _update_alarm_status(self) -> None:
"""Handle alarm status update."""
state = self._read_alarm_state()
_LOGGER.debug("Got status update, current status: %s", state)
if state != self._attr_alarm_state:
self._attr_alarm_state = state
self.async_write_ha_state()
else:
_LOGGER.debug("Ignoring alarm status message, same state")
def _read_alarm_state(self):
def _read_alarm_state(self) -> AlarmControlPanelState | None:
"""Read current status of the alarm and translate it into HA status."""
# Default - disarmed:
hass_alarm_status = AlarmControlPanelState.DISARMED
if not self._satel.connected:
_LOGGER.debug("Alarm panel not connected")
return None
state_map = OrderedDict(
[
(AlarmState.TRIGGERED, AlarmControlPanelState.TRIGGERED),
(AlarmState.TRIGGERED_FIRE, AlarmControlPanelState.TRIGGERED),
(AlarmState.ENTRY_TIME, AlarmControlPanelState.PENDING),
(AlarmState.ARMED_MODE3, AlarmControlPanelState.ARMED_HOME),
(AlarmState.ARMED_MODE2, AlarmControlPanelState.ARMED_HOME),
(AlarmState.ARMED_MODE1, AlarmControlPanelState.ARMED_HOME),
(AlarmState.ARMED_MODE0, AlarmControlPanelState.ARMED_AWAY),
(
AlarmState.EXIT_COUNTDOWN_OVER_10,
AlarmControlPanelState.PENDING,
),
(
AlarmState.EXIT_COUNTDOWN_UNDER_10,
AlarmControlPanelState.PENDING,
),
]
)
_LOGGER.debug("State map of Satel: %s", self._satel.partition_states)
for satel_state, ha_state in state_map.items():
for satel_state, ha_state in ALARM_STATE_MAP.items():
if (
satel_state in self._satel.partition_states
and self._partition_id in self._satel.partition_states[satel_state]
):
hass_alarm_status = ha_state
break
return ha_state
return hass_alarm_status
return AlarmControlPanelState.DISARMED
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
@@ -154,8 +152,6 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
self._attr_alarm_state == AlarmControlPanelState.TRIGGERED
)
_LOGGER.debug("Disarming, self._attr_alarm_state: %s", self._attr_alarm_state)
await self._satel.disarm(code, [self._partition_id])
if clear_alarm_necessary:
@@ -165,14 +161,12 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
_LOGGER.debug("Arming away")
if code:
await self._satel.arm(code, [self._partition_id])
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
_LOGGER.debug("Arming home")
if code:
await self._satel.arm(code, [self._partition_id], self._arm_home_mode)

View File

@@ -118,6 +118,9 @@
"pm25": {
"default": "mdi:molecule"
},
"pm4": {
"default": "mdi:molecule"
},
"power": {
"default": "mdi:flash"
},

View File

@@ -32,9 +32,10 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
type SENZConfigEntry = ConfigEntry[SENZDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
"""Set up SENZ from a config entry."""
try:
implementation = await async_get_config_entry_implementation(hass, entry)
@@ -71,16 +72,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -12,24 +12,23 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SENZDataUpdateCoordinator
from . import SENZConfigEntry, SENZDataUpdateCoordinator
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: SENZConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SENZ climate entities from a config entry."""
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities(
SENZClimate(thermostat, coordinator) for thermostat in coordinator.data.values()
)

View File

@@ -3,10 +3,9 @@
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from . import SENZConfigEntry
TO_REDACT = [
"access_token",
@@ -15,13 +14,11 @@ TO_REDACT = [
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
hass: HomeAssistant, entry: SENZConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
raw_data = (
[device.raw_data for device in hass.data[DOMAIN][entry.entry_id].data.values()],
)
raw_data = ([device.raw_data for device in entry.runtime_data.data.values()],)
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),

View File

@@ -13,14 +13,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SENZDataUpdateCoordinator
from . import SENZConfigEntry, SENZDataUpdateCoordinator
from .const import DOMAIN
@@ -45,11 +44,11 @@ SENSORS: tuple[SenzSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: SENZConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SENZ sensor entities from a config entry."""
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities(
SENZSensor(thermostat, coordinator, description)
for description in SENSORS

View File

@@ -663,7 +663,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"issues": {

View File

@@ -34,7 +34,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"system_health": {

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/tesla_wall_connector",
"iot_class": "local_polling",
"loggers": ["tesla_wall_connector"],
"requirements": ["tesla-wall-connector==1.0.2"]
"requirements": ["tesla-wall-connector==1.1.0"]
}

View File

@@ -237,7 +237,7 @@ class TeslemetryStreamingUpdateEntity(
if self._download_percentage > 1 and self._download_percentage < 100:
self._attr_in_progress = True
self._attr_update_percentage = self._download_percentage
elif self._install_percentage > 1:
elif self._install_percentage > 10:
self._attr_in_progress = True
self._attr_update_percentage = self._install_percentage
else:

View File

@@ -2,9 +2,7 @@
from __future__ import annotations
from contextlib import suppress
import json
from typing import Any, cast
from typing import Any
from tuya_sharing import CustomerDevice
@@ -101,30 +99,20 @@ def _async_device_as_dict(
data["status"][dpcode] = REDACTED
continue
with suppress(ValueError, TypeError):
value = json.loads(value)
data["status"][dpcode] = value
# Gather Tuya functions
for function in device.function.values():
value = function.values
with suppress(ValueError, TypeError, AttributeError):
value = json.loads(cast(str, function.values))
data["function"][function.code] = {
"type": function.type,
"value": value,
"value": function.values,
}
# Gather Tuya status ranges
for status_range in device.status_range.values():
value = status_range.values
with suppress(ValueError, TypeError, AttributeError):
value = json.loads(status_range.values)
data["status_range"][status_range.code] = {
"type": status_range.type,
"value": value,
"value": status_range.values,
}
# Gather information how this Tuya device is represented in Home Assistant

View File

@@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from homeassistant.util.json import json_loads_object
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode
@@ -499,11 +500,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
values = self.device.status_range[dpcode].values
# Fetch color data type information
if function_data := json.loads(values):
if function_data := json_loads_object(values):
self._color_data_type = ColorTypeData(
h_type=IntegerTypeData(dpcode, **function_data["h"]),
s_type=IntegerTypeData(dpcode, **function_data["s"]),
v_type=IntegerTypeData(dpcode, **function_data["v"]),
h_type=IntegerTypeData(dpcode, **cast(dict, function_data["h"])),
s_type=IntegerTypeData(dpcode, **cast(dict, function_data["s"])),
v_type=IntegerTypeData(dpcode, **cast(dict, function_data["v"])),
)
else:
# If no type is found, use a default one
@@ -770,12 +771,12 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
if not (status_data := self.device.status[self._color_data_dpcode]):
return None
if not (status := json.loads(status_data)):
if not (status := json_loads_object(status_data)):
return None
return ColorData(
type_data=self._color_data_type,
h_value=status["h"],
s_value=status["s"],
v_value=status["v"],
h_value=cast(int, status["h"]),
s_value=cast(int, status["s"]),
v_value=cast(int, status["v"]),
)

View File

@@ -5,12 +5,11 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import base64
from dataclasses import dataclass
import json
from typing import Any, Literal, Self, overload
from typing import Any, Literal, Self, cast, overload
from tuya_sharing import CustomerDevice
from homeassistant.util.json import json_loads
from homeassistant.util.json import json_loads, json_loads_object
from .const import DPCode, DPType
from .util import parse_dptype, remap_value
@@ -88,7 +87,7 @@ class IntegerTypeData(TypeInformation):
@classmethod
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
"""Load JSON string and return a IntegerTypeData object."""
if not (parsed := json.loads(data)):
if not (parsed := cast(dict[str, Any] | None, json_loads_object(data))):
return None
return cls(
@@ -111,9 +110,9 @@ class BitmapTypeInformation(TypeInformation):
@classmethod
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
"""Load JSON string and return a BitmapTypeInformation object."""
if not (parsed := json.loads(data)):
if not (parsed := json_loads_object(data)):
return None
return cls(dpcode, **parsed)
return cls(dpcode, **cast(dict[str, list[str]], parsed))
@dataclass
@@ -125,9 +124,9 @@ class EnumTypeData(TypeInformation):
@classmethod
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
"""Load JSON string and return a EnumTypeData object."""
if not (parsed := json.loads(data)):
if not (parsed := json_loads_object(data)):
return None
return cls(dpcode, **parsed)
return cls(dpcode, **cast(dict[str, list[str]], parsed))
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {

View File

@@ -61,7 +61,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -5,7 +5,12 @@ from __future__ import annotations
from pyvlx import PyVLX, PyVLXException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
CONF_PASSWORD,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, issue_registry as ir
@@ -30,6 +35,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
entry.runtime_data = pyvlx
connections = None
if (mac := entry.data.get(CONF_MAC)) is not None:
connections = {(dr.CONNECTION_NETWORK_MAC, mac)}
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
@@ -43,6 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
sw_version=(
str(pyvlx.klf200.version.softwareversion) if pyvlx.klf200.version else None
),
connections=connections,
)
async def on_hass_stop(event):

View File

@@ -14,6 +14,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VeluxConfigEntry
from .const import DOMAIN
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,

View File

@@ -37,9 +37,7 @@ rules:
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates:
status: todo
comment: button still needs it
parallel-updates: done
reauthentication-flow: todo
test-coverage:
status: todo

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/vesync",
"iot_class": "cloud_polling",
"loggers": ["pyvesync"],
"requirements": ["pyvesync==3.2.1"]
"requirements": ["pyvesync==3.2.2"]
}

View File

@@ -144,6 +144,11 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = (
device_class=BinarySensorDeviceClass.DOOR,
value_getter=lambda api: api.isValveOpen(),
),
ViCareBinarySensorEntityDescription(
key="ventilation_frost_protection",
translation_key="ventilation_frost_protection",
value_getter=lambda api: api.getHeatExchangerFrostProtectionActive(),
),
)

View File

@@ -35,6 +35,7 @@ CONF_HEATING_TYPE = "heating_type"
DEFAULT_CACHE_DURATION = 60
VICARE_BAR = "bar"
VICARE_CELSIUS = "celsius"
VICARE_CUBIC_METER = "cubicMeter"
VICARE_KW = "kilowatt"
VICARE_KWH = "kilowattHour"

View File

@@ -16,6 +16,15 @@
"domestic_hot_water_pump": {
"default": "mdi:pump"
},
"filter_hours": {
"default": "mdi:counter"
},
"filter_overdue_hours": {
"default": "mdi:counter"
},
"filter_remaining_hours": {
"default": "mdi:counter"
},
"frost_protection": {
"default": "mdi:snowflake"
},
@@ -28,6 +37,12 @@
"solar_pump": {
"default": "mdi:pump"
},
"supply_fan_hours": {
"default": "mdi:counter"
},
"supply_fan_speed": {
"default": "mdi:rotate-right"
},
"valve": {
"default": "mdi:pipe-valve"
}
@@ -101,6 +116,12 @@
"ess_state_of_charge": {
"default": "mdi:home-battery"
},
"heating_rod_hours": {
"default": "mdi:counter"
},
"heating_rod_starts": {
"default": "mdi:counter"
},
"pcc_energy_consumption": {
"default": "mdi:transmission-tower-export"
},
@@ -116,9 +137,15 @@
"valve_position": {
"default": "mdi:pipe-valve"
},
"ventilation_input_volumeflow": {
"default": "mdi:air-filter"
},
"ventilation_level": {
"default": "mdi:fan"
},
"ventilation_output_volumeflow": {
"default": "mdi:air-filter"
},
"volumetric_flow": {
"default": "mdi:gauge"
},

View File

@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["PyViCare"],
"requirements": ["PyViCare==2.54.0"]
"requirements": ["PyViCare==2.55.0"]
}

View File

@@ -26,7 +26,9 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
EntityCategory,
UnitOfEnergy,
UnitOfMass,
@@ -42,6 +44,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
VICARE_BAR,
VICARE_CELSIUS,
VICARE_CUBIC_METER,
VICARE_KW,
VICARE_KWH,
@@ -56,7 +59,9 @@ from .utils import (
get_burners,
get_circuits,
get_compressors,
get_condensors,
get_device_serial,
get_evaporators,
is_supported,
normalize_state,
)
@@ -74,6 +79,7 @@ VICARE_UNIT_TO_DEVICE_CLASS = {
VICARE_UNIT_TO_HA_UNIT = {
VICARE_BAR: UnitOfPressure.BAR,
VICARE_CELSIUS: UnitOfTemperature.CELSIUS,
VICARE_CUBIC_METER: UnitOfVolume.CUBIC_METERS,
VICARE_KW: UnitOfPower.KILO_WATT,
VICARE_KWH: UnitOfEnergy.KILO_WATT_HOUR,
@@ -111,6 +117,14 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
ViCareSensorEntityDescription(
key="outside_humidity",
translation_key="outside_humidity",
native_unit_of_measurement=PERCENTAGE,
value_getter=lambda api: api.getOutsideHumidity(),
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
ViCareSensorEntityDescription(
key="return_temperature",
translation_key="return_temperature",
@@ -992,6 +1006,101 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
value_getter=lambda api: api.getHydraulicSeparatorTemperature(),
),
SUPPLY_TEMPERATURE_SENSOR,
ViCareSensorEntityDescription(
key="supply_humidity",
translation_key="supply_humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_getter=lambda api: api.getSupplyHumidity(),
),
ViCareSensorEntityDescription(
key="supply_fan_hours",
translation_key="supply_fan_hours",
native_unit_of_measurement=UnitOfTime.HOURS,
value_getter=lambda api: api.getSupplyFanHours(),
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="supply_fan_speed",
translation_key="supply_fan_speed",
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
value_getter=lambda api: api.getSupplyFanSpeed(),
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="filter_hours",
translation_key="filter_hours",
native_unit_of_measurement=UnitOfTime.HOURS,
value_getter=lambda api: api.getFilterHours(),
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="filter_remaining_hours",
translation_key="filter_remaining_hours",
native_unit_of_measurement=UnitOfTime.HOURS,
value_getter=lambda api: api.getFilterRemainingHours(),
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="filter_overdue_hours",
translation_key="filter_overdue_hours",
native_unit_of_measurement=UnitOfTime.HOURS,
value_getter=lambda api: api.getFilterOverdueHours(),
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="pm01",
device_class=SensorDeviceClass.PM1,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
value_getter=lambda api: api.getAirborneDustPM1(),
),
ViCareSensorEntityDescription(
key="pm02",
device_class=SensorDeviceClass.PM25,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
value_getter=lambda api: api.getAirborneDustPM2d5(),
),
ViCareSensorEntityDescription(
key="pm04",
device_class=SensorDeviceClass.PM4,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
value_getter=lambda api: api.getAirborneDustPM4(),
),
ViCareSensorEntityDescription(
key="pm10",
device_class=SensorDeviceClass.PM10,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
value_getter=lambda api: api.getAirborneDustPM10(),
),
ViCareSensorEntityDescription(
key="ventilation_input_volumeflow",
translation_key="ventilation_input_volumeflow",
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
value_getter=lambda api: api.getSupplyVolumeFlow(),
state_class=SensorStateClass.MEASUREMENT,
),
ViCareSensorEntityDescription(
key="ventilation_output_volumeflow",
translation_key="ventilation_output_volumeflow",
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
value_getter=lambda api: api.getExhaustVolumeFlow(),
state_class=SensorStateClass.MEASUREMENT,
),
)
CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
@@ -1090,6 +1199,84 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
value_getter=lambda api: normalize_state(api.getPhase()),
entity_category=EntityCategory.DIAGNOSTIC,
),
ViCareSensorEntityDescription(
key="compressor_inlet_temperature",
translation_key="compressor_inlet_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_getter=lambda api: api.getCompressorInletTemperature(),
unit_getter=lambda api: api.getCompressorInletTemperatureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="compressor_outlet_temperature",
translation_key="compressor_outlet_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_getter=lambda api: api.getCompressorOutletTemperature(),
unit_getter=lambda api: api.getCompressorOutletTemperatureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="compressor_inlet_pressure",
translation_key="compressor_inlet_pressure",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.BAR,
value_getter=lambda api: api.getCompressorInletPressure(),
unit_getter=lambda api: api.getCompressorInletPressureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="compressor_outlet_pressure",
translation_key="compressor_outlet_pressure",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.BAR,
value_getter=lambda api: api.getCompressorOutletPressure(),
unit_getter=lambda api: api.getCompressorOutletPressureUnit(),
entity_registry_enabled_default=False,
),
)
CONDENSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
ViCareSensorEntityDescription(
key="condensor_liquid_temperature",
translation_key="condensor_liquid_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_getter=lambda api: api.getCondensorLiquidTemperature(),
unit_getter=lambda api: api.getCondensorLiquidTemperatureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="condensor_subcooling_temperature",
translation_key="condensor_subcooling_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_getter=lambda api: api.getCondensorSubcoolingTemperature(),
unit_getter=lambda api: api.getCondensorSubcoolingTemperatureUnit(),
entity_registry_enabled_default=False,
),
)
EVAPORATOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
ViCareSensorEntityDescription(
key="evaporator_overheat_temperature",
translation_key="evaporator_overheat_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_getter=lambda api: api.getEvaporatorOverheatTemperature(),
unit_getter=lambda api: api.getEvaporatorOverheatTemperatureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="evaporator_liquid_temperature",
translation_key="evaporator_liquid_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_getter=lambda api: api.getEvaporatorLiquidTemperature(),
unit_getter=lambda api: api.getEvaporatorLiquidTemperatureUnit(),
entity_registry_enabled_default=False,
),
)
@@ -1116,6 +1303,8 @@ def _build_entities(
(get_circuits(device.api), CIRCUIT_SENSORS),
(get_burners(device.api), BURNER_SENSORS),
(get_compressors(device.api), COMPRESSOR_SENSORS),
(get_condensors(device.api), CONDENSOR_SENSORS),
(get_evaporators(device.api), EVAPORATOR_SENSORS),
):
entities.extend(
ViCareSensor(

View File

@@ -78,6 +78,9 @@
},
"valve": {
"name": "Valve"
},
"ventilation_frost_protection": {
"name": "Ventilation frost protection"
}
},
"button": {
@@ -212,6 +215,18 @@
"compressor_hours_loadclass5": {
"name": "Compressor hours load class 5"
},
"compressor_inlet_pressure": {
"name": "Compressor inlet pressure"
},
"compressor_inlet_temperature": {
"name": "Compressor inlet temperature"
},
"compressor_outlet_pressure": {
"name": "Compressor outlet pressure"
},
"compressor_outlet_temperature": {
"name": "Compressor outlet temperature"
},
"compressor_phase": {
"name": "Compressor phase",
"state": {
@@ -229,6 +244,12 @@
"compressor_starts": {
"name": "Compressor starts"
},
"condensor_liquid_temperature": {
"name": "Condensor liquid temperature"
},
"condensor_subcooling_temperature": {
"name": "Condensor subcooling temperature"
},
"dhw_storage_bottom_temperature": {
"name": "DHW storage bottom temperature"
},
@@ -303,6 +324,21 @@
"standby": "[%key:common::state::standby%]"
}
},
"evaporator_liquid_temperature": {
"name": "Evaporator liquid temperature"
},
"evaporator_overheat_temperature": {
"name": "Evaporator overheat temperature"
},
"filter_hours": {
"name": "Filter hours"
},
"filter_overdue_hours": {
"name": "Filter overdue hours"
},
"filter_remaining_hours": {
"name": "Filter remaining hours"
},
"fuel_need": {
"name": "Fuel need"
},
@@ -396,6 +432,9 @@
"hydraulic_separator_temperature": {
"name": "Hydraulic separator temperature"
},
"outside_humidity": {
"name": "Outside humidity"
},
"outside_temperature": {
"name": "Outside temperature"
},
@@ -499,6 +538,15 @@
"spf_total": {
"name": "Seasonal performance factor"
},
"supply_fan_hours": {
"name": "Supply fan hours"
},
"supply_fan_speed": {
"name": "Supply fan speed"
},
"supply_humidity": {
"name": "Supply humidity"
},
"supply_pressure": {
"name": "Supply pressure"
},
@@ -508,6 +556,9 @@
"valve_position": {
"name": "Valve position"
},
"ventilation_input_volumeflow": {
"name": "Ventilation input volume flow"
},
"ventilation_level": {
"name": "Ventilation level",
"state": {
@@ -518,6 +569,9 @@
"standby": "[%key:common::state::standby%]"
}
},
"ventilation_output_volumeflow": {
"name": "Ventilation output volume flow"
},
"ventilation_reason": {
"name": "Ventilation reason",
"state": {

View File

@@ -130,6 +130,28 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone
return []
def get_condensors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]:
"""Return the list of condensors."""
try:
return device.condensors
except PyViCareNotSupportedFeatureError:
_LOGGER.debug("No condensors found")
except AttributeError as error:
_LOGGER.debug("No condensors found: %s", error)
return []
def get_evaporators(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]:
"""Return the list of evaporators."""
try:
return device.evaporators
except PyViCareNotSupportedFeatureError:
_LOGGER.debug("No evaporators found")
except AttributeError as error:
_LOGGER.debug("No evaporators found: %s", error)
return []
def filter_state(state: str) -> str | None:
"""Return the state if not 'nothing' or 'unknown'."""
return None if state in ("nothing", "unknown") else state

View File

@@ -363,7 +363,7 @@
"message": "Unable to retrieve vehicle details."
},
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"unauthorized": {
"message": "Authentication failed. {message}"

View File

@@ -334,7 +334,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -9,7 +9,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
from .coordinator import XboxConfigEntry, XboxUpdateCoordinator
from .coordinator import (
XboxConfigEntry,
XboxConsolesCoordinator,
XboxCoordinators,
XboxUpdateCoordinator,
)
_LOGGER = logging.getLogger(__name__)
@@ -30,7 +35,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bool
coordinator = XboxUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
consoles = XboxConsolesCoordinator(hass, entry, coordinator)
entry.runtime_data = XboxCoordinators(coordinator, consoles)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -53,16 +60,14 @@ async def async_migrate_unique_id(hass: HomeAssistant, entry: XboxConfigEntry) -
if entry.version == 1 and entry.minor_version < 2:
# Migrate unique_id from `xbox` to account xuid and
# change generic entry name to user's gamertag
coordinator = entry.runtime_data.status
xuid = coordinator.client.xuid
gamertag = coordinator.data.presence[xuid].gamertag
return hass.config_entries.async_update_entry(
entry,
unique_id=entry.runtime_data.client.xuid,
title=(
entry.runtime_data.data.presence[
entry.runtime_data.client.xuid
].gamertag
if entry.title == "Home Assistant Cloud"
else entry.title
),
unique_id=xuid,
title=(gamertag if entry.title == "Home Assistant Cloud" else entry.title),
minor_version=2,
)

View File

@@ -44,7 +44,6 @@ class XboxBinarySensorEntityDescription(
"""Xbox binary sensor description."""
is_on_fn: Callable[[Person], bool | None]
deprecated: bool | None = None
def profile_attributes(person: Person, _: Title | None) -> dict[str, Any]:
@@ -112,7 +111,7 @@ async def async_setup_entry(
) -> None:
"""Set up Xbox Live friends."""
xuids_added: set[str] = set()
coordinator = entry.runtime_data
coordinator = entry.runtime_data.status
@callback
def add_entities() -> None:
@@ -120,16 +119,16 @@ async def async_setup_entry(
current_xuids = set(coordinator.data.presence)
if new_xuids := current_xuids - xuids_added:
for xuid in new_xuids:
async_add_entities(
[
XboxBinarySensorEntity(coordinator, xuid, description)
for description in SENSOR_DESCRIPTIONS
if check_deprecated_entity(
hass, xuid, description, BINARY_SENSOR_DOMAIN
)
]
)
async_add_entities(
[
XboxBinarySensorEntity(coordinator, xuid, description)
for xuid in new_xuids
for description in SENSOR_DESCRIPTIONS
if check_deprecated_entity(
hass, xuid, description, BINARY_SENSOR_DOMAIN
)
]
)
xuids_added |= new_xuids
xuids_added &= current_xuids

View File

@@ -4,5 +4,3 @@ DOMAIN = "xbox"
OAUTH2_AUTHORIZE = "https://login.live.com/oauth20_authorize.srf"
OAUTH2_TOKEN = "https://login.live.com/oauth20_token.srf"
EVENT_NEW_FAVORITE = "xbox/new_favorite"

View File

@@ -35,7 +35,7 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type XboxConfigEntry = ConfigEntry[XboxUpdateCoordinator]
type XboxConfigEntry = ConfigEntry[XboxCoordinators]
@dataclass
@@ -55,17 +55,25 @@ class XboxData:
title_info: dict[str, Title] = field(default_factory=dict)
@dataclass
class XboxCoordinators:
"""Xbox coordinators."""
status: XboxUpdateCoordinator
consoles: XboxConsolesCoordinator
class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]):
"""Store Xbox Console Status."""
config_entry: ConfigEntry
config_entry: XboxConfigEntry
consoles: SmartglassConsoleList
client: XboxLiveClient
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: XboxConfigEntry,
) -> None:
"""Initialize."""
super().__init__(
@@ -280,3 +288,43 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]):
for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry.unique_id is not None
}
class XboxConsolesCoordinator(DataUpdateCoordinator[SmartglassConsoleList]):
"""Update list of Xbox consoles."""
config_entry: XboxConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: XboxConfigEntry,
coordinator: XboxUpdateCoordinator,
) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(minutes=10),
)
self.client = coordinator.client
self.async_set_updated_data(coordinator.consoles)
async def _async_update_data(self) -> SmartglassConsoleList:
"""Fetch console data."""
try:
return await self.client.smartglass.get_console_list()
except TimeoutException as e:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from e
except (RequestError, HTTPStatusError) as e:
_LOGGER.debug("Xbox exception:", exc_info=True)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="request_exception",
) from e

View File

@@ -94,8 +94,7 @@ class XboxBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]):
"""Return entity specific state attributes."""
return (
fn(self.data, self.title_info)
if hasattr(self.entity_description, "attributes_fn")
and (fn := self.entity_description.attributes_fn)
if (fn := self.entity_description.attributes_fn)
else super().extra_state_attributes
)
@@ -122,7 +121,7 @@ class XboxConsoleBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, console.id)},
manufacturer="Microsoft",
model=MAP_MODEL.get(self._console.console_type, "Unknown"),
model=MAP_MODEL.get(self._console.console_type),
name=console.name,
)
@@ -135,11 +134,11 @@ class XboxConsoleBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]):
def check_deprecated_entity(
hass: HomeAssistant,
xuid: str,
entity_description: EntityDescription,
entity_description: XboxBaseEntityDescription,
entity_domain: str,
) -> bool:
"""Check for deprecated entity and remove it."""
if not getattr(entity_description, "deprecated", False):
if not entity_description.deprecated:
return True
ent_reg = er.async_get(hass)
if entity_id := ent_reg.async_get_entity_id(

View File

@@ -64,7 +64,7 @@ async def async_setup_entry(
) -> None:
"""Set up Xbox images."""
coordinator = config_entry.runtime_data
coordinator = config_entry.runtime_data.status
xuids_added: set[str] = set()

View File

@@ -3,7 +3,7 @@
"name": "Xbox",
"codeowners": ["@hunterjm", "@tr4nt0r"],
"config_flow": true,
"dependencies": ["auth", "application_credentials"],
"dependencies": ["application_credentials"],
"dhcp": [
{
"hostname": "xbox*"

View File

@@ -56,7 +56,7 @@ async def async_setup_entry(
) -> None:
"""Set up Xbox media_player from a config entry."""
coordinator = entry.runtime_data
coordinator = entry.runtime_data.status
async_add_entities(
[

View File

@@ -112,7 +112,7 @@ class XboxSource(MediaSource):
translation_key="account_not_configured",
) from e
client = entry.runtime_data.client
client = entry.runtime_data.status.client
if identifier.media_type in (ATTR_GAMECLIPS, ATTR_COMMUNITY_GAMECLIPS):
try:
@@ -302,7 +302,7 @@ class XboxSource(MediaSource):
async def _build_games(self, entry: XboxConfigEntry) -> list[BrowseMediaSource]:
"""List Xbox games for the selected account."""
client = entry.runtime_data.client
client = entry.runtime_data.status.client
if TYPE_CHECKING:
assert entry.unique_id
fields = [
@@ -346,7 +346,7 @@ class XboxSource(MediaSource):
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> BrowseMediaSource:
"""Display game title."""
client = entry.runtime_data.client
client = entry.runtime_data.status.client
try:
game = (await client.titlehub.get_title_info(identifier.title_id)).titles[0]
except TimeoutException as e:
@@ -402,7 +402,7 @@ class XboxSource(MediaSource):
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> BrowseMediaSource:
"""List game media."""
client = entry.runtime_data.client
client = entry.runtime_data.status.client
try:
game = (await client.titlehub.get_title_info(identifier.title_id)).titles[0]
except TimeoutException as e:
@@ -439,7 +439,7 @@ class XboxSource(MediaSource):
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> list[BrowseMediaSource]:
"""List media items."""
client = entry.runtime_data.client
client = entry.runtime_data.status.client
if identifier.media_type != ATTR_GAMECLIPS:
return []
@@ -483,7 +483,7 @@ class XboxSource(MediaSource):
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> list[BrowseMediaSource]:
"""List media items."""
client = entry.runtime_data.client
client = entry.runtime_data.status.client
if identifier.media_type != ATTR_COMMUNITY_GAMECLIPS:
return []
@@ -527,7 +527,7 @@ class XboxSource(MediaSource):
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> list[BrowseMediaSource]:
"""List media items."""
client = entry.runtime_data.client
client = entry.runtime_data.status.client
if identifier.media_type != ATTR_SCREENSHOTS:
return []
@@ -571,7 +571,7 @@ class XboxSource(MediaSource):
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> list[BrowseMediaSource]:
"""List media items."""
client = entry.runtime_data.client
client = entry.runtime_data.status.client
if identifier.media_type != ATTR_COMMUNITY_SCREENSHOTS:
return []
@@ -640,7 +640,7 @@ class XboxSource(MediaSource):
def gamerpic(config_entry: XboxConfigEntry) -> str | None:
"""Return gamerpic."""
coordinator = config_entry.runtime_data
coordinator = config_entry.runtime_data.status
if TYPE_CHECKING:
assert config_entry.unique_id
person = coordinator.data.presence[coordinator.client.xuid]

View File

@@ -27,7 +27,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Xbox media_player from a config entry."""
coordinator = entry.runtime_data
coordinator = entry.runtime_data.status
async_add_entities(
[XboxRemote(console, coordinator) for console in coordinator.consoles.result]

View File

@@ -9,20 +9,32 @@ from enum import StrEnum
from typing import Any
from pythonxbox.api.provider.people.models import Person
from pythonxbox.api.provider.smartglass.models import SmartglassConsole, StorageDevice
from pythonxbox.api.provider.titlehub.models import Title
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
EntityCategory,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import CONF_NAME, UnitOfInformation
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import XboxConfigEntry
from .entity import XboxBaseEntity, XboxBaseEntityDescription, check_deprecated_entity
from .const import DOMAIN
from .coordinator import XboxConfigEntry, XboxConsolesCoordinator
from .entity import (
MAP_MODEL,
XboxBaseEntity,
XboxBaseEntityDescription,
check_deprecated_entity,
)
MAP_JOIN_RESTRICTIONS = {
"local": "invite_only",
@@ -44,6 +56,8 @@ class XboxSensor(StrEnum):
FRIENDS = "friends"
IN_PARTY = "in_party"
JOIN_RESTRICTIONS = "join_restrictions"
TOTAL_STORAGE = "total_storage"
FREE_STORAGE = "free_storage"
@dataclass(kw_only=True, frozen=True)
@@ -51,7 +65,15 @@ class XboxSensorEntityDescription(XboxBaseEntityDescription, SensorEntityDescrip
"""Xbox sensor description."""
value_fn: Callable[[Person, Title | None], StateType | datetime]
deprecated: bool | None = None
@dataclass(kw_only=True, frozen=True)
class XboxStorageDeviceSensorEntityDescription(
XboxBaseEntityDescription, SensorEntityDescription
):
"""Xbox console sensor description."""
value_fn: Callable[[StorageDevice], StateType]
def now_playing_attributes(_: Person, title: Title | None) -> dict[str, Any]:
@@ -196,6 +218,31 @@ SENSOR_DESCRIPTIONS: tuple[XboxSensorEntityDescription, ...] = (
),
)
STORAGE_SENSOR_DESCRIPTIONS: tuple[XboxStorageDeviceSensorEntityDescription, ...] = (
XboxStorageDeviceSensorEntityDescription(
key=XboxSensor.TOTAL_STORAGE,
translation_key=XboxSensor.TOTAL_STORAGE,
value_fn=lambda x: x.total_space_bytes,
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=0,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
XboxStorageDeviceSensorEntityDescription(
key=XboxSensor.FREE_STORAGE,
translation_key=XboxSensor.FREE_STORAGE,
value_fn=lambda x: x.free_space_bytes,
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=0,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -204,7 +251,7 @@ async def async_setup_entry(
) -> None:
"""Set up Xbox Live friends."""
xuids_added: set[str] = set()
coordinator = config_entry.runtime_data
coordinator = config_entry.runtime_data.status
@callback
def add_entities() -> None:
@@ -212,22 +259,34 @@ async def async_setup_entry(
current_xuids = set(coordinator.data.presence)
if new_xuids := current_xuids - xuids_added:
for xuid in new_xuids:
async_add_entities(
[
XboxSensorEntity(coordinator, xuid, description)
for description in SENSOR_DESCRIPTIONS
if check_deprecated_entity(
hass, xuid, description, SENSOR_DOMAIN
)
]
)
async_add_entities(
[
XboxSensorEntity(coordinator, xuid, description)
for xuid in new_xuids
for description in SENSOR_DESCRIPTIONS
if check_deprecated_entity(hass, xuid, description, SENSOR_DOMAIN)
]
)
xuids_added |= new_xuids
xuids_added &= current_xuids
coordinator.async_add_listener(add_entities)
add_entities()
consoles_coordinator = config_entry.runtime_data.consoles
async_add_entities(
[
XboxStorageDeviceSensorEntity(
console, storage_device, consoles_coordinator, description
)
for description in STORAGE_SENSOR_DESCRIPTIONS
for console in coordinator.consoles.result
if console.storage_devices
for storage_device in console.storage_devices
]
)
class XboxSensorEntity(XboxBaseEntity, SensorEntity):
"""Representation of a Xbox presence state."""
@@ -238,3 +297,62 @@ class XboxSensorEntity(XboxBaseEntity, SensorEntity):
def native_value(self) -> StateType | datetime:
"""Return the state of the requested attribute."""
return self.entity_description.value_fn(self.data, self.title_info)
class XboxStorageDeviceSensorEntity(
CoordinatorEntity[XboxConsolesCoordinator], SensorEntity
):
"""Console storage device entity for the Xbox integration."""
_attr_has_entity_name = True
entity_description: XboxStorageDeviceSensorEntityDescription
def __init__(
self,
console: SmartglassConsole,
storage_device: StorageDevice,
coordinator: XboxConsolesCoordinator,
entity_description: XboxStorageDeviceSensorEntityDescription,
) -> None:
"""Initialize the Xbox Console entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self.client = coordinator.client
self._console = console
self._storage_device = storage_device
self._attr_unique_id = (
f"{console.id}_{storage_device.storage_device_id}_{entity_description.key}"
)
self._attr_translation_placeholders = {
CONF_NAME: storage_device.storage_device_name
}
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, console.id)},
manufacturer="Microsoft",
model=MAP_MODEL.get(self._console.console_type, "Unknown"),
name=console.name,
)
@property
def data(self):
"""Storage device data."""
consoles = self.coordinator.data.result
console = next((c for c in consoles if c.id == self._console.id), None)
if not console or not console.storage_devices:
return None
return next(
(
d
for d in console.storage_devices
if d.storage_device_id == self._storage_device.storage_device_id
),
None,
)
@property
def native_value(self) -> StateType:
"""Return the state of the requested attribute."""
return self.entity_description.value_fn(self.data) if self.data else None

View File

@@ -65,6 +65,9 @@
"name": "Following",
"unit_of_measurement": "people"
},
"free_storage": {
"name": "Free space - {name}"
},
"friends": {
"name": "Friends",
"unit_of_measurement": "[%key:component::xbox::entity::sensor::following::unit_of_measurement%]"
@@ -105,6 +108,9 @@
},
"status": {
"name": "Status"
},
"total_storage": {
"name": "Total space - {name}"
}
}
},

View File

@@ -43,7 +43,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"options": {

View File

@@ -71,7 +71,20 @@ async def warn_on_wrong_silabs_firmware(hass: HomeAssistant, device: str) -> boo
if device.startswith("socket://"):
return False
app_type = await probe_silabs_firmware_type(device)
app_type = await probe_silabs_firmware_type(
device,
bootloader_reset_methods=(),
application_probe_methods=[
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, 115200),
(ApplicationType.EZSP, 460800),
(ApplicationType.SPINEL, 460800),
(ApplicationType.CPC, 460800),
(ApplicationType.CPC, 230400),
(ApplicationType.CPC, 115200),
(ApplicationType.ROUTER, 115200),
],
)
if app_type is None:
# Failed to probe, we can't tell if the wrong firmware is installed

View File

@@ -66,6 +66,7 @@ from .discovery_data_template import (
NumericSensorDataTemplate,
)
from .entity import NewZwaveDiscoveryInfo
from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS
from .models import (
FirmwareVersionRange,
NewZWaveDiscoverySchema,
@@ -77,6 +78,7 @@ from .models import (
NEW_DISCOVERY_SCHEMAS: dict[Platform, list[NewZWaveDiscoverySchema]] = {
Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS,
Platform.EVENT: EVENT_SCHEMAS,
}
SUPPORTED_PLATFORMS = tuple(NEW_DISCOVERY_SCHEMAS)
@@ -1164,15 +1166,6 @@ DISCOVERY_SCHEMAS = [
allow_multi=True,
entity_registry_enabled_default=False,
),
# event
# stateful = False
ZWaveDiscoverySchema(
platform=Platform.EVENT,
hint="stateless",
primary_value=ZWaveValueDiscoverySchema(
stateful=False,
),
),
# button
# Meter CC idle
ZWaveDiscoverySchema(

View File

@@ -2,22 +2,37 @@
from __future__ import annotations
from dataclasses import dataclass
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.value import Value, ValueNotification
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventEntity
from homeassistant.components.event import (
DOMAIN as EVENT_DOMAIN,
EventEntity,
EventEntityDescription,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_VALUE, DOMAIN
from .discovery import ZwaveDiscoveryInfo
from .entity import ZWaveBaseEntity
from .models import ZwaveJSConfigEntry
from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity
from .models import (
NewZWaveDiscoverySchema,
ZwaveJSConfigEntry,
ZWaveValueDiscoverySchema,
)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ValueNotificationZWaveJSEntityDescription(EventEntityDescription):
"""Represent a Z-Wave JS event entity description."""
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ZwaveJSConfigEntry,
@@ -27,11 +42,13 @@ async def async_setup_entry(
client = config_entry.runtime_data.client
@callback
def async_add_event(info: ZwaveDiscoveryInfo) -> None:
def async_add_event(info: NewZwaveDiscoveryInfo) -> None:
"""Add Z-Wave event entity."""
driver = client.driver
assert driver is not None # Driver is ready before platforms are loaded.
entities: list[ZWaveBaseEntity] = [ZwaveEventEntity(config_entry, driver, info)]
entities: list[ZWaveBaseEntity] = [
info.entity_class(config_entry, driver, info)
]
async_add_entities(entities)
config_entry.async_on_unload(
@@ -55,7 +72,10 @@ class ZwaveEventEntity(ZWaveBaseEntity, EventEntity):
"""Representation of a Z-Wave event entity."""
def __init__(
self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
self,
config_entry: ZwaveJSConfigEntry,
driver: Driver,
info: NewZwaveDiscoveryInfo,
) -> None:
"""Initialize a ZwaveEventEntity entity."""
super().__init__(config_entry, driver, info)
@@ -96,3 +116,17 @@ class ZwaveEventEntity(ZWaveBaseEntity, EventEntity):
lambda event: self._async_handle_event(event["value_notification"]),
)
)
DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
NewZWaveDiscoverySchema(
platform=Platform.EVENT,
primary_value=ZWaveValueDiscoverySchema(
stateful=False,
),
entity_description=ValueNotificationZWaveJSEntityDescription(
key="value_notification",
),
entity_class=ZwaveEventEntity,
),
]

View File

@@ -645,12 +645,24 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
__progress_task: asyncio.Task[Any] | None = None
__no_progress_task_reported = False
deprecated_show_progress = False
_progress_step_data: ProgressStepData[_FlowResultT] = {
"tasks": {},
"abort_reason": "",
"abort_description_placeholders": MappingProxyType({}),
"next_step_result": None,
}
__progress_step_data: ProgressStepData[_FlowResultT] | None = None
@property
def _progress_step_data(self) -> ProgressStepData[_FlowResultT]:
"""Return progress step data.
A property is used instead of a simple attribute as derived classes
do not call super().__init__.
The property makes sure that the dict is initialized if needed.
"""
if not self.__progress_step_data:
self.__progress_step_data = {
"tasks": {},
"abort_reason": "",
"abort_description_placeholders": MappingProxyType({}),
"next_step_result": None,
}
return self.__progress_step_data
@property
def source(self) -> str | None:
@@ -777,9 +789,10 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
self, user_input: dict[str, Any] | None = None
) -> _FlowResultT:
"""Abort the flow."""
progress_step_data = self._progress_step_data
return self.async_abort(
reason=self._progress_step_data["abort_reason"],
description_placeholders=self._progress_step_data[
reason=progress_step_data["abort_reason"],
description_placeholders=progress_step_data[
"abort_description_placeholders"
],
)
@@ -795,14 +808,15 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
without using async_show_progress_done.
If no next step is set, abort the flow.
"""
if self._progress_step_data["next_step_result"] is None:
progress_step_data = self._progress_step_data
if (next_step_result := progress_step_data["next_step_result"]) is None:
return self.async_abort(
reason=self._progress_step_data["abort_reason"],
description_placeholders=self._progress_step_data[
reason=progress_step_data["abort_reason"],
description_placeholders=progress_step_data[
"abort_description_placeholders"
],
)
return self._progress_step_data["next_step_result"]
return next_step_result
@callback
def async_external_step(
@@ -1021,9 +1035,9 @@ def progress_step[
self: FlowHandler[Any, ResultT], *args: P.args, **kwargs: P.kwargs
) -> ResultT:
step_id = func.__name__.replace("async_step_", "")
progress_step_data = self._progress_step_data
# Check if we have a progress task running
progress_task = self._progress_step_data["tasks"].get(step_id)
progress_task = progress_step_data["tasks"].get(step_id)
if progress_task is None:
# First call - create and start the progress task
@@ -1031,30 +1045,30 @@ def progress_step[
func(self, *args, **kwargs), # type: ignore[arg-type]
f"Progress step {step_id}",
)
self._progress_step_data["tasks"][step_id] = progress_task
progress_step_data["tasks"][step_id] = progress_task
if not progress_task.done():
# Handle description placeholders
placeholders = None
if description_placeholders is not None:
if callable(description_placeholders):
placeholders = description_placeholders(self)
else:
placeholders = description_placeholders
if not progress_task.done():
# Handle description placeholders
placeholders = None
if description_placeholders is not None:
if callable(description_placeholders):
placeholders = description_placeholders(self)
else:
placeholders = description_placeholders
return self.async_show_progress(
step_id=step_id,
progress_action=step_id,
progress_task=progress_task,
description_placeholders=placeholders,
)
return self.async_show_progress(
step_id=step_id,
progress_action=step_id,
progress_task=progress_task,
description_placeholders=placeholders,
)
# Task is done or this is a subsequent call
try:
self._progress_step_data["next_step_result"] = await progress_task
progress_step_data["next_step_result"] = await progress_task
except AbortFlow as err:
self._progress_step_data["abort_reason"] = err.reason
self._progress_step_data["abort_description_placeholders"] = (
progress_step_data["abort_reason"] = err.reason
progress_step_data["abort_description_placeholders"] = (
err.description_placeholders or {}
)
return self.async_show_progress_done(
@@ -1062,7 +1076,7 @@ def progress_step[
)
finally:
# Clean up task reference
self._progress_step_data["tasks"].pop(step_id, None)
progress_step_data["tasks"].pop(step_id, None)
return self.async_show_progress_done(
next_step_id="_progress_step_progress_done"

View File

@@ -25,6 +25,7 @@ from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_ATTRIBUTION,
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_ENTITY_PICTURE,
ATTR_FRIENDLY_NAME,
ATTR_ICON,
@@ -417,6 +418,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
"extra_state_attributes",
"force_update",
"icon",
"included_unique_ids",
"name",
"should_poll",
"state",
@@ -524,6 +526,9 @@ class Entity(
__capabilities_updated_at_reported: bool = False
__remove_future: asyncio.Future[None] | None = None
# A list of included entity IDs in case the entity represents a group
_included_entities: list[str] | None = None
# Entity Properties
_attr_assumed_state: bool = False
_attr_attribution: str | None = None
@@ -539,6 +544,7 @@ class Entity(
_attr_extra_state_attributes: dict[str, Any]
_attr_force_update: bool
_attr_icon: str | None
_attr_included_unique_ids: list[str]
_attr_name: str | None
_attr_should_poll: bool = True
_attr_state: StateType = STATE_UNKNOWN
@@ -1085,6 +1091,21 @@ class Entity(
available = self.available # only call self.available once per update cycle
state = self._stringify_state(available)
if available:
if self.included_unique_ids is not None:
entity_registry = er.async_get(self.hass)
self._included_entities = [
entity_id
for included_id in self.included_unique_ids
if (
entity_id := entity_registry.async_get_entity_id(
self.platform.domain,
self.platform.platform_name,
included_id,
)
)
is not None
]
attr[ATTR_ENTITY_ID] = self._included_entities.copy()
if state_attributes := self.state_attributes:
attr |= state_attributes
if extra_state_attributes := self.extra_state_attributes:
@@ -1374,6 +1395,30 @@ class Entity(
async def add_to_platform_finish(self) -> None:
"""Finish adding an entity to a platform."""
entity_registry = er.async_get(self.hass)
async def _handle_entity_registry_updated(event: Event[Any]) -> None:
"""Handle registry create or update event."""
if (
event.data["action"] in {"create", "update"}
and (entry := entity_registry.async_get(event.data["entity_id"]))
and self.included_unique_ids is not None
and entry.unique_id in self.included_unique_ids
) or (
event.data["action"] == "remove"
and self._included_entities is not None
and event.data["entity_id"] in self._included_entities
):
self.async_write_ha_state()
if self.included_unique_ids is not None:
self.async_on_remove(
self.hass.bus.async_listen(
er.EVENT_ENTITY_REGISTRY_UPDATED,
_handle_entity_registry_updated,
)
)
await self.async_internal_added_to_hass()
await self.async_added_to_hass()
self._platform_state = EntityPlatformState.ADDED
@@ -1633,6 +1678,16 @@ class Entity(
self.hass, integration_domain=platform_name, module=type(self).__module__
)
@cached_property
def included_unique_ids(self) -> list[str] | None:
"""Return the list of unique IDs if the entity represents a group.
The corresponding entities will be shown as members in the UI.
"""
if hasattr(self, "_attr_included_unique_ids"):
return self._attr_included_unique_ids
return None
class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes toggle entities."""

View File

@@ -4,12 +4,14 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, NoReturn
from jinja2.ext import Extension
from jinja2.nodes import Node
from jinja2.parser import Parser
from homeassistant.exceptions import TemplateError
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from homeassistant.helpers.template import TemplateEnvironment
@@ -50,8 +52,17 @@ class BaseTemplateExtension(Extension):
if template_func.requires_hass and self.environment.hass is None:
continue
# Skip functions not allowed in limited environments
# Register unsupported stub for functions not allowed in limited environments
if self.environment.limited and not template_func.limited_ok:
unsupported_func = self._create_unsupported_function(
template_func.name
)
if template_func.as_global:
environment.globals[template_func.name] = unsupported_func
if template_func.as_filter:
environment.filters[template_func.name] = unsupported_func
if template_func.as_test:
environment.tests[template_func.name] = unsupported_func
continue
if template_func.as_global:
@@ -61,6 +72,17 @@ class BaseTemplateExtension(Extension):
if template_func.as_test:
environment.tests[template_func.name] = template_func.func
@staticmethod
def _create_unsupported_function(name: str) -> Callable[[], NoReturn]:
"""Create a function that raises an error for unsupported functions in limited templates."""
def unsupported(*args: Any, **kwargs: Any) -> NoReturn:
raise TemplateError(
f"Use of '{name}' is not supported in limited templates"
)
return unsupported
@property
def hass(self) -> HomeAssistant:
"""Return the Home Assistant instance.

18
requirements_all.txt generated
View File

@@ -99,7 +99,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.8.0
# homeassistant.components.vicare
PyViCare==2.54.0
PyViCare==2.55.0
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -273,7 +273,7 @@ aioharmony==0.5.3
aiohasupervisor==0.3.3
# homeassistant.components.home_connect
aiohomeconnect==0.23.0
aiohomeconnect==0.23.1
# homeassistant.components.homekit_controller
aiohomekit==3.2.20
@@ -1220,7 +1220,7 @@ ibeacon-ble==1.2.0
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
# homeassistant.components.remote_calendar
ical==11.0.0
ical==11.1.0
# homeassistant.components.caldav
icalendar==6.3.1
@@ -1395,7 +1395,7 @@ loqedAPI==2.1.10
luftdaten==0.7.4
# homeassistant.components.lunatone
lunatone-rest-api-client==0.5.3
lunatone-rest-api-client==0.5.7
# homeassistant.components.lupusec
lupupy==0.3.2
@@ -1456,7 +1456,7 @@ microBeesPy==0.3.5
mill-local==0.3.0
# homeassistant.components.mill
millheater==0.14.0
millheater==0.14.1
# homeassistant.components.minio
minio==7.1.12
@@ -2449,7 +2449,7 @@ python-clementine-remote==1.0.1
python-digitalocean==1.13.2
# homeassistant.components.ecobee
python-ecobee-api==0.2.20
python-ecobee-api==0.3.2
# homeassistant.components.etherscan
python-etherscan-api==0.0.3
@@ -2604,7 +2604,7 @@ pyvera==0.3.16
pyversasense==0.0.6
# homeassistant.components.vesync
pyvesync==3.2.1
pyvesync==3.2.2
# homeassistant.components.vizio
pyvizio==0.1.61
@@ -2948,7 +2948,7 @@ tesla-fleet-api==1.2.5
tesla-powerwall==0.5.2
# homeassistant.components.tesla_wall_connector
tesla-wall-connector==1.0.2
tesla-wall-connector==1.1.0
# homeassistant.components.teslemetry
teslemetry-stream==0.7.10
@@ -3047,7 +3047,7 @@ unifi_ap==0.0.2
unifiled==0.11
# homeassistant.components.homeassistant_hardware
universal-silabs-flasher==0.0.37
universal-silabs-flasher==0.1.0
# homeassistant.components.upb
upb-lib==0.6.1

View File

@@ -93,7 +93,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.8.0
# homeassistant.components.vicare
PyViCare==2.54.0
PyViCare==2.55.0
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -258,7 +258,7 @@ aioharmony==0.5.3
aiohasupervisor==0.3.3
# homeassistant.components.home_connect
aiohomeconnect==0.23.0
aiohomeconnect==0.23.1
# homeassistant.components.homekit_controller
aiohomekit==3.2.20
@@ -1063,7 +1063,7 @@ ibeacon-ble==1.2.0
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
# homeassistant.components.remote_calendar
ical==11.0.0
ical==11.1.0
# homeassistant.components.caldav
icalendar==6.3.1
@@ -1199,7 +1199,7 @@ loqedAPI==2.1.10
luftdaten==0.7.4
# homeassistant.components.lunatone
lunatone-rest-api-client==0.5.3
lunatone-rest-api-client==0.5.7
# homeassistant.components.lupusec
lupupy==0.3.2
@@ -1251,7 +1251,7 @@ microBeesPy==0.3.5
mill-local==0.3.0
# homeassistant.components.mill
millheater==0.14.0
millheater==0.14.1
# homeassistant.components.minio
minio==7.1.12
@@ -2036,7 +2036,7 @@ python-awair==0.2.4
python-bsblan==3.1.0
# homeassistant.components.ecobee
python-ecobee-api==0.2.20
python-ecobee-api==0.3.2
# homeassistant.components.fully_kiosk
python-fullykiosk==0.0.14
@@ -2158,7 +2158,7 @@ pyuptimerobot==22.2.0
pyvera==0.3.16
# homeassistant.components.vesync
pyvesync==3.2.1
pyvesync==3.2.2
# homeassistant.components.vizio
pyvizio==0.1.61
@@ -2433,7 +2433,7 @@ tesla-fleet-api==1.2.5
tesla-powerwall==0.5.2
# homeassistant.components.tesla_wall_connector
tesla-wall-connector==1.0.2
tesla-wall-connector==1.1.0
# homeassistant.components.teslemetry
teslemetry-stream==0.7.10
@@ -2514,7 +2514,7 @@ ultraheat-api==0.5.7
unifi-discovery==1.2.0
# homeassistant.components.homeassistant_hardware
universal-silabs-flasher==0.0.37
universal-silabs-flasher==0.1.0
# homeassistant.components.upb
upb-lib==0.6.1

View File

@@ -28,6 +28,8 @@ async def test_auth_failure(hass: HomeAssistant) -> None:
"type": FlowResultType.FORM,
"flow_id": "mock_flow",
"step_id": "reauth_confirm",
"description_placeholders": {"username": "test", "name": "test"},
"data_schema": None,
},
) as mock_async_step_reauth:
await setup_platform(hass, side_effect=AuthenticationException())

View File

@@ -0,0 +1,56 @@
"""Fixtures for energy component tests."""
from unittest.mock import patch
import pytest
from homeassistant.components.energy import async_get_manager
from homeassistant.components.energy.data import EnergyManager
from homeassistant.components.recorder import Recorder
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@pytest.fixture
def mock_is_entity_recorded():
"""Mock recorder.is_entity_recorded."""
mocks = {}
with patch(
"homeassistant.components.recorder.is_entity_recorded",
side_effect=lambda hass, entity_id: mocks.get(entity_id, True),
):
yield mocks
@pytest.fixture
def mock_get_metadata():
"""Mock recorder.statistics.get_metadata."""
mocks = {}
def _get_metadata(_hass, *, statistic_ids):
result = {}
for statistic_id in statistic_ids:
if statistic_id in mocks:
if mocks[statistic_id] is not None:
result[statistic_id] = mocks[statistic_id]
else:
result[statistic_id] = (1, {})
return result
with patch(
"homeassistant.components.recorder.statistics.get_metadata",
wraps=_get_metadata,
):
yield mocks
@pytest.fixture
async def mock_energy_manager(
recorder_mock: Recorder, hass: HomeAssistant
) -> EnergyManager:
"""Set up energy."""
assert await async_setup_component(hass, "energy", {"energy": {}})
manager = await async_get_manager(hass)
manager.data = manager.default_preferences()
return manager

View File

@@ -1,65 +1,24 @@
"""Test that validation works."""
from unittest.mock import patch
import pytest
from homeassistant.components.energy import async_get_manager, validate
from homeassistant.components.energy import validate
from homeassistant.components.energy.data import EnergyManager
from homeassistant.components.recorder import Recorder
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import JSON_DUMP
from homeassistant.setup import async_setup_component
ENERGY_UNITS_STRING = ", ".join(tuple(UnitOfEnergy))
ENERGY_PRICE_UNITS_STRING = ", ".join(f"EUR/{unit}" for unit in tuple(UnitOfEnergy))
@pytest.fixture
def mock_is_entity_recorded():
"""Mock recorder.is_entity_recorded."""
mocks = {}
with patch(
"homeassistant.components.recorder.is_entity_recorded",
side_effect=lambda hass, entity_id: mocks.get(entity_id, True),
):
yield mocks
@pytest.fixture
def mock_get_metadata():
"""Mock recorder.statistics.get_metadata."""
mocks = {}
def _get_metadata(_hass, *, statistic_ids):
result = {}
for statistic_id in statistic_ids:
if statistic_id in mocks:
if mocks[statistic_id] is not None:
result[statistic_id] = mocks[statistic_id]
else:
result[statistic_id] = (1, {})
return result
with patch(
"homeassistant.components.recorder.statistics.get_metadata",
wraps=_get_metadata,
):
yield mocks
@pytest.fixture(autouse=True)
async def mock_energy_manager(
recorder_mock: Recorder, hass: HomeAssistant
async def setup_energy_for_validation(
mock_energy_manager: EnergyManager,
) -> EnergyManager:
"""Set up energy."""
assert await async_setup_component(hass, "energy", {"energy": {}})
manager = await async_get_manager(hass)
manager.data = manager.default_preferences()
return manager
"""Ensure energy manager is set up for validation tests."""
return mock_energy_manager
async def test_validation_empty_config(hass: HomeAssistant) -> None:
@@ -413,6 +372,7 @@ async def test_validation_grid(
"stat_compensation": "sensor.grid_compensation_1",
}
],
"power": [],
}
]
}
@@ -504,6 +464,7 @@ async def test_validation_grid_external_cost_compensation(
"stat_compensation": "external:grid_compensation_1",
}
],
"power": [],
}
]
}
@@ -742,6 +703,7 @@ async def test_validation_grid_price_errors(
}
],
"flow_to": [],
"power": [],
}
]
}
@@ -947,6 +909,7 @@ async def test_validation_grid_no_costs_tracking(
"number_energy_price": None,
},
],
"power": [],
"cost_adjustment_day": 0.0,
}
]

View File

@@ -0,0 +1,450 @@
"""Test power stat validation."""
import pytest
from homeassistant.components.energy import validate
from homeassistant.components.energy.data import EnergyManager
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant
POWER_UNITS_STRING = ", ".join(tuple(UnitOfPower))
@pytest.fixture(autouse=True)
async def setup_energy_for_validation(
mock_energy_manager: EnergyManager,
) -> EnergyManager:
"""Ensure energy manager is set up for validation tests."""
return mock_energy_manager
async def test_validation_grid_power_valid(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with valid power sensor."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_rate": "sensor.grid_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
hass.states.async_set(
"sensor.grid_power",
"1.5",
{
"device_class": "power",
"unit_of_measurement": UnitOfPower.KILO_WATT,
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [[]],
"device_consumption": [],
}
async def test_validation_grid_power_wrong_unit(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with power sensor having wrong unit."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_rate": "sensor.grid_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
hass.states.async_set(
"sensor.grid_power",
"1.5",
{
"device_class": "power",
"unit_of_measurement": "beers",
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "entity_unexpected_unit_power",
"affected_entities": {("sensor.grid_power", "beers")},
"translation_placeholders": {"power_units": POWER_UNITS_STRING},
}
]
],
"device_consumption": [],
}
async def test_validation_grid_power_wrong_state_class(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with power sensor having wrong state class."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_rate": "sensor.grid_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
hass.states.async_set(
"sensor.grid_power",
"1.5",
{
"device_class": "power",
"unit_of_measurement": UnitOfPower.KILO_WATT,
"state_class": "total_increasing",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "entity_unexpected_state_class",
"affected_entities": {("sensor.grid_power", "total_increasing")},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
}
async def test_validation_grid_power_entity_missing(
hass: HomeAssistant, mock_energy_manager
) -> None:
"""Test validating grid with missing power sensor."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_rate": "sensor.missing_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "statistics_not_defined",
"affected_entities": {("sensor.missing_power", None)},
"translation_placeholders": None,
},
{
"type": "entity_not_defined",
"affected_entities": {("sensor.missing_power", None)},
"translation_placeholders": None,
},
]
],
"device_consumption": [],
}
async def test_validation_grid_power_entity_unavailable(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with unavailable power sensor."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_rate": "sensor.unavailable_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
hass.states.async_set("sensor.unavailable_power", "unavailable", {})
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "entity_unavailable",
"affected_entities": {("sensor.unavailable_power", "unavailable")},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
}
async def test_validation_grid_power_entity_non_numeric(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with non-numeric power sensor."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_rate": "sensor.non_numeric_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
hass.states.async_set(
"sensor.non_numeric_power",
"not_a_number",
{
"device_class": "power",
"unit_of_measurement": UnitOfPower.KILO_WATT,
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "entity_state_non_numeric",
"affected_entities": {("sensor.non_numeric_power", "not_a_number")},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
}
async def test_validation_grid_power_wrong_device_class(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with power sensor having wrong device class."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_rate": "sensor.wrong_device_class_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
hass.states.async_set(
"sensor.wrong_device_class_power",
"1.5",
{
"device_class": "energy",
"unit_of_measurement": "kWh",
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "entity_unexpected_device_class",
"affected_entities": {
("sensor.wrong_device_class_power", "energy")
},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
}
async def test_validation_grid_power_different_units(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with power sensors using different valid units."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_rate": "sensor.power_watt",
},
{
"stat_rate": "sensor.power_milliwatt",
},
],
"cost_adjustment_day": 0.0,
}
]
}
)
hass.states.async_set(
"sensor.power_watt",
"1500",
{
"device_class": "power",
"unit_of_measurement": UnitOfPower.WATT,
"state_class": "measurement",
},
)
hass.states.async_set(
"sensor.power_milliwatt",
"1500000",
{
"device_class": "power",
"unit_of_measurement": UnitOfPower.MILLIWATT,
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [[]],
"device_consumption": [],
}
async def test_validation_grid_power_external_statistics(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with external power statistics (non-entity)."""
mock_get_metadata["external:power_stat"] = None
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_rate": "external:power_stat",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "statistics_not_defined",
"affected_entities": {("external:power_stat", None)},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
}
async def test_validation_grid_power_recorder_untracked(
hass: HomeAssistant, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata
) -> None:
"""Test validating grid with power sensor not tracked by recorder."""
mock_is_entity_recorded["sensor.untracked_power"] = False
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_rate": "sensor.untracked_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "recorder_untracked",
"affected_entities": {("sensor.untracked_power", None)},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
}

View File

@@ -137,17 +137,24 @@ async def test_save_preferences(
"number_energy_price": 0.20,
},
],
"power": [
{
"stat_rate": "sensor.grid_power",
}
],
"cost_adjustment_day": 1.2,
},
{
"type": "solar",
"stat_energy_from": "my_solar_production",
"stat_rate": "my_solar_power",
"config_entry_solar_forecast": ["predicted_config_entry"],
},
{
"type": "battery",
"stat_energy_from": "my_battery_draining",
"stat_energy_to": "my_battery_charging",
"stat_rate": "my_battery_power",
},
],
"device_consumption": [
@@ -155,6 +162,7 @@ async def test_save_preferences(
"stat_consumption": "some_device_usage",
"name": "My Device",
"included_in_stat": "sensor.some_other_device",
"stat_rate": "sensor.some_device_power",
}
],
}
@@ -253,6 +261,7 @@ async def test_handle_duplicate_from_stat(
},
],
"flow_to": [],
"power": [],
"cost_adjustment_day": 0,
},
],

View File

@@ -37,7 +37,10 @@ def mock_growatt_v1_api():
Methods mocked for switch and number operations:
- min_write_parameter: Called by switch/number entities to change settings
"""
with patch("growattServer.OpenApiV1", autospec=True) as mock_v1_api_class:
with patch(
"homeassistant.components.growatt_server.config_flow.growattServer.OpenApiV1",
autospec=True,
) as mock_v1_api_class:
mock_v1_api = mock_v1_api_class.return_value
# Called during setup to discover devices
@@ -119,7 +122,10 @@ def mock_growatt_classic_api():
Methods mocked for device-specific tests:
- tlx_detail: Provides TLX device data (kept for potential future tests)
"""
with patch("growattServer.GrowattApi", autospec=True) as mock_classic_api_class:
with patch(
"homeassistant.components.growatt_server.config_flow.growattServer.GrowattApi",
autospec=True,
) as mock_classic_api_class:
# Use the autospec'd mock instance instead of creating a new Mock()
mock_classic_api = mock_classic_api_class.return_value
@@ -167,10 +173,10 @@ def mock_config_entry() -> MockConfigEntry:
CONF_TOKEN: "test_token_123",
CONF_URL: DEFAULT_URL,
"user_id": "12345",
CONF_PLANT_ID: "plant_123",
CONF_PLANT_ID: "123456",
"name": "Test Plant",
},
unique_id="plant_123",
unique_id="123456",
)
@@ -188,10 +194,10 @@ def mock_config_entry_classic() -> MockConfigEntry:
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
CONF_URL: DEFAULT_URL,
CONF_PLANT_ID: "12345",
CONF_PLANT_ID: "123456",
"name": "Test Plant",
},
unique_id="12345",
unique_id="123456",
)
@@ -215,3 +221,13 @@ async def init_integration(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
@pytest.fixture
def mock_setup_entry():
"""Mock async_setup_entry to prevent actual setup during config flow tests."""
with patch(
"homeassistant.components.growatt_server.async_setup_entry",
return_value=True,
) as mock:
yield mock

View File

@@ -61,65 +61,3 @@
'via_device_id': None,
})
# ---
# name: test_multiple_devices_discovered[device_min123456]
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(
'growatt_server',
'MIN123456',
),
}),
'labels': set({
}),
'manufacturer': 'Growatt',
'model': None,
'model_id': None,
'name': 'MIN123456',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
# name: test_multiple_devices_discovered[device_min789012]
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(
'growatt_server',
'MIN789012',
),
}),
'labels': set({
}),
'manufacturer': 'Growatt',
'model': None,
'model_id': None,
'name': 'MIN789012',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---

View File

@@ -1,35 +1,4 @@
# serializer version: 1
# name: test_number_device_registry
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(
'growatt_server',
'MIN123456',
),
}),
'labels': set({
}),
'manufacturer': 'Growatt',
'model': None,
'model_id': None,
'name': 'MIN123456',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
# name: test_number_entities[number.min123456_battery_charge_power_limit-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +1,4 @@
# serializer version: 1
# name: test_switch_device_registry
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(
'growatt_server',
'MIN123456',
),
}),
'labels': set({
}),
'manufacturer': 'Growatt',
'model': None,
'model_id': None,
'name': 'MIN123456',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
# name: test_switch_entities[switch.min123456_charge_from_grid-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -78,51 +47,3 @@
'state': 'on',
})
# ---
# name: test_switch_entity_attributes[entity_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': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.min123456_charge_from_grid',
'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': 'Charge from grid',
'platform': 'growatt_server',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'ac_charge',
'unique_id': 'MIN123456_ac_charge',
'unit_of_measurement': None,
})
# ---
# name: test_switch_entity_attributes[state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'MIN123456 Charge from grid',
}),
'context': <ANY>,
'entity_id': 'switch.min123456_charge_from_grid',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -1,7 +1,6 @@
"""Tests for the Growatt server config flow."""
from copy import deepcopy
from unittest.mock import patch
import growattServer
import pytest
@@ -133,8 +132,16 @@ async def test_auth_form_display(
assert field in result["data_schema"].schema
async def test_password_auth_incorrect_login(hass: HomeAssistant) -> None:
async def test_password_auth_incorrect_login(
hass: HomeAssistant, mock_growatt_classic_api, mock_setup_entry
) -> None:
"""Test password authentication with incorrect credentials, then recovery."""
# Simulate incorrect login
mock_growatt_classic_api.login.return_value = {
"msg": LOGIN_INVALID_AUTH_CODE,
"success": False,
}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@@ -144,33 +151,21 @@ async def test_password_auth_incorrect_login(hass: HomeAssistant) -> None:
result["flow_id"], {"next_step_id": "password_auth"}
)
with patch(
"growattServer.GrowattApi.login",
return_value={"msg": LOGIN_INVALID_AUTH_CODE, "success": False},
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "password_auth"
assert result["errors"] == {"base": ERROR_INVALID_AUTH}
# Test recovery - retry with correct credentials
with (
patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE),
patch(
"growattServer.GrowattApi.plant_list",
return_value=GROWATT_PLANT_LIST_RESPONSE,
),
patch(
"homeassistant.components.growatt_server.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
# Test recovery - repatch for correct credentials
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
mock_growatt_classic_api.plant_list.return_value = GROWATT_PLANT_LIST_RESPONSE
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME]
@@ -179,8 +174,13 @@ async def test_password_auth_incorrect_login(hass: HomeAssistant) -> None:
assert result["data"][CONF_AUTH_TYPE] == AUTH_PASSWORD
async def test_password_auth_no_plants(hass: HomeAssistant) -> None:
async def test_password_auth_no_plants(
hass: HomeAssistant, mock_growatt_classic_api
) -> None:
"""Test password authentication with no plants."""
# Repatch to return empty plants
mock_growatt_classic_api.plant_list.return_value = {"data": []}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@@ -190,20 +190,19 @@ async def test_password_auth_no_plants(hass: HomeAssistant) -> None:
result["flow_id"], {"next_step_id": "password_auth"}
)
with (
patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE),
patch("growattServer.GrowattApi.plant_list", return_value={"data": []}),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == ABORT_NO_PLANTS
async def test_token_auth_no_plants(hass: HomeAssistant) -> None:
async def test_token_auth_no_plants(hass: HomeAssistant, mock_growatt_v1_api) -> None:
"""Test token authentication with no plants."""
# Repatch to return empty plants
mock_growatt_v1_api.plant_list.return_value = {"plants": []}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@@ -213,17 +212,21 @@ async def test_token_auth_no_plants(hass: HomeAssistant) -> None:
result["flow_id"], {"next_step_id": "token_auth"}
)
with patch("growattServer.OpenApiV1.plant_list", return_value={"plants": []}):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == ABORT_NO_PLANTS
async def test_password_auth_single_plant(hass: HomeAssistant) -> None:
async def test_password_auth_single_plant(
hass: HomeAssistant, mock_growatt_classic_api, mock_setup_entry
) -> None:
"""Test password authentication with single plant."""
# Repatch plant_list with full plant data for config flow
mock_growatt_classic_api.plant_list.return_value = GROWATT_PLANT_LIST_RESPONSE
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@@ -233,20 +236,9 @@ async def test_password_auth_single_plant(hass: HomeAssistant) -> None:
result["flow_id"], {"next_step_id": "password_auth"}
)
with (
patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE),
patch(
"growattServer.GrowattApi.plant_list",
return_value=GROWATT_PLANT_LIST_RESPONSE,
),
patch(
"homeassistant.components.growatt_server.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME]
@@ -257,17 +249,11 @@ async def test_password_auth_single_plant(hass: HomeAssistant) -> None:
assert result["result"].unique_id == "123456"
async def test_password_auth_multiple_plants(hass: HomeAssistant) -> None:
async def test_password_auth_multiple_plants(
hass: HomeAssistant, mock_growatt_classic_api, mock_setup_entry
) -> None:
"""Test password authentication with multiple plants."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
# Select password authentication
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "password_auth"}
)
# Repatch plant_list with multiple plants
plant_list = deepcopy(GROWATT_PLANT_LIST_RESPONSE)
plant_list["data"].append(
{
@@ -280,29 +266,31 @@ async def test_password_auth_multiple_plants(hass: HomeAssistant) -> None:
"currentPower": "420.0 W",
}
)
mock_growatt_classic_api.plant_list.return_value = plant_list
with (
patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE),
patch("growattServer.GrowattApi.plant_list", return_value=plant_list),
patch(
"homeassistant.components.growatt_server.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
# Should show plant selection form
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "plant"
# Select password authentication
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "password_auth"}
)
# Select first plant
user_input = {CONF_PLANT_ID: "123456"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
# Should show plant selection form
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "plant"
# Select first plant
user_input = {CONF_PLANT_ID: "123456"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME]
@@ -315,7 +303,9 @@ async def test_password_auth_multiple_plants(hass: HomeAssistant) -> None:
# Token authentication tests
async def test_token_auth_api_error(hass: HomeAssistant) -> None:
async def test_token_auth_api_error(
hass: HomeAssistant, mock_growatt_v1_api, mock_setup_entry
) -> None:
"""Test token authentication with API error, then recovery."""
result = await hass.config_entries.flow.async_init(
@@ -329,30 +319,23 @@ async def test_token_auth_api_error(hass: HomeAssistant) -> None:
# Any GrowattV1ApiError during token verification should result in invalid_auth
error = growattServer.GrowattV1ApiError("API error")
error.error_code = 100
mock_growatt_v1_api.plant_list.side_effect = error
with patch("growattServer.OpenApiV1.plant_list", side_effect=error):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "token_auth"
assert result["errors"] == {"base": ERROR_INVALID_AUTH}
# Test recovery - retry with valid token
with (
patch(
"growattServer.OpenApiV1.plant_list",
return_value=GROWATT_V1_PLANT_LIST_RESPONSE,
),
patch(
"homeassistant.components.growatt_server.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
# Test recovery - reset side_effect and set normal return value
mock_growatt_v1_api.plant_list.side_effect = None
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN]
@@ -360,7 +343,9 @@ async def test_token_auth_api_error(hass: HomeAssistant) -> None:
assert result["data"][CONF_AUTH_TYPE] == AUTH_API_TOKEN
async def test_token_auth_connection_error(hass: HomeAssistant) -> None:
async def test_token_auth_connection_error(
hass: HomeAssistant, mock_growatt_v1_api, mock_setup_entry
) -> None:
"""Test token authentication with network error, then recovery."""
result = await hass.config_entries.flow.async_init(
@@ -371,32 +356,26 @@ async def test_token_auth_connection_error(hass: HomeAssistant) -> None:
result["flow_id"], {"next_step_id": "token_auth"}
)
with patch(
"growattServer.OpenApiV1.plant_list",
side_effect=requests.exceptions.ConnectionError("Network error"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
# Simulate connection error on first attempt
mock_growatt_v1_api.plant_list.side_effect = requests.exceptions.ConnectionError(
"Network error"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "token_auth"
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
# Test recovery - retry when network is available
with (
patch(
"growattServer.OpenApiV1.plant_list",
return_value=GROWATT_V1_PLANT_LIST_RESPONSE,
),
patch(
"homeassistant.components.growatt_server.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
# Test recovery - reset side_effect and set normal return value
mock_growatt_v1_api.plant_list.side_effect = None
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN]
@@ -404,7 +383,9 @@ async def test_token_auth_connection_error(hass: HomeAssistant) -> None:
assert result["data"][CONF_AUTH_TYPE] == AUTH_API_TOKEN
async def test_token_auth_invalid_response(hass: HomeAssistant) -> None:
async def test_token_auth_invalid_response(
hass: HomeAssistant, mock_growatt_v1_api, mock_setup_entry
) -> None:
"""Test token authentication with invalid response format, then recovery."""
result = await hass.config_entries.flow.async_init(
@@ -415,32 +396,23 @@ async def test_token_auth_invalid_response(hass: HomeAssistant) -> None:
result["flow_id"], {"next_step_id": "token_auth"}
)
with patch(
"growattServer.OpenApiV1.plant_list",
return_value=None, # Invalid response format
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
# Return invalid response format (None instead of dict with 'plants' key)
mock_growatt_v1_api.plant_list.return_value = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "token_auth"
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
# Test recovery - retry with valid response
with (
patch(
"growattServer.OpenApiV1.plant_list",
return_value=GROWATT_V1_PLANT_LIST_RESPONSE,
),
patch(
"homeassistant.components.growatt_server.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
# Test recovery - set normal return value
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN]
@@ -448,8 +420,13 @@ async def test_token_auth_invalid_response(hass: HomeAssistant) -> None:
assert result["data"][CONF_AUTH_TYPE] == AUTH_API_TOKEN
async def test_token_auth_single_plant(hass: HomeAssistant) -> None:
async def test_token_auth_single_plant(
hass: HomeAssistant, mock_growatt_v1_api, mock_setup_entry
) -> None:
"""Test token authentication with single plant."""
# Repatch plant_list with full plant data for config flow
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@@ -459,19 +436,9 @@ async def test_token_auth_single_plant(hass: HomeAssistant) -> None:
result["flow_id"], {"next_step_id": "token_auth"}
)
with (
patch(
"growattServer.OpenApiV1.plant_list",
return_value=GROWATT_V1_PLANT_LIST_RESPONSE,
),
patch(
"homeassistant.components.growatt_server.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN]
@@ -481,8 +448,13 @@ async def test_token_auth_single_plant(hass: HomeAssistant) -> None:
assert result["result"].unique_id == "123456"
async def test_token_auth_multiple_plants(hass: HomeAssistant) -> None:
async def test_token_auth_multiple_plants(
hass: HomeAssistant, mock_growatt_v1_api, mock_setup_entry
) -> None:
"""Test token authentication with multiple plants."""
# Repatch plant_list with multiple plants
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_MULTIPLE_PLANTS_RESPONSE
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@@ -492,30 +464,20 @@ async def test_token_auth_multiple_plants(hass: HomeAssistant) -> None:
result["flow_id"], {"next_step_id": "token_auth"}
)
with (
patch(
"growattServer.OpenApiV1.plant_list",
return_value=GROWATT_V1_MULTIPLE_PLANTS_RESPONSE,
),
patch(
"homeassistant.components.growatt_server.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
# Should show plant selection form
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "plant"
# Should show plant selection form
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "plant"
# Select second plant
user_input = {CONF_PLANT_ID: "789012"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input
)
await hass.async_block_till_done()
# Select second plant
user_input = {CONF_PLANT_ID: "789012"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN]
@@ -525,10 +487,17 @@ async def test_token_auth_multiple_plants(hass: HomeAssistant) -> None:
assert result["result"].unique_id == "789012"
async def test_password_auth_existing_plant_configured(hass: HomeAssistant) -> None:
async def test_password_auth_existing_plant_configured(
hass: HomeAssistant,
mock_growatt_classic_api,
mock_config_entry_classic: MockConfigEntry,
) -> None:
"""Test password authentication with existing plant_id."""
entry = MockConfigEntry(domain=DOMAIN, unique_id="123456")
entry.add_to_hass(hass)
# Repatch plant_list for this test
mock_growatt_classic_api.plant_list.return_value = GROWATT_PLANT_LIST_RESPONSE
# Use existing fixture (unique_id already matches what config flow returns)
mock_config_entry_classic.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -539,25 +508,23 @@ async def test_password_auth_existing_plant_configured(hass: HomeAssistant) -> N
result["flow_id"], {"next_step_id": "password_auth"}
)
with (
patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE),
patch(
"growattServer.GrowattApi.plant_list",
return_value=GROWATT_PLANT_LIST_RESPONSE,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_token_auth_existing_plant_configured(hass: HomeAssistant) -> None:
async def test_token_auth_existing_plant_configured(
hass: HomeAssistant, mock_growatt_v1_api, mock_config_entry: MockConfigEntry
) -> None:
"""Test token authentication with existing plant_id."""
entry = MockConfigEntry(domain=DOMAIN, unique_id="123456")
entry.add_to_hass(hass)
# Repatch plant_list for this test
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
# Use existing fixture (unique_id already matches what config flow returns)
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -568,20 +535,23 @@ async def test_token_auth_existing_plant_configured(hass: HomeAssistant) -> None
result["flow_id"], {"next_step_id": "token_auth"}
)
with patch(
"growattServer.OpenApiV1.plant_list",
return_value=GROWATT_V1_PLANT_LIST_RESPONSE,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_password_auth_connection_error(hass: HomeAssistant) -> None:
async def test_password_auth_connection_error(
hass: HomeAssistant, mock_growatt_classic_api, mock_setup_entry
) -> None:
"""Test password authentication with connection error, then recovery."""
# Simulate connection error on first attempt
mock_growatt_classic_api.login.side_effect = requests.exceptions.ConnectionError(
"Connection failed"
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@@ -591,33 +561,22 @@ async def test_password_auth_connection_error(hass: HomeAssistant) -> None:
result["flow_id"], {"next_step_id": "password_auth"}
)
with patch(
"growattServer.GrowattApi.login",
side_effect=requests.exceptions.ConnectionError("Connection failed"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "password_auth"
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
# Test recovery - retry when connection is available
with (
patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE),
patch(
"growattServer.GrowattApi.plant_list",
return_value=GROWATT_PLANT_LIST_RESPONSE,
),
patch(
"homeassistant.components.growatt_server.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
# Test recovery - reset side_effect and set normal return values
mock_growatt_classic_api.login.side_effect = None
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
mock_growatt_classic_api.plant_list.return_value = GROWATT_PLANT_LIST_RESPONSE
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME]
@@ -626,7 +585,9 @@ async def test_password_auth_connection_error(hass: HomeAssistant) -> None:
assert result["data"][CONF_AUTH_TYPE] == AUTH_PASSWORD
async def test_password_auth_invalid_response(hass: HomeAssistant) -> None:
async def test_password_auth_invalid_response(
hass: HomeAssistant, mock_growatt_classic_api, mock_setup_entry
) -> None:
"""Test password authentication with invalid response format, then recovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -637,33 +598,25 @@ async def test_password_auth_invalid_response(hass: HomeAssistant) -> None:
result["flow_id"], {"next_step_id": "password_auth"}
)
with patch(
"growattServer.GrowattApi.login",
side_effect=ValueError("Invalid JSON response"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
# Simulate invalid response error on first attempt
mock_growatt_classic_api.login.side_effect = ValueError("Invalid JSON response")
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "password_auth"
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
# Test recovery - retry with valid response
with (
patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE),
patch(
"growattServer.GrowattApi.plant_list",
return_value=GROWATT_PLANT_LIST_RESPONSE,
),
patch(
"homeassistant.components.growatt_server.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
# Test recovery - reset side_effect and set normal return values
mock_growatt_classic_api.login.side_effect = None
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
mock_growatt_classic_api.plant_list.return_value = GROWATT_PLANT_LIST_RESPONSE
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME]
@@ -672,7 +625,9 @@ async def test_password_auth_invalid_response(hass: HomeAssistant) -> None:
assert result["data"][CONF_AUTH_TYPE] == AUTH_PASSWORD
async def test_password_auth_plant_list_error(hass: HomeAssistant) -> None:
async def test_password_auth_plant_list_error(
hass: HomeAssistant, mock_growatt_classic_api, mock_setup_entry
) -> None:
"""Test password authentication with plant list connection error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -683,22 +638,23 @@ async def test_password_auth_plant_list_error(hass: HomeAssistant) -> None:
result["flow_id"], {"next_step_id": "password_auth"}
)
with (
patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE),
patch(
"growattServer.GrowattApi.plant_list",
side_effect=requests.exceptions.ConnectionError("Connection failed"),
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
# Login succeeds but plant_list fails
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
mock_growatt_classic_api.plant_list.side_effect = (
requests.exceptions.ConnectionError("Connection failed")
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == ERROR_CANNOT_CONNECT
async def test_password_auth_plant_list_invalid_format(hass: HomeAssistant) -> None:
async def test_password_auth_plant_list_invalid_format(
hass: HomeAssistant, mock_growatt_classic_api, mock_setup_entry
) -> None:
"""Test password authentication with invalid plant list format."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -709,16 +665,13 @@ async def test_password_auth_plant_list_invalid_format(hass: HomeAssistant) -> N
result["flow_id"], {"next_step_id": "password_auth"}
)
with (
patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE),
patch(
"growattServer.GrowattApi.plant_list",
return_value={"invalid": "format"}, # Missing "data" key
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
# Login succeeds but plant_list returns invalid format (missing "data" key)
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
mock_growatt_classic_api.plant_list.return_value = {"invalid": "format"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == ERROR_CANNOT_CONNECT

View File

@@ -2,7 +2,6 @@
from datetime import timedelta
import json
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import growattServer
@@ -126,62 +125,6 @@ async def test_classic_api_setup(
assert device_entry == snapshot
@pytest.mark.usefixtures("init_integration")
async def test_unload_removes_listeners(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that unloading removes all listeners."""
# Get initial listener count
initial_listeners = len(hass.bus.async_listeners())
# Unload the integration
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Verify listeners were removed (should be same or less)
final_listeners = len(hass.bus.async_listeners())
assert final_listeners <= initial_listeners
async def test_multiple_devices_discovered(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test handling multiple devices from device_list."""
# Reset and add multiple devices
mock_config_entry_new = MockConfigEntry(
domain=DOMAIN,
data=mock_config_entry.data,
unique_id="plant_456",
)
mock_growatt_v1_api.device_list.return_value = {
"devices": [
{"device_sn": "MIN123456", "type": 7},
{"device_sn": "MIN789012", "type": 7},
]
}
with patch(
"homeassistant.components.growatt_server.coordinator.SCAN_INTERVAL",
timedelta(minutes=5),
):
await setup_integration(hass, mock_config_entry_new)
# Verify both devices were created
device1 = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")})
device2 = device_registry.async_get_device(identifiers={(DOMAIN, "MIN789012")})
assert device1 is not None
assert device1 == snapshot(name="device_min123456")
assert device2 is not None
assert device2 == snapshot(name="device_min789012")
async def test_migrate_legacy_api_token_config(
hass: HomeAssistant,
mock_growatt_v1_api,

View File

@@ -14,10 +14,10 @@ from homeassistant.components.number import (
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import STATE_UNKNOWN, EntityCategory, Platform
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -45,28 +45,6 @@ async def test_number_entities(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_set_number_value_success(
hass: HomeAssistant,
mock_growatt_v1_api,
) -> None:
"""Test setting a number entity value successfully."""
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
"entity_id": "number.min123456_battery_charge_power_limit",
ATTR_VALUE: 75,
},
blocking=True,
)
# Verify API was called with correct parameters
mock_growatt_v1_api.min_write_parameter.assert_called_once_with(
"MIN123456", "charge_power", 75
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_set_number_value_api_error(
hass: HomeAssistant,
@@ -88,50 +66,6 @@ async def test_set_number_value_api_error(
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_number_entity_attributes(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test number entity attributes."""
# Check entity registry attributes
entity_entry = entity_registry.async_get(
"number.min123456_battery_charge_power_limit"
)
assert entity_entry is not None
assert entity_entry.entity_category == EntityCategory.CONFIG
assert entity_entry.unique_id == "MIN123456_battery_charge_power_limit"
# Check state attributes
state = hass.states.get("number.min123456_battery_charge_power_limit")
assert state is not None
assert state.attributes["min"] == 0
assert state.attributes["max"] == 100
assert state.attributes["step"] == 1
assert state.attributes["unit_of_measurement"] == "%"
assert state.attributes["friendly_name"] == "MIN123456 Battery charge power limit"
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_number_device_registry(
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test that number entities are associated with the correct device."""
# Get the device from device registry
device = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")})
assert device is not None
assert device == snapshot
# Verify number entity is associated with the device
entity_entry = entity_registry.async_get(
"number.min123456_battery_charge_power_limit"
)
assert entity_entry is not None
assert entity_entry.device_id == device.id
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_all_number_entities_service_calls(
hass: HomeAssistant,
@@ -162,38 +96,6 @@ async def test_all_number_entities_service_calls(
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_number_boundary_values(
hass: HomeAssistant,
mock_growatt_v1_api,
) -> None:
"""Test setting boundary values for number entities."""
# Test minimum value
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{"entity_id": "number.min123456_battery_charge_power_limit", ATTR_VALUE: 0},
blocking=True,
)
mock_growatt_v1_api.min_write_parameter.assert_called_with(
"MIN123456", "charge_power", 0
)
# Test maximum value
mock_growatt_v1_api.reset_mock()
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{"entity_id": "number.min123456_battery_charge_power_limit", ATTR_VALUE: 100},
blocking=True,
)
mock_growatt_v1_api.min_write_parameter.assert_called_with(
"MIN123456", "charge_power", 100
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_number_missing_data(
hass: HomeAssistant,

View File

@@ -116,18 +116,3 @@ async def test_total_sensors_classic_api(
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry_classic.entry_id
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_entity_registry(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test sensor entities are properly registered."""
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -21,7 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -50,115 +50,56 @@ async def test_switch_entities(
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_turn_on_switch_success(
@pytest.mark.parametrize(
("service", "expected_value"),
[
(SERVICE_TURN_ON, 1),
(SERVICE_TURN_OFF, 0),
],
)
async def test_switch_service_call_success(
hass: HomeAssistant,
mock_growatt_v1_api,
service: str,
expected_value: int,
) -> None:
"""Test turning on a switch entity successfully."""
"""Test switch service calls successfully."""
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
service,
{ATTR_ENTITY_ID: "switch.min123456_charge_from_grid"},
blocking=True,
)
# Verify API was called with correct parameters
mock_growatt_v1_api.min_write_parameter.assert_called_once_with(
"MIN123456", "ac_charge", 1
"MIN123456", "ac_charge", expected_value
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_turn_off_switch_success(
@pytest.mark.parametrize(
"service",
[SERVICE_TURN_ON, SERVICE_TURN_OFF],
)
async def test_switch_service_call_api_error(
hass: HomeAssistant,
mock_growatt_v1_api,
service: str,
) -> None:
"""Test turning off a switch entity successfully."""
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{"entity_id": "switch.min123456_charge_from_grid"},
blocking=True,
)
# Verify API was called with correct parameters
mock_growatt_v1_api.min_write_parameter.assert_called_once_with(
"MIN123456", "ac_charge", 0
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_turn_on_switch_api_error(
hass: HomeAssistant,
mock_growatt_v1_api,
) -> None:
"""Test handling API error when turning on switch."""
"""Test handling API error when calling switch services."""
# Mock API to raise error
mock_growatt_v1_api.min_write_parameter.side_effect = GrowattV1ApiError("API Error")
with pytest.raises(HomeAssistantError, match="Error while setting switch state"):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
service,
{"entity_id": "switch.min123456_charge_from_grid"},
blocking=True,
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_turn_off_switch_api_error(
hass: HomeAssistant,
mock_growatt_v1_api,
) -> None:
"""Test handling API error when turning off switch."""
# Mock API to raise error
mock_growatt_v1_api.min_write_parameter.side_effect = GrowattV1ApiError("API Error")
with pytest.raises(HomeAssistantError, match="Error while setting switch state"):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{"entity_id": "switch.min123456_charge_from_grid"},
blocking=True,
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_switch_entity_attributes(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test switch entity attributes."""
# Check entity registry attributes
entity_entry = entity_registry.async_get("switch.min123456_charge_from_grid")
assert entity_entry is not None
assert entity_entry == snapshot(name="entity_entry")
# Check state attributes
state = hass.states.get("switch.min123456_charge_from_grid")
assert state is not None
assert state == snapshot(name="state")
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_switch_device_registry(
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test that switch entities are associated with the correct device."""
# Get the device from device registry
device = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")})
assert device is not None
assert device == snapshot
# Verify switch entity is associated with the device
entity_entry = entity_registry.async_get("switch.min123456_charge_from_grid")
assert entity_entry is not None
assert entity_entry.device_id == device.id
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_switch_state_handling_integer_values(
hass: HomeAssistant,

View File

@@ -347,6 +347,7 @@ async def test_filter_programs(
],
indirect=["appliance"],
)
@pytest.mark.parametrize("program_value", ["A not known program", None])
async def test_select_program_functionality(
hass: HomeAssistant,
client: MagicMock,
@@ -359,6 +360,7 @@ async def test_select_program_functionality(
program_key: ProgramKey,
program_to_set: str,
event_key: EventKey,
program_value: str,
) -> None:
"""Test select functionality."""
assert await integration_setup(client)
@@ -389,7 +391,7 @@ async def test_select_program_functionality(
timestamp=0,
level="",
handling="",
value="A not known program",
value=program_value,
)
]
),

Some files were not shown because too many files have changed in this diff Show More