mirror of
https://github.com/home-assistant/core.git
synced 2025-11-14 21:40:16 +00:00
Compare commits
77 Commits
claude/tri
...
mqtt-entit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3e4676b4c | ||
|
|
f852220282 | ||
|
|
5dd3bf04eb | ||
|
|
b0c2fdc57b | ||
|
|
617d44ffcf | ||
|
|
8fb8eed1c8 | ||
|
|
1ddbd4755b | ||
|
|
3bd76294dc | ||
|
|
bb97822db9 | ||
|
|
33ffccabd1 | ||
|
|
56de03ce33 | ||
|
|
0cbf7002a8 | ||
|
|
cffceffe04 | ||
|
|
253189805e | ||
|
|
2e91725ac0 | ||
|
|
3b54dddc08 | ||
|
|
9bc3d83a55 | ||
|
|
d62a554cbf | ||
|
|
f071b7cd46 | ||
|
|
37f34f6189 | ||
|
|
27dc5b6d18 | ||
|
|
0bbc2f49a6 | ||
|
|
c121fa25e8 | ||
|
|
660cea8b65 | ||
|
|
c7749ebae1 | ||
|
|
a2acb744b3 | ||
|
|
0d9158689d | ||
|
|
f85e8d6c1f | ||
|
|
9be4cc5af1 | ||
|
|
a141eedf2c | ||
|
|
03040c131c | ||
|
|
3eef50632c | ||
|
|
eff150cd54 | ||
|
|
6dcc94b0a1 | ||
|
|
7201903877 | ||
|
|
5b776307ea | ||
|
|
3cb414511b | ||
|
|
f55c36d42d | ||
|
|
26bb301cc0 | ||
|
|
4159e483ee | ||
|
|
7eb6f7cc07 | ||
|
|
a7d01b0b03 | ||
|
|
1e5cfddf83 | ||
|
|
006fc5b10a | ||
|
|
35a4b685b3 | ||
|
|
b166818ef4 | ||
|
|
34cd9f11d0 | ||
|
|
0711d62085 | ||
|
|
f70aeafb5f | ||
|
|
e2279b3589 | ||
|
|
87b68e99ec | ||
|
|
b6c8b787e8 | ||
|
|
78f26edc29 | ||
|
|
5e6a72de90 | ||
|
|
dcc559f8b6 | ||
|
|
eda49cced0 | ||
|
|
14e41ab119 | ||
|
|
46151456d8 | ||
|
|
39773a022a | ||
|
|
5f49a6450f | ||
|
|
dc8425c580 | ||
|
|
910bd371e4 | ||
|
|
802a225e11 | ||
|
|
84f66fa689 | ||
|
|
0b7e88d0e0 | ||
|
|
1fcaf95df5 | ||
|
|
6c7434531f | ||
|
|
5ec1c2b68b | ||
|
|
d8636d8346 | ||
|
|
434763c74d | ||
|
|
8cd2c1b43b | ||
|
|
44711787a4 | ||
|
|
98fd0ee683 | ||
|
|
303e4ce961 | ||
|
|
76f29298cd | ||
|
|
17f5d0a69f | ||
|
|
90561de438 |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -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"
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["ical"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["ical==11.0.0"]
|
||||
"requirements": ["ical==11.1.0"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -118,6 +118,9 @@
|
||||
"pm25": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
"pm4": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
"power": {
|
||||
"default": "mdi:flash"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -663,7 +663,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]),
|
||||
)
|
||||
|
||||
@@ -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]] = {
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["PyViCare"],
|
||||
"requirements": ["PyViCare==2.54.0"]
|
||||
"requirements": ["PyViCare==2.55.0"]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -334,7 +334,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Xbox",
|
||||
"codeowners": ["@hunterjm", "@tr4nt0r"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["auth", "application_credentials"],
|
||||
"dependencies": ["application_credentials"],
|
||||
"dhcp": [
|
||||
{
|
||||
"hostname": "xbox*"
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
18
requirements_all.txt
generated
@@ -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
|
||||
|
||||
18
requirements_test_all.txt
generated
18
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
56
tests/components/energy/conftest.py
Normal file
56
tests/components/energy/conftest.py
Normal 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
|
||||
@@ -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,
|
||||
}
|
||||
]
|
||||
|
||||
450
tests/components/energy/test_validate_power.py
Normal file
450
tests/components/energy/test_validate_power.py
Normal 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": [],
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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
@@ -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',
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user