mirror of
https://github.com/home-assistant/core.git
synced 2026-01-14 11:08:13 +00:00
Compare commits
24 Commits
simplify_t
...
simplify_l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2e421ebda | ||
|
|
56f02a41ca | ||
|
|
d43102de1b | ||
|
|
2bcd02b296 | ||
|
|
ad11c72488 | ||
|
|
ddfa6f83c3 | ||
|
|
85baf7a41d | ||
|
|
bf4d5a0bab | ||
|
|
16527ba707 | ||
|
|
0612ea4ee8 | ||
|
|
9e842152f7 | ||
|
|
63e79c3639 | ||
|
|
d0e4a7fa75 | ||
|
|
815976b9a4 | ||
|
|
86a5cc5edb | ||
|
|
3ebc08c5ec | ||
|
|
1bcbebb00c | ||
|
|
2895225552 | ||
|
|
f4f772ea31 | ||
|
|
66f60e6757 | ||
|
|
72d299f088 | ||
|
|
9c66561381 | ||
|
|
e762f839fa | ||
|
|
0c9d97c89f |
@@ -39,7 +39,7 @@ repos:
|
||||
- id: prettier
|
||||
additional_dependencies:
|
||||
- prettier@3.6.2
|
||||
- prettier-plugin-sort-json@4.1.1
|
||||
- prettier-plugin-sort-json@4.2.0
|
||||
- repo: https://github.com/cdce8p/python-typing-update
|
||||
rev: v0.6.0
|
||||
hooks:
|
||||
|
||||
@@ -407,6 +407,7 @@ homeassistant.components.person.*
|
||||
homeassistant.components.pi_hole.*
|
||||
homeassistant.components.ping.*
|
||||
homeassistant.components.plugwise.*
|
||||
homeassistant.components.pooldose.*
|
||||
homeassistant.components.portainer.*
|
||||
homeassistant.components.powerfox.*
|
||||
homeassistant.components.powerwall.*
|
||||
|
||||
@@ -7,6 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["datadog"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["datadog==0.52.0"]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
@@ -98,16 +99,29 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
|
||||
|
||||
try:
|
||||
accounts = await self.firefly.get_accounts()
|
||||
categories = await self.firefly.get_categories()
|
||||
category_details = [
|
||||
await self.firefly.get_category(
|
||||
category_id=int(category.id), start=start_date, end=end_date
|
||||
|
||||
(
|
||||
categories,
|
||||
primary_currency,
|
||||
budgets,
|
||||
bills,
|
||||
) = await asyncio.gather(
|
||||
self.firefly.get_categories(),
|
||||
self.firefly.get_currency_primary(),
|
||||
self.firefly.get_budgets(start=start_date, end=end_date),
|
||||
self.firefly.get_bills(),
|
||||
)
|
||||
|
||||
category_details = await asyncio.gather(
|
||||
*(
|
||||
self.firefly.get_category(
|
||||
category_id=int(category.id),
|
||||
start=start_date,
|
||||
end=end_date,
|
||||
)
|
||||
for category in categories
|
||||
)
|
||||
for category in categories
|
||||
]
|
||||
primary_currency = await self.firefly.get_currency_primary()
|
||||
budgets = await self.firefly.get_budgets(start=start_date, end=end_date)
|
||||
bills = await self.firefly.get_bills()
|
||||
)
|
||||
except FireflyAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyfirefly==0.1.10"]
|
||||
"requirements": ["pyfirefly==0.1.11"]
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ from .const import (
|
||||
CONF_COLD_TOLERANCE,
|
||||
CONF_HEATER,
|
||||
CONF_HOT_TOLERANCE,
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_MAX_TEMP,
|
||||
CONF_MIN_DUR,
|
||||
CONF_MIN_TEMP,
|
||||
@@ -81,7 +82,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
DEFAULT_NAME = "Generic Thermostat"
|
||||
|
||||
CONF_INITIAL_HVAC_MODE = "initial_hvac_mode"
|
||||
CONF_KEEP_ALIVE = "keep_alive"
|
||||
CONF_PRECISION = "precision"
|
||||
CONF_TARGET_TEMP = "target_temp"
|
||||
CONF_TEMP_STEP = "target_temp_step"
|
||||
|
||||
@@ -21,6 +21,7 @@ from .const import (
|
||||
CONF_COLD_TOLERANCE,
|
||||
CONF_HEATER,
|
||||
CONF_HOT_TOLERANCE,
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_MAX_TEMP,
|
||||
CONF_MIN_DUR,
|
||||
CONF_MIN_TEMP,
|
||||
@@ -59,6 +60,9 @@ OPTIONS_SCHEMA = {
|
||||
vol.Optional(CONF_MIN_DUR): selector.DurationSelector(
|
||||
selector.DurationSelectorConfig(allow_negative=False)
|
||||
),
|
||||
vol.Optional(CONF_KEEP_ALIVE): selector.DurationSelector(
|
||||
selector.DurationSelectorConfig(allow_negative=False)
|
||||
),
|
||||
vol.Optional(CONF_MIN_TEMP): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1
|
||||
|
||||
@@ -33,4 +33,5 @@ CONF_PRESETS = {
|
||||
)
|
||||
}
|
||||
CONF_SENSOR = "target_sensor"
|
||||
CONF_KEEP_ALIVE = "keep_alive"
|
||||
DEFAULT_TOLERANCE = 0.3
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"cold_tolerance": "Cold tolerance",
|
||||
"heater": "Actuator switch",
|
||||
"hot_tolerance": "Hot tolerance",
|
||||
"keep_alive": "Keep-alive interval",
|
||||
"max_temp": "Maximum target temperature",
|
||||
"min_cycle_duration": "Minimum cycle duration",
|
||||
"min_temp": "Minimum target temperature",
|
||||
@@ -29,6 +30,7 @@
|
||||
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.",
|
||||
"heater": "Switch entity used to cool or heat depending on A/C mode.",
|
||||
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5.",
|
||||
"keep_alive": "Trigger the heater periodically to keep devices from losing state. When set, min cycle duration is ignored.",
|
||||
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
|
||||
"target_sensor": "Temperature sensor that reflects the current temperature."
|
||||
},
|
||||
@@ -45,6 +47,7 @@
|
||||
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data::cold_tolerance%]",
|
||||
"heater": "[%key:component::generic_thermostat::config::step::user::data::heater%]",
|
||||
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]",
|
||||
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data::keep_alive%]",
|
||||
"max_temp": "[%key:component::generic_thermostat::config::step::user::data::max_temp%]",
|
||||
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]",
|
||||
"min_temp": "[%key:component::generic_thermostat::config::step::user::data::min_temp%]",
|
||||
@@ -55,6 +58,7 @@
|
||||
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::cold_tolerance%]",
|
||||
"heater": "[%key:component::generic_thermostat::config::step::user::data_description::heater%]",
|
||||
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::hot_tolerance%]",
|
||||
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data_description::keep_alive%]",
|
||||
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::min_cycle_duration%]",
|
||||
"target_sensor": "[%key:component::generic_thermostat::config::step::user::data_description::target_sensor%]"
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CO,
|
||||
native_unit_of_measurement_fn=lambda x: x.pollutants.co.concentration.units,
|
||||
exists_fn=lambda x: "co" in {p.code for p in x.pollutants},
|
||||
value_fn=lambda x: x.pollutants.co.concentration.value,
|
||||
),
|
||||
AirQualitySensorEntityDescription(
|
||||
@@ -143,6 +144,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
|
||||
translation_key="nitrogen_dioxide",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement_fn=lambda x: x.pollutants.no2.concentration.units,
|
||||
exists_fn=lambda x: "no2" in {p.code for p in x.pollutants},
|
||||
value_fn=lambda x: x.pollutants.no2.concentration.value,
|
||||
),
|
||||
AirQualitySensorEntityDescription(
|
||||
@@ -150,6 +152,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
|
||||
translation_key="ozone",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement_fn=lambda x: x.pollutants.o3.concentration.units,
|
||||
exists_fn=lambda x: "o3" in {p.code for p in x.pollutants},
|
||||
value_fn=lambda x: x.pollutants.o3.concentration.value,
|
||||
),
|
||||
AirQualitySensorEntityDescription(
|
||||
@@ -157,6 +160,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.PM10,
|
||||
native_unit_of_measurement_fn=lambda x: x.pollutants.pm10.concentration.units,
|
||||
exists_fn=lambda x: "pm10" in {p.code for p in x.pollutants},
|
||||
value_fn=lambda x: x.pollutants.pm10.concentration.value,
|
||||
),
|
||||
AirQualitySensorEntityDescription(
|
||||
@@ -164,6 +168,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.PM25,
|
||||
native_unit_of_measurement_fn=lambda x: x.pollutants.pm25.concentration.units,
|
||||
exists_fn=lambda x: "pm25" in {p.code for p in x.pollutants},
|
||||
value_fn=lambda x: x.pollutants.pm25.concentration.value,
|
||||
),
|
||||
AirQualitySensorEntityDescription(
|
||||
@@ -171,6 +176,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
|
||||
translation_key="sulphur_dioxide",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement_fn=lambda x: x.pollutants.so2.concentration.units,
|
||||
exists_fn=lambda x: "so2" in {p.code for p in x.pollutants},
|
||||
value_fn=lambda x: x.pollutants.so2.concentration.value,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -374,7 +374,7 @@ class SensorGroup(GroupEntity, SensorEntity):
|
||||
def async_update_group_state(self) -> None:
|
||||
"""Query all members and determine the sensor group state."""
|
||||
self.calculate_state_attributes(self._get_valid_entities())
|
||||
states: list[str] = []
|
||||
states: list[str | None] = []
|
||||
valid_units = self._valid_units
|
||||
valid_states: list[bool] = []
|
||||
sensor_values: list[tuple[str, float, State]] = []
|
||||
@@ -435,9 +435,12 @@ class SensorGroup(GroupEntity, SensorEntity):
|
||||
state.attributes.get("unit_of_measurement"),
|
||||
self.entity_id,
|
||||
)
|
||||
else:
|
||||
states.append(None)
|
||||
valid_states.append(False)
|
||||
|
||||
# Set group as unavailable if all members do not have numeric values
|
||||
self._attr_available = any(numeric_state for numeric_state in valid_states)
|
||||
# Set group as unavailable if all members are unavailable or missing
|
||||
self._attr_available = not all(s in (STATE_UNAVAILABLE, None) for s in states)
|
||||
|
||||
valid_state = self.mode(
|
||||
state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states
|
||||
@@ -446,6 +449,7 @@ class SensorGroup(GroupEntity, SensorEntity):
|
||||
|
||||
if not valid_state or not valid_state_numeric:
|
||||
self._attr_native_value = None
|
||||
self._extra_state_attribute = {}
|
||||
return
|
||||
|
||||
# Calculate values
|
||||
|
||||
@@ -8,6 +8,7 @@ from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
@@ -16,6 +16,50 @@
|
||||
"default": "mdi:hdmi-port"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"aud0": {
|
||||
"default": "mdi:audio-input-rca"
|
||||
},
|
||||
"aud1": {
|
||||
"default": "mdi:audio-input-rca"
|
||||
},
|
||||
"audout": {
|
||||
"default": "mdi:television-speaker"
|
||||
},
|
||||
"earcrx": {
|
||||
"default": "mdi:audio-video"
|
||||
},
|
||||
"edida0": {
|
||||
"default": "mdi:format-list-text"
|
||||
},
|
||||
"edida1": {
|
||||
"default": "mdi:format-list-text"
|
||||
},
|
||||
"edida2": {
|
||||
"default": "mdi:format-list-text"
|
||||
},
|
||||
"rx0": {
|
||||
"default": "mdi:video-input-hdmi"
|
||||
},
|
||||
"rx1": {
|
||||
"default": "mdi:video-input-hdmi"
|
||||
},
|
||||
"sink0": {
|
||||
"default": "mdi:television"
|
||||
},
|
||||
"sink1": {
|
||||
"default": "mdi:television"
|
||||
},
|
||||
"sink2": {
|
||||
"default": "mdi:audio-video"
|
||||
},
|
||||
"tx0": {
|
||||
"default": "mdi:cable-data"
|
||||
},
|
||||
"tx1": {
|
||||
"default": "mdi:cable-data"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"autosw": {
|
||||
"default": "mdi:import"
|
||||
|
||||
121
homeassistant/components/hdfury/sensor.py
Normal file
121
homeassistant/components/hdfury/sensor.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Sensor platform for HDFury Integration."""
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import HDFuryConfigEntry
|
||||
from .entity import HDFuryEntity
|
||||
|
||||
SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="RX0",
|
||||
translation_key="rx0",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="RX1",
|
||||
translation_key="rx1",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="TX0",
|
||||
translation_key="tx0",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="TX1",
|
||||
translation_key="tx1",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="AUD0",
|
||||
translation_key="aud0",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="AUD1",
|
||||
translation_key="aud1",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="AUDOUT",
|
||||
translation_key="audout",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="EARCRX",
|
||||
translation_key="earcrx",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="SINK0",
|
||||
translation_key="sink0",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="SINK1",
|
||||
translation_key="sink1",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="SINK2",
|
||||
translation_key="sink2",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="EDIDA0",
|
||||
translation_key="edida0",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="EDIDA1",
|
||||
translation_key="edida1",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="EDIDA2",
|
||||
translation_key="edida2",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HDFuryConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensors using the platform schema."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
HDFurySensor(coordinator, description)
|
||||
for description in SENSORS
|
||||
if description.key in coordinator.data.info
|
||||
)
|
||||
|
||||
|
||||
class HDFurySensor(HDFuryEntity, SensorEntity):
|
||||
"""Base HDFury Sensor Class."""
|
||||
|
||||
entity_description: SensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
"""Set Sensor Value."""
|
||||
|
||||
return self.coordinator.data.info[self.entity_description.key]
|
||||
@@ -57,6 +57,50 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"aud0": {
|
||||
"name": "Audio TX0"
|
||||
},
|
||||
"aud1": {
|
||||
"name": "Audio TX1"
|
||||
},
|
||||
"audout": {
|
||||
"name": "Audio output"
|
||||
},
|
||||
"earcrx": {
|
||||
"name": "eARC/ARC status"
|
||||
},
|
||||
"edida0": {
|
||||
"name": "EDID TXA0"
|
||||
},
|
||||
"edida1": {
|
||||
"name": "EDID TXA1"
|
||||
},
|
||||
"edida2": {
|
||||
"name": "EDID AUDA"
|
||||
},
|
||||
"rx0": {
|
||||
"name": "Input RX0"
|
||||
},
|
||||
"rx1": {
|
||||
"name": "Input RX1"
|
||||
},
|
||||
"sink0": {
|
||||
"name": "EDID TX0"
|
||||
},
|
||||
"sink1": {
|
||||
"name": "EDID TX1"
|
||||
},
|
||||
"sink2": {
|
||||
"name": "EDID AUD"
|
||||
},
|
||||
"tx0": {
|
||||
"name": "Output TX0"
|
||||
},
|
||||
"tx1": {
|
||||
"name": "Output TX1"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"autosw": {
|
||||
"name": "Auto switch inputs"
|
||||
|
||||
@@ -220,31 +220,33 @@ def get_accessory( # noqa: C901
|
||||
a_type = "TemperatureSensor"
|
||||
elif device_class == SensorDeviceClass.HUMIDITY and unit == PERCENTAGE:
|
||||
a_type = "HumiditySensor"
|
||||
elif (
|
||||
device_class == SensorDeviceClass.PM10
|
||||
or SensorDeviceClass.PM10 in state.entity_id
|
||||
):
|
||||
elif device_class == SensorDeviceClass.PM10:
|
||||
a_type = "PM10Sensor"
|
||||
elif (
|
||||
device_class == SensorDeviceClass.PM25
|
||||
or SensorDeviceClass.PM25 in state.entity_id
|
||||
):
|
||||
elif device_class == SensorDeviceClass.PM25:
|
||||
a_type = "PM25Sensor"
|
||||
elif device_class == SensorDeviceClass.NITROGEN_DIOXIDE:
|
||||
a_type = "NitrogenDioxideSensor"
|
||||
elif device_class == SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS:
|
||||
a_type = "VolatileOrganicCompoundsSensor"
|
||||
elif (
|
||||
device_class == SensorDeviceClass.GAS
|
||||
or SensorDeviceClass.GAS in state.entity_id
|
||||
):
|
||||
elif device_class == SensorDeviceClass.GAS:
|
||||
a_type = "AirQualitySensor"
|
||||
elif device_class == SensorDeviceClass.CO:
|
||||
a_type = "CarbonMonoxideSensor"
|
||||
elif device_class == SensorDeviceClass.CO2 or "co2" in state.entity_id:
|
||||
elif device_class == SensorDeviceClass.CO2:
|
||||
a_type = "CarbonDioxideSensor"
|
||||
elif device_class == SensorDeviceClass.ILLUMINANCE or unit == LIGHT_LUX:
|
||||
a_type = "LightSensor"
|
||||
|
||||
# Fallbacks based on entity_id
|
||||
elif SensorDeviceClass.PM10 in state.entity_id:
|
||||
a_type = "PM10Sensor"
|
||||
elif SensorDeviceClass.PM25 in state.entity_id:
|
||||
a_type = "PM25Sensor"
|
||||
elif SensorDeviceClass.GAS in state.entity_id:
|
||||
a_type = "AirQualitySensor"
|
||||
elif "co2" in state.entity_id:
|
||||
a_type = "CarbonDioxideSensor"
|
||||
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s: Unsupported sensor type (device_class=%s) (unit=%s)",
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["london_tube_status"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["london-tube-status==0.5"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -528,7 +528,10 @@ DISCOVERY_SCHEMAS = [
|
||||
),
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
required_attributes=(clusters.Thermostat.Attributes.RemoteSensing,),
|
||||
required_attributes=(
|
||||
clusters.Thermostat.Attributes.RemoteSensing,
|
||||
clusters.Thermostat.Attributes.OutdoorTemperature,
|
||||
),
|
||||
allow_multi=True,
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
|
||||
@@ -642,6 +642,7 @@ DISCOVERY_SCHEMAS = [
|
||||
list_attribute=clusters.DoorLock.Attributes.SupportedOperatingModes,
|
||||
device_to_ha=DOOR_LOCK_OPERATING_MODE_MAP.get,
|
||||
ha_to_device=DOOR_LOCK_OPERATING_MODE_MAP_REVERSE.get,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
entity_class=MatterDoorLockOperatingModeSelectEntity,
|
||||
required_attributes=(
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from aiohttp import ClientConnectionError, ClientResponseError
|
||||
from pymelcloud import get_devices
|
||||
@@ -23,21 +24,18 @@ PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) -> bool:
|
||||
"""Establish connection with MELCloud."""
|
||||
token = entry.data[CONF_TOKEN]
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
all_devices = await get_devices(
|
||||
token,
|
||||
session,
|
||||
token=entry.data[CONF_TOKEN],
|
||||
session=async_get_clientsession(hass),
|
||||
conf_update_interval=timedelta(minutes=30),
|
||||
device_set_debounce=timedelta(seconds=2),
|
||||
)
|
||||
except ClientResponseError as ex:
|
||||
if ex.status in (401, 403):
|
||||
if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
if ex.status == 429:
|
||||
if ex.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise UpdateFailed(
|
||||
"MELCloud rate limit exceeded. Your account may be temporarily blocked"
|
||||
) from ex
|
||||
@@ -49,13 +47,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) ->
|
||||
coordinators: dict[str, list[MelCloudDeviceUpdateCoordinator]] = {}
|
||||
device_registry = dr.async_get(hass)
|
||||
for device_type, devices in all_devices.items():
|
||||
coordinators[device_type] = []
|
||||
for device in devices:
|
||||
coordinator = MelCloudDeviceUpdateCoordinator(hass, device, entry)
|
||||
# Perform initial refresh for this device
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
coordinators[device_type].append(coordinator)
|
||||
# Register parent device now so zone entities can reference it via via_device
|
||||
# Build coordinators for this device_type
|
||||
coordinators[device_type] = [
|
||||
MelCloudDeviceUpdateCoordinator(hass, device, entry) for device in devices
|
||||
]
|
||||
|
||||
# Perform initial refreshes concurrently
|
||||
await asyncio.gather(
|
||||
*(
|
||||
coordinator.async_config_entry_first_refresh()
|
||||
for coordinator in coordinators[device_type]
|
||||
)
|
||||
)
|
||||
|
||||
# Register parent devices so zone entities can reference via_device
|
||||
for coordinator in coordinators[device_type]:
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
**coordinator.device_info,
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
@@ -18,8 +17,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow."""
|
||||
@@ -37,8 +34,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async def _create_client(
|
||||
self,
|
||||
username: str,
|
||||
*,
|
||||
password: str | None = None,
|
||||
password: str,
|
||||
token: str | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Create client."""
|
||||
@@ -46,13 +42,13 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async with asyncio.timeout(10):
|
||||
if (acquired_token := token) is None:
|
||||
acquired_token = await pymelcloud.login(
|
||||
username,
|
||||
password,
|
||||
async_get_clientsession(self.hass),
|
||||
email=username,
|
||||
password=password,
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
await pymelcloud.get_devices(
|
||||
acquired_token,
|
||||
async_get_clientsession(self.hass),
|
||||
token=acquired_token,
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
except ClientResponseError as err:
|
||||
if err.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
|
||||
@@ -78,8 +74,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
|
||||
),
|
||||
)
|
||||
username = user_input[CONF_USERNAME]
|
||||
return await self._create_client(username, password=user_input[CONF_PASSWORD])
|
||||
return await self._create_client(
|
||||
username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD]
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
@@ -118,9 +115,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
acquired_token = await pymelcloud.login(
|
||||
user_input[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
async_get_clientsession(self.hass),
|
||||
email=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
except (ClientResponseError, AttributeError) as err:
|
||||
if (
|
||||
@@ -134,10 +131,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
errors["base"] = "cannot_connect"
|
||||
except (
|
||||
TimeoutError,
|
||||
ClientError,
|
||||
):
|
||||
except (TimeoutError, ClientError):
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
return acquired_token, errors
|
||||
@@ -155,9 +149,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
acquired_token = await pymelcloud.login(
|
||||
user_input[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
async_get_clientsession(self.hass),
|
||||
email=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
except (ClientResponseError, AttributeError) as err:
|
||||
if (
|
||||
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
from functools import partial
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
@@ -47,7 +48,7 @@ from .util import supports_push
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def push_registrations(hass):
|
||||
def push_registrations(hass: HomeAssistant) -> dict[str, str]:
|
||||
"""Return a dictionary of push enabled registrations."""
|
||||
targets = {}
|
||||
|
||||
@@ -90,38 +91,32 @@ async def async_get_service(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> MobileAppNotificationService:
|
||||
"""Get the mobile_app notification service."""
|
||||
service = hass.data[DOMAIN][DATA_NOTIFY] = MobileAppNotificationService(hass)
|
||||
service = hass.data[DOMAIN][DATA_NOTIFY] = MobileAppNotificationService()
|
||||
return service
|
||||
|
||||
|
||||
class MobileAppNotificationService(BaseNotificationService):
|
||||
"""Implement the notification service for mobile_app."""
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initialize the service."""
|
||||
self._hass = hass
|
||||
|
||||
@property
|
||||
def targets(self):
|
||||
def targets(self) -> dict[str, str]:
|
||||
"""Return a dictionary of registered targets."""
|
||||
return push_registrations(self.hass)
|
||||
|
||||
async def async_send_message(self, message="", **kwargs):
|
||||
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message to the Lambda APNS gateway."""
|
||||
data = {ATTR_MESSAGE: message}
|
||||
|
||||
# Remove default title from notifications.
|
||||
if (
|
||||
kwargs.get(ATTR_TITLE) is not None
|
||||
and kwargs.get(ATTR_TITLE) != ATTR_TITLE_DEFAULT
|
||||
):
|
||||
data[ATTR_TITLE] = kwargs.get(ATTR_TITLE)
|
||||
|
||||
title_arg := kwargs.get(ATTR_TITLE)
|
||||
) is not None and title_arg != ATTR_TITLE_DEFAULT:
|
||||
data[ATTR_TITLE] = title_arg
|
||||
if not (targets := kwargs.get(ATTR_TARGET)):
|
||||
targets = push_registrations(self.hass).values()
|
||||
|
||||
if kwargs.get(ATTR_DATA) is not None:
|
||||
data[ATTR_DATA] = kwargs.get(ATTR_DATA)
|
||||
if (data_arg := kwargs.get(ATTR_DATA)) is not None:
|
||||
data[ATTR_DATA] = data_arg
|
||||
|
||||
local_push_channels = self.hass.data[DOMAIN][DATA_PUSH_CHANNEL]
|
||||
|
||||
@@ -166,7 +161,7 @@ class MobileAppNotificationService(BaseNotificationService):
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
response = await async_get_clientsession(self._hass).post(
|
||||
response = await async_get_clientsession(self.hass).post(
|
||||
push_url, json=target_data
|
||||
)
|
||||
result = await response.json()
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
"""Support for namecheap DNS services."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientError, ClientSession
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN, UPDATE_URL
|
||||
from .const import DOMAIN
|
||||
from .coordinator import NamecheapConfigEntry, NamecheapDnsUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
@@ -36,8 +29,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
type NamecheapConfigEntry = ConfigEntry[None]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Initialize the namecheap DNS component."""
|
||||
@@ -54,37 +45,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) -> bool:
|
||||
"""Set up Namecheap DynamicDNS from a config entry."""
|
||||
host = entry.data[CONF_HOST]
|
||||
domain = entry.data[CONF_DOMAIN]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
coordinator = NamecheapDnsUpdateCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
try:
|
||||
if not await update_namecheapdns(session, host, domain, password):
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={
|
||||
CONF_DOMAIN: f"{entry.data[CONF_HOST]}.{entry.data[CONF_DOMAIN]}"
|
||||
},
|
||||
)
|
||||
except ClientError as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
translation_placeholders={
|
||||
CONF_DOMAIN: f"{entry.data[CONF_HOST]}.{entry.data[CONF_DOMAIN]}"
|
||||
},
|
||||
) from e
|
||||
|
||||
async def update_domain_interval(now):
|
||||
"""Update the namecheap DNS entry."""
|
||||
await update_namecheapdns(session, host, domain, password)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(hass, update_domain_interval, INTERVAL)
|
||||
)
|
||||
# Add a dummy listener as we do not have regular entities
|
||||
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
|
||||
|
||||
return True
|
||||
|
||||
@@ -92,19 +59,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) ->
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return True
|
||||
|
||||
|
||||
async def update_namecheapdns(
|
||||
session: ClientSession, host: str, domain: str, password: str
|
||||
):
|
||||
"""Update namecheap DNS entry."""
|
||||
params = {"host": host, "domain": domain, "password": password}
|
||||
|
||||
resp = await session.get(UPDATE_URL, params=params)
|
||||
xml_string = await resp.text()
|
||||
|
||||
if "<ErrCount>0</ErrCount>" not in xml_string:
|
||||
_LOGGER.warning("Updating namecheap domain failed: %s", domain)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -9,7 +9,7 @@ from aiohttp import ClientError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
|
||||
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_NAME, CONF_PASSWORD
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -18,8 +18,8 @@ from homeassistant.helpers.selector import (
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from . import update_namecheapdns
|
||||
from .const import DOMAIN
|
||||
from .helpers import update_namecheapdns
|
||||
from .issue import deprecate_yaml_issue
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -37,6 +37,16 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.PASSWORD, autocomplete="current-password"
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class NamecheapDnsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Namecheap DynamicDNS."""
|
||||
@@ -89,3 +99,41 @@ class NamecheapDnsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
deprecate_yaml_issue(self.hass, import_success=True)
|
||||
return result
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfigure flow."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
if not await update_namecheapdns(
|
||||
session,
|
||||
entry.data[CONF_HOST],
|
||||
entry.data[CONF_DOMAIN],
|
||||
user_input[CONF_PASSWORD],
|
||||
):
|
||||
errors["base"] = "update_failed"
|
||||
except ClientError:
|
||||
_LOGGER.debug("Cannot connect", exc_info=True)
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
entry,
|
||||
data_updates=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=STEP_RECONFIGURE_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={CONF_NAME: entry.title},
|
||||
)
|
||||
|
||||
61
homeassistant/components/namecheapdns/coordinator.py
Normal file
61
homeassistant/components/namecheapdns/coordinator.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Coordinator for the Namecheap DynamicDNS integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
from .helpers import update_namecheapdns
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type NamecheapConfigEntry = ConfigEntry[NamecheapDnsUpdateCoordinator]
|
||||
|
||||
|
||||
INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
class NamecheapDnsUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Namecheap DynamicDNS update coordinator."""
|
||||
|
||||
config_entry: NamecheapConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: NamecheapConfigEntry) -> None:
|
||||
"""Initialize the Namecheap DynamicDNS update coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=INTERVAL,
|
||||
)
|
||||
|
||||
self.session = async_get_clientsession(hass)
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Update Namecheap DNS."""
|
||||
host = self.config_entry.data[CONF_HOST]
|
||||
domain = self.config_entry.data[CONF_DOMAIN]
|
||||
password = self.config_entry.data[CONF_PASSWORD]
|
||||
|
||||
try:
|
||||
if not await update_namecheapdns(self.session, host, domain, password):
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={CONF_DOMAIN: f"{host}.{domain}"},
|
||||
)
|
||||
except ClientError as e:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
translation_placeholders={CONF_DOMAIN: f"{host}.{domain}"},
|
||||
) from e
|
||||
24
homeassistant/components/namecheapdns/helpers.py
Normal file
24
homeassistant/components/namecheapdns/helpers.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Helpers for the Namecheap DynamicDNS integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from .const import UPDATE_URL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def update_namecheapdns(
|
||||
session: ClientSession, host: str, domain: str, password: str
|
||||
):
|
||||
"""Update namecheap DNS entry."""
|
||||
params = {"host": host, "domain": domain, "password": password}
|
||||
|
||||
resp = await session.get(UPDATE_URL, params=params)
|
||||
xml_string = await resp.text()
|
||||
|
||||
if "<ErrCount>0</ErrCount>" not in xml_string:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -9,6 +10,15 @@
|
||||
"update_failed": "Updating DNS failed"
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"password": "[%key:component::namecheapdns::config::step::user::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::namecheapdns::config::step::user::data_description::password%]"
|
||||
},
|
||||
"title": "Re-configure {name}"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"domain": "[%key:common::config_flow::data::username%]",
|
||||
|
||||
@@ -6,6 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nederlandse_spoorwegen",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["nsapi==3.1.3"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyrail"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyrail==0.4.1"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, Literal
|
||||
|
||||
from pooldose.type_definitions import DeviceInfoDict, ValueDict
|
||||
|
||||
@@ -80,7 +81,10 @@ class PooldoseEntity(CoordinatorEntity[PooldoseCoordinator]):
|
||||
return platform_data.get(self.entity_description.key)
|
||||
|
||||
async def _async_perform_write(
|
||||
self, api_call, key: str, value: bool | str | float
|
||||
self,
|
||||
api_call: Callable[[str, Any], Coroutine[Any, Any, bool]],
|
||||
key: str,
|
||||
value: bool | str | float,
|
||||
) -> None:
|
||||
"""Perform a write call to the API with unified error handling.
|
||||
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/pooldose",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "gold",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-pooldose==0.8.2"]
|
||||
}
|
||||
|
||||
@@ -71,4 +71,4 @@ rules:
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
strict-typing: done
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyportainer==1.0.22"]
|
||||
"requirements": ["pyportainer==1.0.23"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["prowl"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["prowlpy==1.1.1"]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -77,7 +78,7 @@ class TelegramNotificationService(BaseNotificationService):
|
||||
self._chat_id = chat_id
|
||||
self.hass = hass
|
||||
|
||||
def send_message(self, message="", **kwargs):
|
||||
def send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message to a user."""
|
||||
service_data = {ATTR_TARGET: kwargs.get(ATTR_TARGET, self._chat_id)}
|
||||
data = kwargs.get(ATTR_DATA)
|
||||
@@ -126,7 +127,7 @@ class TelegramNotificationService(BaseNotificationService):
|
||||
self.hass.services.call(
|
||||
TELEGRAM_BOT_DOMAIN, "send_photo", service_data=service_data
|
||||
)
|
||||
return None
|
||||
return
|
||||
if data is not None and ATTR_VIDEO in data:
|
||||
videos = data.get(ATTR_VIDEO)
|
||||
videos = videos if isinstance(videos, list) else [videos]
|
||||
@@ -135,7 +136,7 @@ class TelegramNotificationService(BaseNotificationService):
|
||||
self.hass.services.call(
|
||||
TELEGRAM_BOT_DOMAIN, "send_video", service_data=service_data
|
||||
)
|
||||
return None
|
||||
return
|
||||
if data is not None and ATTR_VOICE in data:
|
||||
voices = data.get(ATTR_VOICE)
|
||||
voices = voices if isinstance(voices, list) else [voices]
|
||||
@@ -144,17 +145,19 @@ class TelegramNotificationService(BaseNotificationService):
|
||||
self.hass.services.call(
|
||||
TELEGRAM_BOT_DOMAIN, "send_voice", service_data=service_data
|
||||
)
|
||||
return None
|
||||
return
|
||||
if data is not None and ATTR_LOCATION in data:
|
||||
service_data.update(data.get(ATTR_LOCATION))
|
||||
return self.hass.services.call(
|
||||
self.hass.services.call(
|
||||
TELEGRAM_BOT_DOMAIN, "send_location", service_data=service_data
|
||||
)
|
||||
return
|
||||
if data is not None and ATTR_DOCUMENT in data:
|
||||
service_data.update(data.get(ATTR_DOCUMENT))
|
||||
return self.hass.services.call(
|
||||
self.hass.services.call(
|
||||
TELEGRAM_BOT_DOMAIN, "send_document", service_data=service_data
|
||||
)
|
||||
return
|
||||
|
||||
# Send message
|
||||
|
||||
@@ -168,6 +171,6 @@ class TelegramNotificationService(BaseNotificationService):
|
||||
TELEGRAM_BOT_DOMAIN,
|
||||
service_data,
|
||||
)
|
||||
return self.hass.services.call(
|
||||
self.hass.services.call(
|
||||
TELEGRAM_BOT_DOMAIN, "send_message", service_data=service_data
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Final
|
||||
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
import jwt
|
||||
from tesla_fleet_api import TeslaFleetApi
|
||||
from tesla_fleet_api import TeslaFleetApi, is_valid_region
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.exceptions import (
|
||||
InvalidRegion,
|
||||
@@ -14,6 +14,7 @@ from tesla_fleet_api.exceptions import (
|
||||
OAuthExpired,
|
||||
TeslaFleetError,
|
||||
)
|
||||
from tesla_fleet_api.tesla import VehicleFleet
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform
|
||||
@@ -79,7 +80,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
|
||||
|
||||
token = jwt.decode(access_token, options={"verify_signature": False})
|
||||
scopes: list[Scope] = [Scope(s) for s in token["scp"]]
|
||||
region: str = token["ou_code"].lower()
|
||||
region_code = token["ou_code"].lower()
|
||||
region = region_code if is_valid_region(region_code) else None
|
||||
|
||||
oauth_session = OAuth2Session(hass, entry, implementation)
|
||||
|
||||
@@ -131,14 +133,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
|
||||
product.pop("cached_data", None)
|
||||
vin = product["vin"]
|
||||
signing = product["command_signing"] == "required"
|
||||
api_vehicle: VehicleFleet
|
||||
if signing:
|
||||
if not tesla.private_key:
|
||||
await tesla.get_private_key(hass.config.path("tesla_fleet.key"))
|
||||
api = tesla.vehicles.createSigned(vin)
|
||||
api_vehicle = tesla.vehicles.createSigned(vin)
|
||||
else:
|
||||
api = tesla.vehicles.createFleet(vin)
|
||||
api_vehicle = tesla.vehicles.createFleet(vin)
|
||||
coordinator = TeslaFleetVehicleDataCoordinator(
|
||||
hass, entry, api, product, Scope.VEHICLE_LOCATION in scopes
|
||||
hass, entry, api_vehicle, product, Scope.VEHICLE_LOCATION in scopes
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
@@ -153,7 +156,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
|
||||
|
||||
vehicles.append(
|
||||
TeslaFleetVehicleData(
|
||||
api=api,
|
||||
api=api_vehicle,
|
||||
coordinator=coordinator,
|
||||
vin=vin,
|
||||
device=device,
|
||||
@@ -173,14 +176,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
|
||||
)
|
||||
continue
|
||||
|
||||
api = tesla.energySites.create(site_id)
|
||||
api_energy = tesla.energySites.create(site_id)
|
||||
|
||||
live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, entry, api)
|
||||
live_coordinator = TeslaFleetEnergySiteLiveCoordinator(
|
||||
hass, entry, api_energy
|
||||
)
|
||||
history_coordinator = TeslaFleetEnergySiteHistoryCoordinator(
|
||||
hass, entry, api
|
||||
hass, entry, api_energy
|
||||
)
|
||||
info_coordinator = TeslaFleetEnergySiteInfoCoordinator(
|
||||
hass, entry, api, product
|
||||
hass, entry, api_energy, product
|
||||
)
|
||||
|
||||
await live_coordinator.async_config_entry_first_refresh()
|
||||
@@ -214,7 +219,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
|
||||
|
||||
energysites.append(
|
||||
TeslaFleetEnergyData(
|
||||
api=api,
|
||||
api=api_energy,
|
||||
live_coordinator=live_coordinator,
|
||||
history_coordinator=history_coordinator,
|
||||
info_coordinator=info_coordinator,
|
||||
|
||||
@@ -79,7 +79,7 @@ class TeslaFleetClimateEntity(TeslaFleetVehicleEntity, ClimateEntity):
|
||||
self,
|
||||
data: TeslaFleetVehicleData,
|
||||
side: TeslaFleetClimateSide,
|
||||
scopes: Scope,
|
||||
scopes: list[Scope],
|
||||
) -> None:
|
||||
"""Initialize the climate."""
|
||||
|
||||
@@ -219,7 +219,7 @@ class TeslaFleetCabinOverheatProtectionEntity(TeslaFleetVehicleEntity, ClimateEn
|
||||
def __init__(
|
||||
self,
|
||||
data: TeslaFleetVehicleData,
|
||||
scopes: Scope,
|
||||
scopes: list[Scope],
|
||||
) -> None:
|
||||
"""Initialize the cabin overheat climate entity."""
|
||||
|
||||
|
||||
@@ -178,13 +178,15 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]])
|
||||
try:
|
||||
data = (await self.api.live_status())["response"]
|
||||
except RateLimited as e:
|
||||
LOGGER.warning(
|
||||
"%s rate limited, will retry in %s seconds",
|
||||
self.name,
|
||||
e.data.get("after"),
|
||||
)
|
||||
if "after" in e.data:
|
||||
if isinstance(e.data, dict) and "after" in e.data:
|
||||
LOGGER.warning(
|
||||
"%s rate limited, will retry in %s seconds",
|
||||
self.name,
|
||||
e.data["after"],
|
||||
)
|
||||
self.update_interval = timedelta(seconds=int(e.data["after"]))
|
||||
else:
|
||||
LOGGER.warning("%s rate limited, will skip refresh", self.name)
|
||||
return self.data
|
||||
except (InvalidToken, OAuthExpired, LoginRequired) as e:
|
||||
raise ConfigEntryAuthFailed from e
|
||||
@@ -240,13 +242,15 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any
|
||||
try:
|
||||
data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"]
|
||||
except RateLimited as e:
|
||||
LOGGER.warning(
|
||||
"%s rate limited, will retry in %s seconds",
|
||||
self.name,
|
||||
e.data.get("after"),
|
||||
)
|
||||
if "after" in e.data:
|
||||
if isinstance(e.data, dict) and "after" in e.data:
|
||||
LOGGER.warning(
|
||||
"%s rate limited, will retry in %s seconds",
|
||||
self.name,
|
||||
e.data["after"],
|
||||
)
|
||||
self.update_interval = timedelta(seconds=int(e.data["after"]))
|
||||
else:
|
||||
LOGGER.warning("%s rate limited, will skip refresh", self.name)
|
||||
return self.data
|
||||
except (InvalidToken, OAuthExpired, LoginRequired) as e:
|
||||
raise ConfigEntryAuthFailed from e
|
||||
@@ -303,13 +307,15 @@ class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]])
|
||||
try:
|
||||
data = (await self.api.site_info())["response"]
|
||||
except RateLimited as e:
|
||||
LOGGER.warning(
|
||||
"%s rate limited, will retry in %s seconds",
|
||||
self.name,
|
||||
e.data.get("after"),
|
||||
)
|
||||
if "after" in e.data:
|
||||
if isinstance(e.data, dict) and "after" in e.data:
|
||||
LOGGER.warning(
|
||||
"%s rate limited, will retry in %s seconds",
|
||||
self.name,
|
||||
e.data["after"],
|
||||
)
|
||||
self.update_interval = timedelta(seconds=int(e.data["after"]))
|
||||
else:
|
||||
LOGGER.warning("%s rate limited, will skip refresh", self.name)
|
||||
return self.data
|
||||
except (InvalidToken, OAuthExpired, LoginRequired) as e:
|
||||
raise ConfigEntryAuthFailed from e
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tesla Fleet parent entity class."""
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import Any
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.tesla.energysite import EnergySite
|
||||
@@ -21,6 +21,8 @@ from .coordinator import (
|
||||
from .helpers import wake_up_vehicle
|
||||
from .models import TeslaFleetEnergyData, TeslaFleetVehicleData
|
||||
|
||||
_ApiT = TypeVar("_ApiT", bound=VehicleFleet | EnergySite)
|
||||
|
||||
|
||||
class TeslaFleetEntity(
|
||||
CoordinatorEntity[
|
||||
@@ -28,13 +30,15 @@ class TeslaFleetEntity(
|
||||
| TeslaFleetEnergySiteLiveCoordinator
|
||||
| TeslaFleetEnergySiteHistoryCoordinator
|
||||
| TeslaFleetEnergySiteInfoCoordinator
|
||||
]
|
||||
],
|
||||
Generic[_ApiT],
|
||||
):
|
||||
"""Parent class for all TeslaFleet entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
read_only: bool
|
||||
scoped: bool
|
||||
api: _ApiT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -42,7 +46,7 @@ class TeslaFleetEntity(
|
||||
| TeslaFleetEnergySiteLiveCoordinator
|
||||
| TeslaFleetEnergySiteHistoryCoordinator
|
||||
| TeslaFleetEnergySiteInfoCoordinator,
|
||||
api: VehicleFleet | EnergySite,
|
||||
api: _ApiT,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize common aspects of a TeslaFleet entity."""
|
||||
@@ -100,7 +104,7 @@ class TeslaFleetEntity(
|
||||
)
|
||||
|
||||
|
||||
class TeslaFleetVehicleEntity(TeslaFleetEntity):
|
||||
class TeslaFleetVehicleEntity(TeslaFleetEntity[VehicleFleet]):
|
||||
"""Parent class for TeslaFleet Vehicle entities."""
|
||||
|
||||
_last_update: int = 0
|
||||
@@ -128,7 +132,7 @@ class TeslaFleetVehicleEntity(TeslaFleetEntity):
|
||||
await wake_up_vehicle(self.vehicle)
|
||||
|
||||
|
||||
class TeslaFleetEnergyLiveEntity(TeslaFleetEntity):
|
||||
class TeslaFleetEnergyLiveEntity(TeslaFleetEntity[EnergySite]):
|
||||
"""Parent class for TeslaFleet Energy Site Live entities."""
|
||||
|
||||
def __init__(
|
||||
@@ -143,7 +147,7 @@ class TeslaFleetEnergyLiveEntity(TeslaFleetEntity):
|
||||
super().__init__(data.live_coordinator, data.api, key)
|
||||
|
||||
|
||||
class TeslaFleetEnergyHistoryEntity(TeslaFleetEntity):
|
||||
class TeslaFleetEnergyHistoryEntity(TeslaFleetEntity[EnergySite]):
|
||||
"""Parent class for TeslaFleet Energy Site History entities."""
|
||||
|
||||
def __init__(
|
||||
@@ -158,7 +162,7 @@ class TeslaFleetEnergyHistoryEntity(TeslaFleetEntity):
|
||||
super().__init__(data.history_coordinator, data.api, key)
|
||||
|
||||
|
||||
class TeslaFleetEnergyInfoEntity(TeslaFleetEntity):
|
||||
class TeslaFleetEnergyInfoEntity(TeslaFleetEntity[EnergySite]):
|
||||
"""Parent class for TeslaFleet Energy Site Info entities."""
|
||||
|
||||
def __init__(
|
||||
@@ -174,7 +178,7 @@ class TeslaFleetEnergyInfoEntity(TeslaFleetEntity):
|
||||
|
||||
|
||||
class TeslaFleetWallConnectorEntity(
|
||||
TeslaFleetEntity, CoordinatorEntity[TeslaFleetEnergySiteLiveCoordinator]
|
||||
TeslaFleetEntity[EnergySite], CoordinatorEntity[TeslaFleetEnergySiteLiveCoordinator]
|
||||
):
|
||||
"""Parent class for Tesla Fleet Wall Connector entities."""
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tesla-fleet-api"],
|
||||
"requirements": ["tesla-fleet-api==1.3.2"]
|
||||
"requirements": ["tesla-fleet-api==1.4.2"]
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0
|
||||
class TeslaFleetNumberVehicleEntityDescription(NumberEntityDescription):
|
||||
"""Describes TeslaFleet Number entity."""
|
||||
|
||||
func: Callable[[VehicleFleet, float], Awaitable[Any]]
|
||||
func: Callable[[VehicleFleet, int], Awaitable[Any]]
|
||||
native_min_value: float
|
||||
native_max_value: float
|
||||
min_key: str | None = None
|
||||
@@ -74,19 +74,19 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetNumberVehicleEntityDescription, ...] = (
|
||||
class TeslaFleetNumberBatteryEntityDescription(NumberEntityDescription):
|
||||
"""Describes TeslaFleet Number entity."""
|
||||
|
||||
func: Callable[[EnergySite, float], Awaitable[Any]]
|
||||
func: Callable[[EnergySite, int], Awaitable[Any]]
|
||||
requires: str | None = None
|
||||
|
||||
|
||||
ENERGY_INFO_DESCRIPTIONS: tuple[TeslaFleetNumberBatteryEntityDescription, ...] = (
|
||||
TeslaFleetNumberBatteryEntityDescription(
|
||||
key="backup_reserve_percent",
|
||||
func=lambda api, value: api.backup(int(value)),
|
||||
func=lambda api, value: api.backup(value),
|
||||
requires="components_battery",
|
||||
),
|
||||
TeslaFleetNumberBatteryEntityDescription(
|
||||
key="off_grid_vehicle_charging_reserve_percent",
|
||||
func=lambda api, value: api.off_grid_vehicle_charging_reserve(int(value)),
|
||||
func=lambda api, value: api.off_grid_vehicle_charging_reserve(value),
|
||||
requires="components_off_grid_vehicle_charging_reserve_supported",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -136,14 +136,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
# Remove the protobuff 'cached_data' that we do not use to save memory
|
||||
product.pop("cached_data", None)
|
||||
vin = product["vin"]
|
||||
api = teslemetry.vehicles.create(vin)
|
||||
coordinator = TeslemetryVehicleDataCoordinator(hass, entry, api, product)
|
||||
vehicle = teslemetry.vehicles.create(vin)
|
||||
coordinator = TeslemetryVehicleDataCoordinator(
|
||||
hass, entry, vehicle, product
|
||||
)
|
||||
device = DeviceInfo(
|
||||
identifiers={(DOMAIN, vin)},
|
||||
manufacturer="Tesla",
|
||||
configuration_url="https://teslemetry.com/console",
|
||||
name=product["display_name"],
|
||||
model=api.model,
|
||||
model=vehicle.model,
|
||||
serial_number=vin,
|
||||
)
|
||||
current_devices.add((DOMAIN, vin))
|
||||
@@ -168,7 +170,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
|
||||
vehicles.append(
|
||||
TeslemetryVehicleData(
|
||||
api=api,
|
||||
api=vehicle,
|
||||
config_entry=entry,
|
||||
coordinator=coordinator,
|
||||
poll=poll,
|
||||
@@ -194,7 +196,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
)
|
||||
continue
|
||||
|
||||
api = teslemetry.energySites.create(site_id)
|
||||
energy_site = teslemetry.energySites.create(site_id)
|
||||
device = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(site_id))},
|
||||
manufacturer="Tesla",
|
||||
@@ -210,7 +212,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
|
||||
# Check live status endpoint works before creating its coordinator
|
||||
try:
|
||||
live_status = (await api.live_status())["response"]
|
||||
live_status = (await energy_site.live_status())["response"]
|
||||
except (InvalidToken, Forbidden, SubscriptionRequired) as e:
|
||||
raise ConfigEntryAuthFailed from e
|
||||
except TeslaFleetError as e:
|
||||
@@ -218,19 +220,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
|
||||
energysites.append(
|
||||
TeslemetryEnergyData(
|
||||
api=api,
|
||||
api=energy_site,
|
||||
live_coordinator=(
|
||||
TeslemetryEnergySiteLiveCoordinator(
|
||||
hass, entry, api, live_status
|
||||
hass, entry, energy_site, live_status
|
||||
)
|
||||
if isinstance(live_status, dict)
|
||||
else None
|
||||
),
|
||||
info_coordinator=TeslemetryEnergySiteInfoCoordinator(
|
||||
hass, entry, api, product
|
||||
hass, entry, energy_site, product
|
||||
),
|
||||
history_coordinator=(
|
||||
TeslemetryEnergyHistoryCoordinator(hass, entry, api)
|
||||
TeslemetryEnergyHistoryCoordinator(hass, entry, energy_site)
|
||||
if powerwall
|
||||
else None
|
||||
),
|
||||
@@ -314,7 +316,7 @@ async def async_migrate_entry(
|
||||
# Convert legacy access token to OAuth tokens using migrate endpoint
|
||||
try:
|
||||
data = await Teslemetry(session, access_token).migrate_to_oauth(
|
||||
CLIENT_ID, access_token, hass.config.location_name
|
||||
CLIENT_ID, hass.config.location_name
|
||||
)
|
||||
except (ClientError, TypeError) as e:
|
||||
raise ConfigEntryAuthFailed from e
|
||||
|
||||
@@ -7,7 +7,11 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint
|
||||
from tesla_fleet_api.exceptions import (
|
||||
GatewayTimeout,
|
||||
InvalidResponse,
|
||||
InvalidToken,
|
||||
RateLimited,
|
||||
ServiceUnavailable,
|
||||
SubscriptionRequired,
|
||||
TeslaFleetError,
|
||||
)
|
||||
@@ -23,6 +27,22 @@ if TYPE_CHECKING:
|
||||
from .const import ENERGY_HISTORY_FIELDS, LOGGER
|
||||
from .helpers import flatten
|
||||
|
||||
RETRY_EXCEPTIONS = (
|
||||
InvalidResponse,
|
||||
RateLimited,
|
||||
ServiceUnavailable,
|
||||
GatewayTimeout,
|
||||
)
|
||||
|
||||
|
||||
def _get_retry_after(e: TeslaFleetError) -> float:
|
||||
"""Calculate wait time from exception."""
|
||||
if isinstance(e.data, dict):
|
||||
if after := e.data.get("after"):
|
||||
return float(after)
|
||||
return 10.0
|
||||
|
||||
|
||||
VEHICLE_INTERVAL = timedelta(seconds=60)
|
||||
VEHICLE_WAIT = timedelta(minutes=15)
|
||||
ENERGY_LIVE_INTERVAL = timedelta(seconds=30)
|
||||
@@ -69,14 +89,14 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update vehicle data using Teslemetry API."""
|
||||
|
||||
try:
|
||||
data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"]
|
||||
except (InvalidToken, SubscriptionRequired) as e:
|
||||
raise ConfigEntryAuthFailed from e
|
||||
except RETRY_EXCEPTIONS as e:
|
||||
raise UpdateFailed(e.message, retry_after=_get_retry_after(e)) from e
|
||||
except TeslaFleetError as e:
|
||||
raise UpdateFailed(e.message) from e
|
||||
|
||||
return flatten(data)
|
||||
|
||||
|
||||
@@ -111,19 +131,18 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]])
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update energy site data using Teslemetry API."""
|
||||
|
||||
try:
|
||||
data = (await self.api.live_status())["response"]
|
||||
except (InvalidToken, SubscriptionRequired) as e:
|
||||
raise ConfigEntryAuthFailed from e
|
||||
except RETRY_EXCEPTIONS as e:
|
||||
raise UpdateFailed(e.message, retry_after=_get_retry_after(e)) from e
|
||||
except TeslaFleetError as e:
|
||||
raise UpdateFailed(e.message) from e
|
||||
|
||||
# Convert Wall Connectors from array to dict
|
||||
data["wall_connectors"] = {
|
||||
wc["din"]: wc for wc in (data.get("wall_connectors") or [])
|
||||
}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -152,14 +171,14 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]])
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update energy site data using Teslemetry API."""
|
||||
|
||||
try:
|
||||
data = (await self.api.site_info())["response"]
|
||||
except (InvalidToken, SubscriptionRequired) as e:
|
||||
raise ConfigEntryAuthFailed from e
|
||||
except RETRY_EXCEPTIONS as e:
|
||||
raise UpdateFailed(e.message, retry_after=_get_retry_after(e)) from e
|
||||
except TeslaFleetError as e:
|
||||
raise UpdateFailed(e.message) from e
|
||||
|
||||
return flatten(data)
|
||||
|
||||
|
||||
@@ -187,11 +206,12 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update energy site data using Teslemetry API."""
|
||||
|
||||
try:
|
||||
data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"]
|
||||
except (InvalidToken, SubscriptionRequired) as e:
|
||||
raise ConfigEntryAuthFailed from e
|
||||
except RETRY_EXCEPTIONS as e:
|
||||
raise UpdateFailed(e.message, retry_after=_get_retry_after(e)) from e
|
||||
except TeslaFleetError as e:
|
||||
raise UpdateFailed(e.message) from e
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tesla-fleet-api"],
|
||||
"requirements": ["tesla-fleet-api==1.3.2", "teslemetry-stream==0.9.0"]
|
||||
"requirements": ["tesla-fleet-api==1.4.2", "teslemetry-stream==0.9.0"]
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
config = async_get_config_for_device(hass, device)
|
||||
vehicle = async_get_vehicle_for_entry(hass, device, config)
|
||||
|
||||
time: int | None = None
|
||||
time: int
|
||||
# Convert time to minutes since minute
|
||||
if "time" in call.data:
|
||||
(hours, minutes, *_seconds) = call.data["time"].split(":")
|
||||
@@ -158,6 +158,8 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="set_scheduled_charging_time"
|
||||
)
|
||||
else:
|
||||
time = 0
|
||||
|
||||
await handle_vehicle_command(
|
||||
vehicle.api.set_scheduled_charging(enable=call.data["enable"], time=time)
|
||||
@@ -198,6 +200,8 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_scheduled_departure_preconditioning",
|
||||
)
|
||||
else:
|
||||
departure_time = 0
|
||||
|
||||
# Off peak charging
|
||||
off_peak_charging_enabled = call.data.get(ATTR_OFF_PEAK_CHARGING_ENABLED, False)
|
||||
@@ -214,6 +218,8 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_scheduled_departure_off_peak",
|
||||
)
|
||||
else:
|
||||
end_off_peak_time = 0
|
||||
|
||||
await handle_vehicle_command(
|
||||
vehicle.api.set_scheduled_departure(
|
||||
@@ -252,9 +258,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
vehicle = async_get_vehicle_for_entry(hass, device, config)
|
||||
|
||||
await handle_vehicle_command(
|
||||
vehicle.api.set_valet_mode(
|
||||
call.data.get("enable"), call.data.get("pin", "")
|
||||
)
|
||||
vehicle.api.set_valet_mode(call.data["enable"], call.data["pin"])
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -276,14 +280,14 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
config = async_get_config_for_device(hass, device)
|
||||
vehicle = async_get_vehicle_for_entry(hass, device, config)
|
||||
|
||||
enable = call.data.get("enable")
|
||||
enable = call.data["enable"]
|
||||
if enable is True:
|
||||
await handle_vehicle_command(
|
||||
vehicle.api.speed_limit_activate(call.data.get("pin"))
|
||||
vehicle.api.speed_limit_activate(call.data["pin"])
|
||||
)
|
||||
elif enable is False:
|
||||
await handle_vehicle_command(
|
||||
vehicle.api.speed_limit_deactivate(call.data.get("pin"))
|
||||
vehicle.api.speed_limit_deactivate(call.data["pin"])
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -306,7 +310,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
site = async_get_energy_site_for_entry(hass, device, config)
|
||||
|
||||
resp = await handle_command(
|
||||
site.api.time_of_use_settings(call.data.get(ATTR_TOU_SETTINGS))
|
||||
site.api.time_of_use_settings(call.data[ATTR_TOU_SETTINGS])
|
||||
)
|
||||
if "error" in resp:
|
||||
raise HomeAssistantError(
|
||||
|
||||
@@ -1127,6 +1127,15 @@
|
||||
"no_vehicle_data_for_device": {
|
||||
"message": "No vehicle data for device ID: {device_id}"
|
||||
},
|
||||
"set_scheduled_charging_time": {
|
||||
"message": "Scheduled charging time is required when enabling"
|
||||
},
|
||||
"set_scheduled_departure_off_peak": {
|
||||
"message": "Off-peak charging end time is required when enabling"
|
||||
},
|
||||
"set_scheduled_departure_preconditioning": {
|
||||
"message": "Preconditioning departure time is required when enabling"
|
||||
},
|
||||
"wake_up_failed": {
|
||||
"message": "Failed to wake up vehicle: {message}"
|
||||
},
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tessie", "tesla-fleet-api"],
|
||||
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.3.2"]
|
||||
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.4.2"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
import aiohttp
|
||||
from aiohttp.client_exceptions import ClientError, ClientResponseError
|
||||
import tibber
|
||||
from tibber import data_api as tibber_data_api
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
@@ -22,7 +23,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util, ssl as ssl_util
|
||||
|
||||
from .const import AUTH_IMPLEMENTATION, DATA_HASS_CONFIG, DOMAIN, TibberConfigEntry
|
||||
from .const import (
|
||||
AUTH_IMPLEMENTATION,
|
||||
CONF_LEGACY_ACCESS_TOKEN,
|
||||
DATA_HASS_CONFIG,
|
||||
DOMAIN,
|
||||
TibberConfigEntry,
|
||||
)
|
||||
from .coordinator import TibberDataAPICoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -37,23 +44,24 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class TibberRuntimeData:
|
||||
"""Runtime data for Tibber API entries."""
|
||||
|
||||
tibber_connection: tibber.Tibber
|
||||
session: OAuth2Session
|
||||
data_api_coordinator: TibberDataAPICoordinator | None = field(default=None)
|
||||
_client: tibber.Tibber | None = None
|
||||
_client: tibber_data_api.TibberDataAPI | None = None
|
||||
|
||||
async def async_get_client(self, hass: HomeAssistant) -> tibber.Tibber:
|
||||
"""Return an authenticated Tibber client."""
|
||||
async def async_get_client(
|
||||
self, hass: HomeAssistant
|
||||
) -> tibber_data_api.TibberDataAPI:
|
||||
"""Return an authenticated Tibber Data API client."""
|
||||
await self.session.async_ensure_token_valid()
|
||||
token = self.session.token
|
||||
access_token = token.get(CONF_ACCESS_TOKEN)
|
||||
if not access_token:
|
||||
raise ConfigEntryAuthFailed("Access token missing from OAuth session")
|
||||
if self._client is None:
|
||||
self._client = tibber.Tibber(
|
||||
access_token=access_token,
|
||||
self._client = tibber_data_api.TibberDataAPI(
|
||||
access_token,
|
||||
websession=async_get_clientsession(hass),
|
||||
time_zone=dt_util.get_default_time_zone(),
|
||||
ssl=ssl_util.get_default_context(),
|
||||
)
|
||||
self._client.set_access_token(access_token)
|
||||
return self._client
|
||||
@@ -80,6 +88,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bo
|
||||
translation_key="data_api_reauth_required",
|
||||
)
|
||||
|
||||
tibber_connection = tibber.Tibber(
|
||||
access_token=entry.data[CONF_LEGACY_ACCESS_TOKEN],
|
||||
websession=async_get_clientsession(hass),
|
||||
time_zone=dt_util.get_default_time_zone(),
|
||||
ssl=ssl_util.get_default_context(),
|
||||
)
|
||||
|
||||
async def _close(event: Event) -> None:
|
||||
await tibber_connection.rt_disconnect()
|
||||
|
||||
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
|
||||
|
||||
try:
|
||||
await tibber_connection.update_info()
|
||||
except (
|
||||
TimeoutError,
|
||||
aiohttp.ClientError,
|
||||
tibber.RetryableHttpExceptionError,
|
||||
) as err:
|
||||
raise ConfigEntryNotReady("Unable to connect") from err
|
||||
except tibber.InvalidLoginError as exp:
|
||||
_LOGGER.error("Failed to login. %s", exp)
|
||||
return False
|
||||
except tibber.FatalHttpExceptionError:
|
||||
return False
|
||||
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
@@ -101,29 +135,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bo
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
entry.runtime_data = TibberRuntimeData(
|
||||
tibber_connection=tibber_connection,
|
||||
session=session,
|
||||
)
|
||||
|
||||
tibber_connection = await entry.runtime_data.async_get_client(hass)
|
||||
|
||||
async def _close(event: Event) -> None:
|
||||
await tibber_connection.rt_disconnect()
|
||||
|
||||
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
|
||||
|
||||
try:
|
||||
await tibber_connection.update_info()
|
||||
except (
|
||||
TimeoutError,
|
||||
aiohttp.ClientError,
|
||||
tibber.RetryableHttpExceptionError,
|
||||
) as err:
|
||||
raise ConfigEntryNotReady("Unable to connect") from err
|
||||
except tibber.InvalidLoginError as err:
|
||||
raise ConfigEntryAuthFailed("Invalid login credentials") from err
|
||||
except tibber.FatalHttpExceptionError as err:
|
||||
raise ConfigEntryNotReady("Fatal HTTP error from Tibber API") from err
|
||||
|
||||
coordinator = TibberDataAPICoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data.data_api_coordinator = coordinator
|
||||
@@ -139,6 +154,5 @@ async def async_unload_entry(
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
):
|
||||
tibber_connection = await config_entry.runtime_data.async_get_client(hass)
|
||||
await tibber_connection.rt_disconnect()
|
||||
await config_entry.runtime_data.tibber_connection.rt_disconnect()
|
||||
return unload_ok
|
||||
|
||||
@@ -8,16 +8,21 @@ from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import tibber
|
||||
from tibber import data_api as tibber_data_api
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
|
||||
from .const import DATA_API_DEFAULT_SCOPES, DOMAIN
|
||||
from .const import CONF_LEGACY_ACCESS_TOKEN, DATA_API_DEFAULT_SCOPES, DOMAIN
|
||||
|
||||
DATA_SCHEMA = vol.Schema({vol.Required(CONF_LEGACY_ACCESS_TOKEN): str})
|
||||
ERR_TIMEOUT = "timeout"
|
||||
ERR_CLIENT = "cannot_connect"
|
||||
ERR_TOKEN = "invalid_access_token"
|
||||
TOKEN_URL = "https://developer.tibber.com/settings/access-token"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,7 +36,8 @@ class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
super().__init__()
|
||||
self._oauth_data: dict[str, Any] | None = None
|
||||
self._access_token: str | None = None
|
||||
self._title = ""
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
@@ -46,70 +52,114 @@ class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"scope": " ".join(DATA_API_DEFAULT_SCOPES),
|
||||
}
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a reauth flow."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauthentication by reusing the user step."""
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
return await self.async_step_user()
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
|
||||
)
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||
"""Finalize the OAuth flow and create the config entry."""
|
||||
self._oauth_data = data
|
||||
return await self._async_validate_and_create()
|
||||
return self.async_show_form(
|
||||
step_id=SOURCE_USER,
|
||||
data_schema=data_schema,
|
||||
description_placeholders={"url": TOKEN_URL},
|
||||
errors={},
|
||||
)
|
||||
|
||||
async def async_step_connection_error(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle connection error retry."""
|
||||
if user_input is not None:
|
||||
return await self._async_validate_and_create()
|
||||
return self.async_show_form(step_id="connection_error")
|
||||
|
||||
async def _async_validate_and_create(self) -> ConfigFlowResult:
|
||||
"""Validate the OAuth token and create the config entry."""
|
||||
assert self._oauth_data is not None
|
||||
access_token = self._oauth_data[CONF_TOKEN][CONF_ACCESS_TOKEN]
|
||||
self._access_token = user_input[CONF_LEGACY_ACCESS_TOKEN].replace(" ", "")
|
||||
tibber_connection = tibber.Tibber(
|
||||
access_token=access_token,
|
||||
access_token=self._access_token,
|
||||
websession=async_get_clientsession(self.hass),
|
||||
)
|
||||
self._title = tibber_connection.name or "Tibber"
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
try:
|
||||
await tibber_connection.update_info()
|
||||
except TimeoutError:
|
||||
return await self.async_step_connection_error()
|
||||
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TIMEOUT
|
||||
except tibber.InvalidLoginError:
|
||||
return self.async_abort(reason=ERR_TOKEN)
|
||||
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TOKEN
|
||||
except (
|
||||
aiohttp.ClientError,
|
||||
tibber.RetryableHttpExceptionError,
|
||||
tibber.FatalHttpExceptionError,
|
||||
):
|
||||
return await self.async_step_connection_error()
|
||||
except tibber.FatalHttpExceptionError:
|
||||
return self.async_abort(reason=ERR_CLIENT)
|
||||
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_CLIENT
|
||||
|
||||
if errors:
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=SOURCE_USER,
|
||||
data_schema=data_schema,
|
||||
description_placeholders={"url": TOKEN_URL},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
await self.async_set_unique_id(tibber_connection.user_id)
|
||||
|
||||
title = tibber_connection.name or "Tibber"
|
||||
if self.source == SOURCE_REAUTH:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
self._abort_if_unique_id_mismatch(
|
||||
reason="wrong_account",
|
||||
description_placeholders={"title": reauth_entry.title},
|
||||
)
|
||||
else:
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return await self.async_step_pick_implementation()
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a reauth flow."""
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
|
||||
self._title = reauth_entry.title
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauthentication by reusing the user step."""
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
|
||||
self._title = reauth_entry.title
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
)
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||
"""Finalize the OAuth flow and create the config entry."""
|
||||
if self._access_token is None:
|
||||
return self.async_abort(reason="missing_configuration")
|
||||
|
||||
data[CONF_LEGACY_ACCESS_TOKEN] = self._access_token
|
||||
|
||||
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
|
||||
data_api_client = tibber_data_api.TibberDataAPI(
|
||||
access_token,
|
||||
websession=async_get_clientsession(self.hass),
|
||||
)
|
||||
|
||||
try:
|
||||
await data_api_client.get_userinfo()
|
||||
except (aiohttp.ClientError, TimeoutError):
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data=self._oauth_data,
|
||||
title=title,
|
||||
data=data,
|
||||
title=self._title,
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=title, data=self._oauth_data)
|
||||
return self.async_create_entry(title=self._title, data=data)
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import TibberRuntimeData
|
||||
@@ -12,6 +13,8 @@ if TYPE_CHECKING:
|
||||
type TibberConfigEntry = ConfigEntry[TibberRuntimeData]
|
||||
|
||||
|
||||
CONF_LEGACY_ACCESS_TOKEN = CONF_ACCESS_TOKEN
|
||||
|
||||
AUTH_IMPLEMENTATION = "auth_implementation"
|
||||
DATA_HASS_CONFIG = "tibber_hass_config"
|
||||
DOMAIN = "tibber"
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, cast
|
||||
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
import tibber
|
||||
from tibber.data_api import TibberDevice
|
||||
from tibber.data_api import TibberDataAPI, TibberDevice
|
||||
|
||||
from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.components.recorder.models import (
|
||||
@@ -230,26 +230,28 @@ class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
|
||||
return device_sensors.get(sensor_id)
|
||||
return None
|
||||
|
||||
async def _async_get_client(self) -> tibber.Tibber:
|
||||
"""Get the Tibber client with error handling."""
|
||||
async def _async_get_client(self) -> TibberDataAPI:
|
||||
"""Get the Tibber Data API client with error handling."""
|
||||
try:
|
||||
return await self._runtime_data.async_get_client(self.hass)
|
||||
except ConfigEntryAuthFailed:
|
||||
raise
|
||||
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
|
||||
raise UpdateFailed(f"Unable to create Tibber client: {err}") from err
|
||||
raise UpdateFailed(
|
||||
f"Unable to create Tibber Data API client: {err}"
|
||||
) from err
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Initial load of Tibber Data API devices."""
|
||||
client = await self._async_get_client()
|
||||
devices = await client.data_api.get_all_devices()
|
||||
devices = await client.get_all_devices()
|
||||
self._build_sensor_lookup(devices)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, TibberDevice]:
|
||||
"""Fetch the latest device capabilities from the Tibber Data API."""
|
||||
client = await self._async_get_client()
|
||||
try:
|
||||
devices: dict[str, TibberDevice] = await client.data_api.update_devices()
|
||||
devices: dict[str, TibberDevice] = await client.update_devices()
|
||||
except tibber.exceptions.RateLimitExceededError as err:
|
||||
raise UpdateFailed(
|
||||
f"Rate limit exceeded, retry after {err.retry_after} seconds",
|
||||
|
||||
@@ -15,7 +15,6 @@ async def async_get_config_entry_diagnostics(
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
runtime = config_entry.runtime_data
|
||||
tibber_connection = await runtime.async_get_client(hass)
|
||||
result: dict[str, Any] = {
|
||||
"homes": [
|
||||
{
|
||||
@@ -25,7 +24,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"last_cons_data_timestamp": home.last_cons_data_timestamp,
|
||||
"country": home.country,
|
||||
}
|
||||
for home in tibber_connection.get_homes(only_active=False)
|
||||
for home in runtime.tibber_connection.get_homes(only_active=False)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/tibber",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tibber"],
|
||||
"requirements": ["pyTibber==0.34.5"]
|
||||
"requirements": ["pyTibber==0.34.4"]
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tibber
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_TITLE_DEFAULT,
|
||||
NotifyEntity,
|
||||
@@ -39,9 +37,7 @@ class TibberNotificationEntity(NotifyEntity):
|
||||
|
||||
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Send a message to Tibber devices."""
|
||||
tibber_connection: tibber.Tibber = (
|
||||
await self._entry.runtime_data.async_get_client(self.hass)
|
||||
)
|
||||
tibber_connection = self._entry.runtime_data.tibber_connection
|
||||
try:
|
||||
await tibber_connection.send_notification(
|
||||
title or ATTR_TITLE_DEFAULT, message
|
||||
|
||||
@@ -348,7 +348,7 @@ async def _async_setup_graphql_sensors(
|
||||
) -> None:
|
||||
"""Set up the Tibber sensor."""
|
||||
|
||||
tibber_connection = await entry.runtime_data.async_get_client(hass)
|
||||
tibber_connection = entry.runtime_data.tibber_connection
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse:
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_config_entry",
|
||||
)
|
||||
tibber_connection = await entries[0].runtime_data.async_get_client(call.hass)
|
||||
tibber_connection = entries[0].runtime_data.tibber_connection
|
||||
|
||||
start = __get_date(call.data.get(ATTR_START), "start")
|
||||
end = __get_date(call.data.get(ATTR_END), "end")
|
||||
|
||||
@@ -2,21 +2,26 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"wrong_account": "The connected account does not match {title}. Sign in with the same Tibber account and try again."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
|
||||
"timeout": "[%key:common::config_flow::error::timeout_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"connection_error": {
|
||||
"description": "Could not connect to Tibber. Check your internet connection and try again.",
|
||||
"title": "Connection failed"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "Reconnect your Tibber account to refresh access.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"access_token": "[%key:common::config_flow::data::access_token%]"
|
||||
},
|
||||
"description": "Enter your access token from {url}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,6 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["wsdot"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["wsdot==0.0.1"]
|
||||
}
|
||||
|
||||
10
mypy.ini
generated
10
mypy.ini
generated
@@ -3826,6 +3826,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.pooldose.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.portainer.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
8
requirements_all.txt
generated
8
requirements_all.txt
generated
@@ -1866,7 +1866,7 @@ pyRFXtrx==0.31.1
|
||||
pySDCP==1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.34.5
|
||||
pyTibber==0.34.4
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.8.0
|
||||
@@ -2045,7 +2045,7 @@ pyfibaro==0.8.3
|
||||
pyfido==2.1.2
|
||||
|
||||
# homeassistant.components.firefly_iii
|
||||
pyfirefly==0.1.10
|
||||
pyfirefly==0.1.11
|
||||
|
||||
# homeassistant.components.fireservicerota
|
||||
pyfireservicerota==0.0.46
|
||||
@@ -2320,7 +2320,7 @@ pyplaato==0.0.19
|
||||
pypoint==3.0.0
|
||||
|
||||
# homeassistant.components.portainer
|
||||
pyportainer==1.0.22
|
||||
pyportainer==1.0.23
|
||||
|
||||
# homeassistant.components.probe_plus
|
||||
pyprobeplus==1.1.2
|
||||
@@ -2990,7 +2990,7 @@ temperusb==1.6.1
|
||||
# homeassistant.components.tesla_fleet
|
||||
# homeassistant.components.teslemetry
|
||||
# homeassistant.components.tessie
|
||||
tesla-fleet-api==1.3.2
|
||||
tesla-fleet-api==1.4.2
|
||||
|
||||
# homeassistant.components.powerwall
|
||||
tesla-powerwall==0.5.2
|
||||
|
||||
8
requirements_test_all.txt
generated
8
requirements_test_all.txt
generated
@@ -1597,7 +1597,7 @@ pyHomee==1.3.8
|
||||
pyRFXtrx==0.31.1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.34.5
|
||||
pyTibber==0.34.4
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.8.0
|
||||
@@ -1734,7 +1734,7 @@ pyfibaro==0.8.3
|
||||
pyfido==2.1.2
|
||||
|
||||
# homeassistant.components.firefly_iii
|
||||
pyfirefly==0.1.10
|
||||
pyfirefly==0.1.11
|
||||
|
||||
# homeassistant.components.fireservicerota
|
||||
pyfireservicerota==0.0.46
|
||||
@@ -1964,7 +1964,7 @@ pyplaato==0.0.19
|
||||
pypoint==3.0.0
|
||||
|
||||
# homeassistant.components.portainer
|
||||
pyportainer==1.0.22
|
||||
pyportainer==1.0.23
|
||||
|
||||
# homeassistant.components.probe_plus
|
||||
pyprobeplus==1.1.2
|
||||
@@ -2496,7 +2496,7 @@ temperusb==1.6.1
|
||||
# homeassistant.components.tesla_fleet
|
||||
# homeassistant.components.teslemetry
|
||||
# homeassistant.components.tessie
|
||||
tesla-fleet-api==1.3.2
|
||||
tesla-fleet-api==1.4.2
|
||||
|
||||
# homeassistant.components.powerwall
|
||||
tesla-powerwall==0.5.2
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.components.generic_thermostat.const import (
|
||||
CONF_COLD_TOLERANCE,
|
||||
CONF_HEATER,
|
||||
CONF_HOT_TOLERANCE,
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_PRESETS,
|
||||
CONF_SENSOR,
|
||||
DOMAIN,
|
||||
@@ -85,6 +86,7 @@ async def test_options(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None
|
||||
CONF_AC_MODE: False,
|
||||
CONF_COLD_TOLERANCE: 0.3,
|
||||
CONF_HOT_TOLERANCE: 0.3,
|
||||
CONF_KEEP_ALIVE: {"seconds": 60},
|
||||
CONF_PRESETS[PRESET_AWAY]: 20,
|
||||
},
|
||||
title="My dehumidifier",
|
||||
@@ -180,3 +182,46 @@ async def test_config_flow_preset_accepts_float(
|
||||
"name": "My thermostat",
|
||||
"target_sensor": "sensor.temperature",
|
||||
}
|
||||
|
||||
|
||||
async def test_config_flow_with_keep_alive(hass: HomeAssistant) -> None:
|
||||
"""Test the config flow when keep_alive is set."""
|
||||
with patch(
|
||||
"homeassistant.components.generic_thermostat.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
# Keep_alive input data for test
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_NAME: "My thermostat",
|
||||
CONF_HEATER: "switch.run",
|
||||
CONF_SENSOR: "sensor.temperature",
|
||||
CONF_AC_MODE: False,
|
||||
CONF_COLD_TOLERANCE: 0.3,
|
||||
CONF_HOT_TOLERANCE: 0.3,
|
||||
CONF_KEEP_ALIVE: {"seconds": 60},
|
||||
},
|
||||
)
|
||||
|
||||
# Complete config flow
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_PRESETS[PRESET_AWAY]: 21,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
|
||||
val = result["options"].get(CONF_KEEP_ALIVE)
|
||||
assert val is not None
|
||||
assert isinstance(val, dict)
|
||||
assert val == {"seconds": 60}
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
@@ -41,7 +41,20 @@ from homeassistant.setup import async_setup_component
|
||||
from tests.common import get_fixture_path
|
||||
|
||||
VALUES = [17, 20, 15.3]
|
||||
VALUES_ERROR = [17, "string", 15.3]
|
||||
|
||||
STATES_ONE_ERROR = ["17", "string", "15.3"]
|
||||
STATES_ONE_MISSING = ["17", None, "15.3"]
|
||||
STATES_ONE_UNKNOWN = ["17", STATE_UNKNOWN, "15.3"]
|
||||
STATES_ONE_UNAVAILABLE = ["17", STATE_UNAVAILABLE, "15.3"]
|
||||
STATES_ALL_ERROR = ["string", "string", "string"]
|
||||
STATES_ALL_MISSING = [None, None, None]
|
||||
STATES_ALL_UNKNOWN = [STATE_UNKNOWN, STATE_UNKNOWN, STATE_UNKNOWN]
|
||||
STATES_ALL_UNAVAILABLE = [STATE_UNAVAILABLE, STATE_UNAVAILABLE, STATE_UNAVAILABLE]
|
||||
STATES_MIX_MISSING_UNAVAILABLE_UNKNOWN = [None, STATE_UNAVAILABLE, STATE_UNKNOWN]
|
||||
STATES_MIX_MISSING_UNAVAILABLE = [None, STATE_UNAVAILABLE, STATE_UNAVAILABLE]
|
||||
STATES_MIX_MISSING_UNKNOWN = [None, STATE_UNKNOWN, STATE_UNKNOWN]
|
||||
STATES_MIX_UNAVAILABLE_UNKNOWN = [STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNKNOWN]
|
||||
|
||||
COUNT = len(VALUES)
|
||||
MIN_VALUE = min(VALUES)
|
||||
MAX_VALUE = max(VALUES)
|
||||
@@ -53,6 +66,18 @@ SUM_VALUE = sum(VALUES)
|
||||
PRODUCT_VALUE = prod(VALUES)
|
||||
|
||||
|
||||
def set_or_remove_state(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
state: str | None,
|
||||
) -> None:
|
||||
"""Set or remove the state of an entity."""
|
||||
if state is None:
|
||||
hass.states.async_remove(entity_id)
|
||||
else:
|
||||
hass.states.async_set(entity_id, state)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sensor_type", "result", "attributes"),
|
||||
[
|
||||
@@ -90,7 +115,7 @@ async def test_sensors2(
|
||||
for entity_id, value in dict(zip(entity_ids, VALUES, strict=False)).items():
|
||||
hass.states.async_set(
|
||||
entity_id,
|
||||
value,
|
||||
str(value),
|
||||
{
|
||||
ATTR_DEVICE_CLASS: SensorDeviceClass.VOLUME,
|
||||
ATTR_STATE_CLASS: SensorStateClass.TOTAL,
|
||||
@@ -140,7 +165,7 @@ async def test_sensors_attributes_defined(hass: HomeAssistant) -> None:
|
||||
for entity_id, value in dict(zip(entity_ids, VALUES, strict=False)).items():
|
||||
hass.states.async_set(
|
||||
entity_id,
|
||||
value,
|
||||
str(value),
|
||||
{
|
||||
ATTR_DEVICE_CLASS: SensorDeviceClass.VOLUME,
|
||||
ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT,
|
||||
@@ -181,11 +206,11 @@ async def test_not_enough_sensor_value(hass: HomeAssistant) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("sensor.test_max")
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
assert state.state == STATE_UNKNOWN
|
||||
assert state.attributes.get("min_entity_id") is None
|
||||
assert state.attributes.get("max_entity_id") is None
|
||||
|
||||
hass.states.async_set(entity_ids[1], VALUES[1])
|
||||
hass.states.async_set(entity_ids[1], str(VALUES[1]))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("sensor.test_max")
|
||||
@@ -203,15 +228,15 @@ async def test_not_enough_sensor_value(hass: HomeAssistant) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("sensor.test_max")
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
assert state.state == STATE_UNKNOWN
|
||||
assert state.attributes.get("min_entity_id") is None
|
||||
assert state.attributes.get("max_entity_id") is None
|
||||
|
||||
|
||||
async def test_reload(hass: HomeAssistant) -> None:
|
||||
"""Verify we can reload sensors."""
|
||||
hass.states.async_set("sensor.test_1", 12345)
|
||||
hass.states.async_set("sensor.test_2", 45678)
|
||||
hass.states.async_set("sensor.test_1", "12345")
|
||||
hass.states.async_set("sensor.test_2", "45678")
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
@@ -249,8 +274,28 @@ async def test_reload(hass: HomeAssistant) -> None:
|
||||
assert hass.states.get("sensor.second_test")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("states_list", "expected_group_state"),
|
||||
[
|
||||
(STATES_ONE_ERROR, "17.0"),
|
||||
(STATES_ONE_MISSING, "17.0"),
|
||||
(STATES_ONE_UNKNOWN, "17.0"),
|
||||
(STATES_ONE_UNAVAILABLE, "17.0"),
|
||||
(STATES_ALL_ERROR, STATE_UNKNOWN),
|
||||
(STATES_ALL_MISSING, STATE_UNAVAILABLE),
|
||||
(STATES_ALL_UNKNOWN, STATE_UNKNOWN),
|
||||
(STATES_ALL_UNAVAILABLE, STATE_UNAVAILABLE),
|
||||
(STATES_MIX_MISSING_UNAVAILABLE, STATE_UNAVAILABLE),
|
||||
(STATES_MIX_MISSING_UNKNOWN, STATE_UNKNOWN),
|
||||
(STATES_MIX_UNAVAILABLE_UNKNOWN, STATE_UNKNOWN),
|
||||
(STATES_MIX_MISSING_UNAVAILABLE_UNKNOWN, STATE_UNKNOWN),
|
||||
],
|
||||
)
|
||||
async def test_sensor_incorrect_state_with_ignore_non_numeric(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
states_list: list[str | None],
|
||||
expected_group_state: str,
|
||||
) -> None:
|
||||
"""Test that non numeric values are ignored in a group."""
|
||||
config = {
|
||||
@@ -271,27 +316,48 @@ async def test_sensor_incorrect_state_with_ignore_non_numeric(
|
||||
entity_ids = config["sensor"]["entities"]
|
||||
|
||||
# Check that the final sensor value ignores the non numeric input
|
||||
for entity_id, value in dict(zip(entity_ids, VALUES_ERROR, strict=False)).items():
|
||||
hass.states.async_set(entity_id, value)
|
||||
for entity_id, value in dict(zip(entity_ids, states_list, strict=False)).items():
|
||||
set_or_remove_state(hass, entity_id, value)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("sensor.test_ignore_non_numeric")
|
||||
assert state.state == "17.0"
|
||||
assert state.state == expected_group_state
|
||||
assert (
|
||||
"Unable to use state. Only numerical states are supported," not in caplog.text
|
||||
)
|
||||
|
||||
# Check that the final sensor value with all numeric inputs
|
||||
for entity_id, value in dict(zip(entity_ids, VALUES, strict=False)).items():
|
||||
hass.states.async_set(entity_id, value)
|
||||
hass.states.async_set(entity_id, str(value))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("sensor.test_ignore_non_numeric")
|
||||
assert state.state == "20.0"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("states_list", "expected_group_state", "error_count"),
|
||||
[
|
||||
(STATES_ONE_ERROR, STATE_UNKNOWN, 1),
|
||||
(STATES_ONE_MISSING, STATE_UNKNOWN, 0),
|
||||
(STATES_ONE_UNKNOWN, STATE_UNKNOWN, 1),
|
||||
(STATES_ONE_UNAVAILABLE, STATE_UNKNOWN, 1),
|
||||
(STATES_ALL_ERROR, STATE_UNKNOWN, 3),
|
||||
(STATES_ALL_MISSING, STATE_UNAVAILABLE, 0),
|
||||
(STATES_ALL_UNKNOWN, STATE_UNKNOWN, 3),
|
||||
(STATES_ALL_UNAVAILABLE, STATE_UNAVAILABLE, 3),
|
||||
(STATES_MIX_MISSING_UNAVAILABLE, STATE_UNAVAILABLE, 2),
|
||||
(STATES_MIX_MISSING_UNKNOWN, STATE_UNKNOWN, 2),
|
||||
(STATES_MIX_UNAVAILABLE_UNKNOWN, STATE_UNKNOWN, 3),
|
||||
(STATES_MIX_MISSING_UNAVAILABLE_UNKNOWN, STATE_UNKNOWN, 2),
|
||||
],
|
||||
)
|
||||
async def test_sensor_incorrect_state_with_not_ignore_non_numeric(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
states_list: list[str | None],
|
||||
expected_group_state: str,
|
||||
error_count: int,
|
||||
) -> None:
|
||||
"""Test that non numeric values cause a group to be unknown."""
|
||||
config = {
|
||||
@@ -312,24 +378,46 @@ async def test_sensor_incorrect_state_with_not_ignore_non_numeric(
|
||||
entity_ids = config["sensor"]["entities"]
|
||||
|
||||
# Check that the final sensor value is unavailable if a non numeric input exists
|
||||
for entity_id, value in dict(zip(entity_ids, VALUES_ERROR, strict=False)).items():
|
||||
hass.states.async_set(entity_id, value)
|
||||
for entity_id, value in dict(zip(entity_ids, states_list, strict=False)).items():
|
||||
set_or_remove_state(hass, entity_id, value)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("sensor.test_failure")
|
||||
assert state.state == "unknown"
|
||||
assert "Unable to use state. Only numerical states are supported" in caplog.text
|
||||
assert state.state == expected_group_state
|
||||
assert (
|
||||
caplog.text.count("Unable to use state. Only numerical states are supported")
|
||||
== error_count
|
||||
)
|
||||
|
||||
# Check that the final sensor value is correct with all numeric inputs
|
||||
for entity_id, value in dict(zip(entity_ids, VALUES, strict=False)).items():
|
||||
hass.states.async_set(entity_id, value)
|
||||
hass.states.async_set(entity_id, str(value))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("sensor.test_failure")
|
||||
assert state.state == "20.0"
|
||||
|
||||
|
||||
async def test_sensor_require_all_states(hass: HomeAssistant) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("states_list", "expected_group_state"),
|
||||
[
|
||||
(STATES_ONE_ERROR, STATE_UNKNOWN),
|
||||
(STATES_ONE_MISSING, STATE_UNKNOWN),
|
||||
(STATES_ONE_UNKNOWN, STATE_UNKNOWN),
|
||||
(STATES_ONE_UNAVAILABLE, STATE_UNKNOWN),
|
||||
(STATES_ALL_ERROR, STATE_UNKNOWN),
|
||||
(STATES_ALL_MISSING, STATE_UNAVAILABLE),
|
||||
(STATES_ALL_UNKNOWN, STATE_UNKNOWN),
|
||||
(STATES_ALL_UNAVAILABLE, STATE_UNAVAILABLE),
|
||||
(STATES_MIX_MISSING_UNAVAILABLE, STATE_UNAVAILABLE),
|
||||
(STATES_MIX_MISSING_UNKNOWN, STATE_UNKNOWN),
|
||||
(STATES_MIX_UNAVAILABLE_UNKNOWN, STATE_UNKNOWN),
|
||||
(STATES_MIX_MISSING_UNAVAILABLE_UNKNOWN, STATE_UNKNOWN),
|
||||
],
|
||||
)
|
||||
async def test_sensor_require_all_states(
|
||||
hass: HomeAssistant, states_list: list[str | None], expected_group_state: str
|
||||
) -> None:
|
||||
"""Test the sum sensor with missing state require all."""
|
||||
config = {
|
||||
SENSOR_DOMAIN: {
|
||||
@@ -348,13 +436,13 @@ async def test_sensor_require_all_states(hass: HomeAssistant) -> None:
|
||||
|
||||
entity_ids = config["sensor"]["entities"]
|
||||
|
||||
for entity_id, value in dict(zip(entity_ids, VALUES_ERROR, strict=False)).items():
|
||||
hass.states.async_set(entity_id, value)
|
||||
for entity_id, value in dict(zip(entity_ids, states_list, strict=False)).items():
|
||||
set_or_remove_state(hass, entity_id, value)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("sensor.test_sum")
|
||||
|
||||
assert state.state == STATE_UNKNOWN
|
||||
assert state.state == expected_group_state
|
||||
|
||||
|
||||
async def test_sensor_calculated_properties(hass: HomeAssistant) -> None:
|
||||
@@ -373,7 +461,7 @@ async def test_sensor_calculated_properties(hass: HomeAssistant) -> None:
|
||||
|
||||
hass.states.async_set(
|
||||
entity_ids[0],
|
||||
VALUES[0],
|
||||
str(VALUES[0]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.ENERGY,
|
||||
"state_class": SensorStateClass.TOTAL,
|
||||
@@ -382,7 +470,7 @@ async def test_sensor_calculated_properties(hass: HomeAssistant) -> None:
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_ids[1],
|
||||
VALUES[1],
|
||||
str(VALUES[1]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.ENERGY,
|
||||
"state_class": SensorStateClass.TOTAL,
|
||||
@@ -391,7 +479,7 @@ async def test_sensor_calculated_properties(hass: HomeAssistant) -> None:
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_ids[2],
|
||||
VALUES[2],
|
||||
str(VALUES[2]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.ENERGY,
|
||||
"state_class": SensorStateClass.TOTAL,
|
||||
@@ -413,7 +501,7 @@ async def test_sensor_calculated_properties(hass: HomeAssistant) -> None:
|
||||
# is converted correctly by the group sensor
|
||||
hass.states.async_set(
|
||||
entity_ids[2],
|
||||
VALUES[2],
|
||||
str(VALUES[2]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.ENERGY,
|
||||
"state_class": SensorStateClass.TOTAL,
|
||||
@@ -446,7 +534,7 @@ async def test_sensor_with_uoms_but_no_device_class(
|
||||
|
||||
hass.states.async_set(
|
||||
entity_ids[0],
|
||||
VALUES[0],
|
||||
str(VALUES[0]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.POWER,
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
@@ -455,7 +543,7 @@ async def test_sensor_with_uoms_but_no_device_class(
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_ids[1],
|
||||
VALUES[1],
|
||||
str(VALUES[1]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.POWER,
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
@@ -464,7 +552,7 @@ async def test_sensor_with_uoms_but_no_device_class(
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_ids[2],
|
||||
VALUES[2],
|
||||
str(VALUES[2]),
|
||||
{
|
||||
"unit_of_measurement": "W",
|
||||
},
|
||||
@@ -487,7 +575,7 @@ async def test_sensor_with_uoms_but_no_device_class(
|
||||
|
||||
hass.states.async_set(
|
||||
entity_ids[0],
|
||||
VALUES[0],
|
||||
str(VALUES[0]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.POWER,
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
@@ -508,7 +596,7 @@ async def test_sensor_with_uoms_but_no_device_class(
|
||||
|
||||
hass.states.async_set(
|
||||
entity_ids[0],
|
||||
VALUES[0],
|
||||
str(VALUES[0]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.POWER,
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
@@ -541,7 +629,7 @@ async def test_sensor_calculated_properties_not_same(
|
||||
|
||||
hass.states.async_set(
|
||||
entity_ids[0],
|
||||
VALUES[0],
|
||||
str(VALUES[0]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.ENERGY,
|
||||
"state_class": SensorStateClass.TOTAL,
|
||||
@@ -550,7 +638,7 @@ async def test_sensor_calculated_properties_not_same(
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_ids[1],
|
||||
VALUES[1],
|
||||
str(VALUES[1]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.ENERGY,
|
||||
"state_class": SensorStateClass.TOTAL,
|
||||
@@ -559,7 +647,7 @@ async def test_sensor_calculated_properties_not_same(
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_ids[2],
|
||||
VALUES[2],
|
||||
str(VALUES[2]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.CURRENT,
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
@@ -604,7 +692,7 @@ async def test_sensor_calculated_result_fails_on_uom(hass: HomeAssistant) -> Non
|
||||
|
||||
hass.states.async_set(
|
||||
entity_ids[0],
|
||||
VALUES[0],
|
||||
str(VALUES[0]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.ENERGY,
|
||||
"state_class": SensorStateClass.TOTAL,
|
||||
@@ -613,7 +701,7 @@ async def test_sensor_calculated_result_fails_on_uom(hass: HomeAssistant) -> Non
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_ids[1],
|
||||
VALUES[1],
|
||||
str(VALUES[1]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.ENERGY,
|
||||
"state_class": SensorStateClass.TOTAL,
|
||||
@@ -622,7 +710,7 @@ async def test_sensor_calculated_result_fails_on_uom(hass: HomeAssistant) -> Non
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_ids[2],
|
||||
VALUES[2],
|
||||
str(VALUES[2]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.ENERGY,
|
||||
"state_class": SensorStateClass.TOTAL,
|
||||
@@ -642,7 +730,7 @@ async def test_sensor_calculated_result_fails_on_uom(hass: HomeAssistant) -> Non
|
||||
|
||||
hass.states.async_set(
|
||||
entity_ids[2],
|
||||
12,
|
||||
"12",
|
||||
{
|
||||
"device_class": SensorDeviceClass.ENERGY,
|
||||
"state_class": SensorStateClass.TOTAL,
|
||||
@@ -652,7 +740,7 @@ async def test_sensor_calculated_result_fails_on_uom(hass: HomeAssistant) -> Non
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("sensor.test_sum")
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
assert state.state == STATE_UNKNOWN
|
||||
assert state.attributes.get("device_class") == "energy"
|
||||
assert state.attributes.get("state_class") == "total"
|
||||
assert state.attributes.get("unit_of_measurement") is None
|
||||
@@ -677,7 +765,7 @@ async def test_sensor_calculated_properties_not_convertible_device_class(
|
||||
|
||||
hass.states.async_set(
|
||||
entity_ids[0],
|
||||
VALUES[0],
|
||||
str(VALUES[0]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.HUMIDITY,
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
@@ -686,7 +774,7 @@ async def test_sensor_calculated_properties_not_convertible_device_class(
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_ids[1],
|
||||
VALUES[1],
|
||||
str(VALUES[1]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.HUMIDITY,
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
@@ -695,7 +783,7 @@ async def test_sensor_calculated_properties_not_convertible_device_class(
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_ids[2],
|
||||
VALUES[2],
|
||||
str(VALUES[2]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.HUMIDITY,
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
@@ -720,7 +808,7 @@ async def test_sensor_calculated_properties_not_convertible_device_class(
|
||||
|
||||
hass.states.async_set(
|
||||
entity_ids[2],
|
||||
VALUES[2],
|
||||
str(VALUES[2]),
|
||||
{
|
||||
"device_class": SensorDeviceClass.HUMIDITY,
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
@@ -759,8 +847,14 @@ async def test_last_sensor(hass: HomeAssistant) -> None:
|
||||
|
||||
entity_ids = config["sensor"]["entities"]
|
||||
|
||||
for entity_id in entity_ids[1:]:
|
||||
hass.states.async_set(entity_id, "0.0")
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get("sensor.test_last")
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
for entity_id, value in dict(zip(entity_ids, VALUES, strict=False)).items():
|
||||
hass.states.async_set(entity_id, value)
|
||||
hass.states.async_set(entity_id, str(value))
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get("sensor.test_last")
|
||||
assert str(float(value)) == state.state
|
||||
@@ -797,7 +891,7 @@ async def test_sensors_attributes_added_when_entity_info_available(
|
||||
for entity_id, value in dict(zip(entity_ids, VALUES, strict=False)).items():
|
||||
hass.states.async_set(
|
||||
entity_id,
|
||||
value,
|
||||
str(value),
|
||||
{
|
||||
ATTR_DEVICE_CLASS: SensorDeviceClass.VOLUME,
|
||||
ATTR_STATE_CLASS: SensorStateClass.TOTAL,
|
||||
@@ -843,9 +937,9 @@ async def test_sensor_state_class_no_uom_not_available(
|
||||
"unit_of_measurement": PERCENTAGE,
|
||||
}
|
||||
|
||||
hass.states.async_set(entity_ids[0], VALUES[0], input_attributes)
|
||||
hass.states.async_set(entity_ids[1], VALUES[1], input_attributes)
|
||||
hass.states.async_set(entity_ids[2], VALUES[2], input_attributes)
|
||||
hass.states.async_set(entity_ids[0], str(VALUES[0]), input_attributes)
|
||||
hass.states.async_set(entity_ids[1], str(VALUES[1]), input_attributes)
|
||||
hass.states.async_set(entity_ids[2], str(VALUES[2]), input_attributes)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert await async_setup_component(hass, "sensor", config)
|
||||
@@ -864,7 +958,7 @@ async def test_sensor_state_class_no_uom_not_available(
|
||||
# sensor.test_3 drops the unit of measurement
|
||||
hass.states.async_set(
|
||||
entity_ids[2],
|
||||
VALUES[2],
|
||||
str(VALUES[2]),
|
||||
{
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
},
|
||||
@@ -914,7 +1008,7 @@ async def test_sensor_different_attributes_ignore_non_numeric(
|
||||
test_cases = [
|
||||
{
|
||||
"entity": entity_ids[0],
|
||||
"value": VALUES[0],
|
||||
"value": str(VALUES[0]),
|
||||
"attributes": {
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
"unit_of_measurement": PERCENTAGE,
|
||||
@@ -926,7 +1020,7 @@ async def test_sensor_different_attributes_ignore_non_numeric(
|
||||
},
|
||||
{
|
||||
"entity": entity_ids[1],
|
||||
"value": VALUES[1],
|
||||
"value": str(VALUES[1]),
|
||||
"attributes": {
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
"device_class": SensorDeviceClass.HUMIDITY,
|
||||
@@ -939,7 +1033,7 @@ async def test_sensor_different_attributes_ignore_non_numeric(
|
||||
},
|
||||
{
|
||||
"entity": entity_ids[2],
|
||||
"value": VALUES[2],
|
||||
"value": str(VALUES[2]),
|
||||
"attributes": {
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
"device_class": SensorDeviceClass.TEMPERATURE,
|
||||
@@ -952,7 +1046,7 @@ async def test_sensor_different_attributes_ignore_non_numeric(
|
||||
},
|
||||
{
|
||||
"entity": entity_ids[2],
|
||||
"value": VALUES[2],
|
||||
"value": str(VALUES[2]),
|
||||
"attributes": {
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
"device_class": SensorDeviceClass.HUMIDITY,
|
||||
@@ -966,7 +1060,7 @@ async def test_sensor_different_attributes_ignore_non_numeric(
|
||||
},
|
||||
{
|
||||
"entity": entity_ids[0],
|
||||
"value": VALUES[0],
|
||||
"value": str(VALUES[0]),
|
||||
"attributes": {
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
"device_class": SensorDeviceClass.HUMIDITY,
|
||||
@@ -980,7 +1074,7 @@ async def test_sensor_different_attributes_ignore_non_numeric(
|
||||
},
|
||||
{
|
||||
"entity": entity_ids[0],
|
||||
"value": VALUES[0],
|
||||
"value": str(VALUES[0]),
|
||||
"attributes": {
|
||||
"state_class": SensorStateClass.MEASUREMENT,
|
||||
},
|
||||
|
||||
@@ -68,6 +68,20 @@ def mock_hdfury_client() -> Generator[AsyncMock]:
|
||||
"portseltx0": "0",
|
||||
"portseltx1": "4",
|
||||
"opmode": "0",
|
||||
"RX0": "4K59.937 593MHz 422 BT2020 12b 2.2",
|
||||
"RX1": "no signal",
|
||||
"TX0": "4K59.937 593MHz 422 BT2020 12b 2.2",
|
||||
"TX1": "4K59.937 593MHz 422 BT2020 12b 2.2",
|
||||
"AUD0": "bitstream 48kHz",
|
||||
"AUD1": "bitstream 48kHz",
|
||||
"AUDOUT": "bitstream 48kHz",
|
||||
"EARCRX": "eARC/ARC not active",
|
||||
"SINK0": "LG TV SSCR2: 4K120 444 FRL6 VRR DSC ALLM DV HDR10 HLG",
|
||||
"EDIDA0": "MAT Atmos, DD Atmos, DD, DTS:X+IMAX, DTSHD, DTS, LPCM 2.0 192kHz 24b",
|
||||
"SINK1": "Signify FCD: 4K60 444 DV HDR10+ HLG",
|
||||
"EDIDA1": "DD, DTS, LPCM 2.0 48kHz 24b",
|
||||
"SINK2": "Bose CineMate: 4K60 420 ",
|
||||
"EDIDA2": "DD, DTS, LPCM 7.1 96kHz 24b",
|
||||
}
|
||||
)
|
||||
coord_client.get_config = AsyncMock(
|
||||
|
||||
@@ -22,6 +22,20 @@
|
||||
'relay': '0',
|
||||
}),
|
||||
'info': dict({
|
||||
'AUD0': 'bitstream 48kHz',
|
||||
'AUD1': 'bitstream 48kHz',
|
||||
'AUDOUT': 'bitstream 48kHz',
|
||||
'EARCRX': 'eARC/ARC not active',
|
||||
'EDIDA0': 'MAT Atmos, DD Atmos, DD, DTS:X+IMAX, DTSHD, DTS, LPCM 2.0 192kHz 24b',
|
||||
'EDIDA1': 'DD, DTS, LPCM 2.0 48kHz 24b',
|
||||
'EDIDA2': 'DD, DTS, LPCM 7.1 96kHz 24b',
|
||||
'RX0': '4K59.937 593MHz 422 BT2020 12b 2.2',
|
||||
'RX1': 'no signal',
|
||||
'SINK0': 'LG TV SSCR2: 4K120 444 FRL6 VRR DSC ALLM DV HDR10 HLG',
|
||||
'SINK1': 'Signify FCD: 4K60 444 DV HDR10+ HLG',
|
||||
'SINK2': 'Bose CineMate: 4K60 420 ',
|
||||
'TX0': '4K59.937 593MHz 422 BT2020 12b 2.2',
|
||||
'TX1': '4K59.937 593MHz 422 BT2020 12b 2.2',
|
||||
'opmode': '0',
|
||||
'portseltx0': '0',
|
||||
'portseltx1': '4',
|
||||
|
||||
673
tests/components/hdfury/snapshots/test_sensor.ambr
Normal file
673
tests/components/hdfury/snapshots/test_sensor.ambr
Normal file
@@ -0,0 +1,673 @@
|
||||
# serializer version: 1
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_audio_output-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': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_audio_output',
|
||||
'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': 'Audio output',
|
||||
'platform': 'hdfury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'audout',
|
||||
'unique_id': '000123456789_AUDOUT',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_audio_output-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'HDFury VRROOM-02 Audio output',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_audio_output',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'bitstream 48kHz',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_audio_tx0-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': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_audio_tx0',
|
||||
'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': 'Audio TX0',
|
||||
'platform': 'hdfury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'aud0',
|
||||
'unique_id': '000123456789_AUD0',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_audio_tx0-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'HDFury VRROOM-02 Audio TX0',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_audio_tx0',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'bitstream 48kHz',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_audio_tx1-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': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_audio_tx1',
|
||||
'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': 'Audio TX1',
|
||||
'platform': 'hdfury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'aud1',
|
||||
'unique_id': '000123456789_AUD1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_audio_tx1-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'HDFury VRROOM-02 Audio TX1',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_audio_tx1',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'bitstream 48kHz',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_earc_arc_status-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': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_earc_arc_status',
|
||||
'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': 'eARC/ARC status',
|
||||
'platform': 'hdfury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'earcrx',
|
||||
'unique_id': '000123456789_EARCRX',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_earc_arc_status-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'HDFury VRROOM-02 eARC/ARC status',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_earc_arc_status',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'eARC/ARC not active',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_edid_aud-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': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_edid_aud',
|
||||
'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': 'EDID AUD',
|
||||
'platform': 'hdfury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'sink2',
|
||||
'unique_id': '000123456789_SINK2',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_edid_aud-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'HDFury VRROOM-02 EDID AUD',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_edid_aud',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'Bose CineMate: 4K60 420 ',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_edid_auda-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': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_edid_auda',
|
||||
'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': 'EDID AUDA',
|
||||
'platform': 'hdfury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'edida2',
|
||||
'unique_id': '000123456789_EDIDA2',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_edid_auda-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'HDFury VRROOM-02 EDID AUDA',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_edid_auda',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'DD, DTS, LPCM 7.1 96kHz 24b',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_edid_tx0-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': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_edid_tx0',
|
||||
'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': 'EDID TX0',
|
||||
'platform': 'hdfury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'sink0',
|
||||
'unique_id': '000123456789_SINK0',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_edid_tx0-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'HDFury VRROOM-02 EDID TX0',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_edid_tx0',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'LG TV SSCR2: 4K120 444 FRL6 VRR DSC ALLM DV HDR10 HLG',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_edid_tx1-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': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_edid_tx1',
|
||||
'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': 'EDID TX1',
|
||||
'platform': 'hdfury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'sink1',
|
||||
'unique_id': '000123456789_SINK1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_edid_tx1-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'HDFury VRROOM-02 EDID TX1',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_edid_tx1',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'Signify FCD: 4K60 444 DV HDR10+ HLG',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_edid_txa0-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': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_edid_txa0',
|
||||
'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': 'EDID TXA0',
|
||||
'platform': 'hdfury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'edida0',
|
||||
'unique_id': '000123456789_EDIDA0',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_edid_txa0-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'HDFury VRROOM-02 EDID TXA0',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_edid_txa0',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'MAT Atmos, DD Atmos, DD, DTS:X+IMAX, DTSHD, DTS, LPCM 2.0 192kHz 24b',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_edid_txa1-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': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_edid_txa1',
|
||||
'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': 'EDID TXA1',
|
||||
'platform': 'hdfury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'edida1',
|
||||
'unique_id': '000123456789_EDIDA1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_edid_txa1-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'HDFury VRROOM-02 EDID TXA1',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_edid_txa1',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'DD, DTS, LPCM 2.0 48kHz 24b',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_input_rx0-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': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_input_rx0',
|
||||
'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': 'Input RX0',
|
||||
'platform': 'hdfury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'rx0',
|
||||
'unique_id': '000123456789_RX0',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_input_rx0-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'HDFury VRROOM-02 Input RX0',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_input_rx0',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '4K59.937 593MHz 422 BT2020 12b 2.2',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_input_rx1-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': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_input_rx1',
|
||||
'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': 'Input RX1',
|
||||
'platform': 'hdfury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'rx1',
|
||||
'unique_id': '000123456789_RX1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_input_rx1-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'HDFury VRROOM-02 Input RX1',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_input_rx1',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'no signal',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_output_tx0-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': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_output_tx0',
|
||||
'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': 'Output TX0',
|
||||
'platform': 'hdfury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'tx0',
|
||||
'unique_id': '000123456789_TX0',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_output_tx0-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'HDFury VRROOM-02 Output TX0',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_output_tx0',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '4K59.937 593MHz 422 BT2020 12b 2.2',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_output_tx1-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': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_output_tx1',
|
||||
'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': 'Output TX1',
|
||||
'platform': 'hdfury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'tx1',
|
||||
'unique_id': '000123456789_TX1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.hdfury_vrroom_02_output_tx1-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'HDFury VRROOM-02 Output TX1',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.hdfury_vrroom_02_output_tx1',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '4K59.937 593MHz 422 BT2020 12b 2.2',
|
||||
})
|
||||
# ---
|
||||
25
tests/components/hdfury/test_sensor.py
Normal file
25
tests/components/hdfury/test_sensor.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Tests for the HDFury sensor platform."""
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_sensor_entities(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test HDFury sensor entities."""
|
||||
|
||||
await setup_integration(hass, mock_config_entry, [Platform.SENSOR])
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
@@ -20,6 +20,13 @@ from homeassistant.components.homekit.const import (
|
||||
TYPE_SWITCH,
|
||||
TYPE_VALVE,
|
||||
)
|
||||
from homeassistant.components.homekit.type_sensors import (
|
||||
AirQualitySensor,
|
||||
CarbonDioxideSensor,
|
||||
PM10Sensor,
|
||||
PM25Sensor,
|
||||
TemperatureSensor,
|
||||
)
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntityFeature,
|
||||
@@ -42,6 +49,20 @@ from homeassistant.const import (
|
||||
from homeassistant.core import State
|
||||
|
||||
|
||||
def get_identified_type(entity_id, attrs, config=None):
|
||||
"""Helper to return the accessory type name selected by get_accessory."""
|
||||
|
||||
def passthrough(type: type):
|
||||
return lambda *args, **kwargs: type
|
||||
|
||||
# Patch TYPES so that get_accessory returns a type instead of an instance.
|
||||
with patch.dict(
|
||||
TYPES, {type_name: passthrough(v) for type_name, v in TYPES.items()}
|
||||
):
|
||||
entity_state = State(entity_id, "irrelevant", attrs)
|
||||
return get_accessory(None, None, entity_state, 2, config or {})
|
||||
|
||||
|
||||
def test_not_supported(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Test if none is returned if entity isn't supported."""
|
||||
# not supported entity
|
||||
@@ -425,3 +446,58 @@ def test_type_camera(type_name, entity_id, state, attrs) -> None:
|
||||
entity_state = State(entity_id, state, attrs)
|
||||
get_accessory(None, None, entity_state, 2, {})
|
||||
assert mock_type.called
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("expected_type", "entity_id", "attrs"),
|
||||
[
|
||||
(
|
||||
PM10Sensor,
|
||||
"sensor.air_quality_pm25",
|
||||
{ATTR_DEVICE_CLASS: SensorDeviceClass.PM10},
|
||||
),
|
||||
(
|
||||
PM25Sensor,
|
||||
"sensor.air_quality_pm10",
|
||||
{ATTR_DEVICE_CLASS: SensorDeviceClass.PM25},
|
||||
),
|
||||
(
|
||||
AirQualitySensor,
|
||||
"sensor.co2_sensor",
|
||||
{ATTR_DEVICE_CLASS: SensorDeviceClass.GAS},
|
||||
),
|
||||
(
|
||||
CarbonDioxideSensor,
|
||||
"sensor.air_quality_gas",
|
||||
{ATTR_DEVICE_CLASS: SensorDeviceClass.CO2},
|
||||
),
|
||||
(
|
||||
TemperatureSensor,
|
||||
"sensor.random_sensor",
|
||||
{ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_explicit_device_class_takes_precedence(
|
||||
expected_type, entity_id, attrs
|
||||
) -> None:
|
||||
"""Test that explicit device_class takes precedence over entity_id hints."""
|
||||
identified_type = get_identified_type(entity_id, attrs=attrs)
|
||||
assert identified_type == expected_type
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("expected_type", "entity_id", "attrs"),
|
||||
[
|
||||
(PM10Sensor, "sensor.air_quality_pm10", {}),
|
||||
(PM25Sensor, "sensor.air_quality_pm25", {}),
|
||||
(AirQualitySensor, "sensor.air_quality_gas", {}),
|
||||
(CarbonDioxideSensor, "sensor.airmeter_co2", {}),
|
||||
],
|
||||
)
|
||||
def test_entity_id_fallback_when_no_device_class(
|
||||
expected_type, entity_id, attrs
|
||||
) -> None:
|
||||
"""Test that entity_id is used as fallback when device_class is not set."""
|
||||
identified_type = get_identified_type(entity_id, attrs=attrs)
|
||||
assert identified_type == expected_type
|
||||
|
||||
@@ -16,6 +16,10 @@ from homeassistant.const import (
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers.condition import (
|
||||
ConditionCheckerTypeOptional,
|
||||
async_from_config,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.components import (
|
||||
@@ -71,15 +75,22 @@ async def setup_automation_with_light_condition(
|
||||
)
|
||||
|
||||
|
||||
async def has_single_call_after_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> bool:
|
||||
"""Check if there is a single service call after the trigger event."""
|
||||
hass.bus.async_fire("test_event")
|
||||
await hass.async_block_till_done()
|
||||
num_calls = len(service_calls)
|
||||
service_calls.clear()
|
||||
return num_calls == 1
|
||||
async def create_condition(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
condition: str,
|
||||
target: dict,
|
||||
behavior: str,
|
||||
) -> ConditionCheckerTypeOptional:
|
||||
"""Set up automation with light state condition."""
|
||||
return await async_from_config(
|
||||
hass,
|
||||
{
|
||||
CONF_CONDITION: condition,
|
||||
CONF_TARGET: target,
|
||||
CONF_OPTIONS: {"behavior": behavior},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="enable_experimental_triggers_conditions")
|
||||
@@ -136,7 +147,6 @@ async def test_light_conditions_gated_by_labs_flag(
|
||||
)
|
||||
async def test_light_state_condition_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_lights: list[str],
|
||||
target_switches: list[str],
|
||||
condition_target_config: dict,
|
||||
@@ -154,7 +164,7 @@ async def test_light_state_condition_behavior_any(
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await setup_automation_with_light_condition(
|
||||
condition = await create_condition(
|
||||
hass,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
@@ -166,25 +176,19 @@ async def test_light_state_condition_behavior_any(
|
||||
for eid in target_switches:
|
||||
set_or_remove_state(hass, eid, state["included"])
|
||||
await hass.async_block_till_done()
|
||||
assert not await has_single_call_after_trigger(hass, service_calls)
|
||||
assert condition(hass) is False
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
await has_single_call_after_trigger(hass, service_calls)
|
||||
== state["condition_true"]
|
||||
)
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other lights also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
await has_single_call_after_trigger(hass, service_calls)
|
||||
== state["condition_true"]
|
||||
)
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@@ -230,7 +234,7 @@ async def test_light_state_condition_behavior_all(
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await setup_automation_with_light_condition(
|
||||
condition = await create_condition(
|
||||
hass,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
@@ -243,7 +247,7 @@ async def test_light_state_condition_behavior_all(
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
# The condition passes if all entities are either in a target state or invalid
|
||||
assert await has_single_call_after_trigger(hass, service_calls) == (
|
||||
assert condition(hass) == (
|
||||
(not state["state_valid"])
|
||||
or (state["condition_true"] and entities_in_target == 1)
|
||||
)
|
||||
@@ -253,6 +257,6 @@ async def test_light_state_condition_behavior_all(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The condition passes if all entities are either in a target state or invalid
|
||||
assert await has_single_call_after_trigger(hass, service_calls) == (
|
||||
assert condition(hass) == (
|
||||
(not state["state_valid"]) or state["condition_true"]
|
||||
)
|
||||
|
||||
@@ -439,54 +439,6 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[eve_thermo_v4][binary_sensor.eve_thermo_20ebp1701_outdoor_temperature_remote_sensing-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': 'binary_sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'binary_sensor.eve_thermo_20ebp1701_outdoor_temperature_remote_sensing',
|
||||
'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': 'Outdoor temperature remote sensing',
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'thermostat_remote_sensing_outdoor_temperature',
|
||||
'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-ThermostatRemoteSensing_OutdoorTemperature-513-26',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[eve_thermo_v4][binary_sensor.eve_thermo_20ebp1701_outdoor_temperature_remote_sensing-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Eve Thermo 20EBP1701 Outdoor temperature remote sensing',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.eve_thermo_20ebp1701_outdoor_temperature_remote_sensing',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[eve_thermo_v5][binary_sensor.eve_thermo_20ecd1701_local_temperature_remote_sensing-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -535,54 +487,6 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[eve_thermo_v5][binary_sensor.eve_thermo_20ecd1701_outdoor_temperature_remote_sensing-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': 'binary_sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'binary_sensor.eve_thermo_20ecd1701_outdoor_temperature_remote_sensing',
|
||||
'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': 'Outdoor temperature remote sensing',
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'thermostat_remote_sensing_outdoor_temperature',
|
||||
'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-ThermostatRemoteSensing_OutdoorTemperature-513-26',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[eve_thermo_v5][binary_sensor.eve_thermo_20ecd1701_outdoor_temperature_remote_sensing-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Eve Thermo 20ECD1701 Outdoor temperature remote sensing',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.eve_thermo_20ecd1701_outdoor_temperature_remote_sensing',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[heiman_motion_sensor_m1][binary_sensor.smart_motion_sensor_occupancy-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'select',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'select.aqara_smart_lock_u200_operating_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -687,7 +687,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'select',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'select.mock_door_lock_operating_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -866,7 +866,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'select',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'select.mock_door_lock_with_unbolt_operating_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -2571,7 +2571,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'select',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'select.mock_lock_operating_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -3826,7 +3826,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'select',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'select.secuyou_smart_lock_operating_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
|
||||
@@ -149,7 +149,15 @@ async def test_notify_works(
|
||||
"""Test notify works."""
|
||||
assert hass.services.has_service("notify", "mobile_app_test") is True
|
||||
await hass.services.async_call(
|
||||
"notify", "mobile_app_test", {"message": "Hello world"}, blocking=True
|
||||
"notify",
|
||||
"mobile_app_test",
|
||||
{
|
||||
"message": "Hello world",
|
||||
"title": "Demo",
|
||||
"target": ["mock-webhook_id"],
|
||||
"data": {"field1": "value1"},
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert len(aioclient_mock.mock_calls) == 1
|
||||
@@ -159,6 +167,8 @@ async def test_notify_works(
|
||||
|
||||
assert call_json["push_token"] == "PUSH_TOKEN"
|
||||
assert call_json["message"] == "Hello world"
|
||||
assert call_json["title"] == "Demo"
|
||||
assert call_json["data"] == {"field1": "value1"}
|
||||
assert call_json["registration_info"]["app_id"] == "io.homeassistant.mobile_app"
|
||||
assert call_json["registration_info"]["app_version"] == "1.0"
|
||||
assert call_json["registration_info"]["webhook_id"] == "mock-webhook_id"
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
|
||||
from homeassistant.components.namecheapdns.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
|
||||
from homeassistant.const import CONF_PASSWORD
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
@@ -14,6 +15,8 @@ from homeassistant.setup import async_setup_component
|
||||
|
||||
from .conftest import TEST_USER_INPUT
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_namecheap")
|
||||
async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
|
||||
@@ -140,3 +143,68 @@ async def test_init_import_flow(
|
||||
)
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_namecheap")
|
||||
async def test_reconfigure(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reconfigure flow."""
|
||||
config_entry.add_to_hass(hass)
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_PASSWORD: "new-password"}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert config_entry.data[CONF_PASSWORD] == "new-password"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "text_error"),
|
||||
[
|
||||
(ValueError, "unknown"),
|
||||
(False, "update_failed"),
|
||||
(ClientError, "cannot_connect"),
|
||||
],
|
||||
)
|
||||
async def test_reconfigure_errors(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
mock_namecheap: AsyncMock,
|
||||
side_effect: Exception | bool,
|
||||
text_error: str,
|
||||
) -> None:
|
||||
"""Test we handle errors."""
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
mock_namecheap.side_effect = [side_effect]
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_PASSWORD: "new-password"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": text_error}
|
||||
|
||||
mock_namecheap.side_effect = None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_PASSWORD: "new-password"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
assert config_entry.data[CONF_PASSWORD] == "new-password"
|
||||
|
||||
@@ -35,7 +35,10 @@ def mock_expires_at() -> int:
|
||||
|
||||
|
||||
def create_config_entry(
|
||||
expires_at: int, scopes: list[Scope], implementation: str = DOMAIN
|
||||
expires_at: int,
|
||||
scopes: list[Scope],
|
||||
implementation: str = DOMAIN,
|
||||
region: str = "NA",
|
||||
) -> MockConfigEntry:
|
||||
"""Create Tesla Fleet entry in Home Assistant."""
|
||||
access_token = jwt.encode(
|
||||
@@ -43,7 +46,7 @@ def create_config_entry(
|
||||
"sub": UID,
|
||||
"aud": [],
|
||||
"scp": scopes,
|
||||
"ou_code": "NA",
|
||||
"ou_code": region,
|
||||
},
|
||||
key="",
|
||||
algorithm="none",
|
||||
|
||||
@@ -230,6 +230,52 @@ async def test_vehicle_refresh_ratelimited(
|
||||
assert state.state == "unknown"
|
||||
|
||||
|
||||
async def test_vehicle_refresh_ratelimited_no_after(
|
||||
hass: HomeAssistant,
|
||||
normal_config_entry: MockConfigEntry,
|
||||
mock_vehicle_data: AsyncMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test coordinator refresh handles 429 without after."""
|
||||
|
||||
await setup_platform(hass, normal_config_entry)
|
||||
# mock_vehicle_data called once during setup
|
||||
assert mock_vehicle_data.call_count == 1
|
||||
|
||||
mock_vehicle_data.side_effect = RateLimited({})
|
||||
freezer.tick(VEHICLE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Called again during refresh, failed with RateLimited
|
||||
assert mock_vehicle_data.call_count == 2
|
||||
|
||||
freezer.tick(VEHICLE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Called again because skip refresh doesn't change interval
|
||||
assert mock_vehicle_data.call_count == 3
|
||||
|
||||
|
||||
async def test_init_invalid_region(
|
||||
hass: HomeAssistant,
|
||||
expires_at: int,
|
||||
) -> None:
|
||||
"""Test init with an invalid region in the token."""
|
||||
|
||||
# ou_code 'other' should be caught by the region validation and set to None
|
||||
config_entry = create_config_entry(
|
||||
expires_at, [Scope.VEHICLE_DEVICE_DATA], region="other"
|
||||
)
|
||||
|
||||
with patch("homeassistant.components.tesla_fleet.TeslaFleetApi") as mock_api:
|
||||
await setup_platform(hass, config_entry)
|
||||
# Check if TeslaFleetApi was called with region=None
|
||||
mock_api.assert_called()
|
||||
assert mock_api.call_args.kwargs.get("region") is None
|
||||
|
||||
|
||||
async def test_vehicle_sleep(
|
||||
hass: HomeAssistant,
|
||||
normal_config_entry: MockConfigEntry,
|
||||
|
||||
@@ -85,6 +85,21 @@ async def test_number_services(
|
||||
assert state.state == "60"
|
||||
call.assert_called_once()
|
||||
|
||||
# Test float conversion
|
||||
with patch(
|
||||
"tesla_fleet_api.tesla.VehicleFleet.set_charge_limit",
|
||||
return_value=COMMAND_OK,
|
||||
) as call:
|
||||
await hass.services.async_call(
|
||||
NUMBER_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
{ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 60.5},
|
||||
blocking=True,
|
||||
)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == "60"
|
||||
call.assert_called_once_with(60)
|
||||
|
||||
entity_id = "number.energy_site_backup_reserve"
|
||||
with patch(
|
||||
"tesla_fleet_api.tesla.EnergySite.backup",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test the Teslemetry init."""
|
||||
|
||||
from copy import deepcopy
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -8,17 +9,24 @@ from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from tesla_fleet_api.exceptions import (
|
||||
InvalidResponse,
|
||||
InvalidToken,
|
||||
RateLimited,
|
||||
SubscriptionRequired,
|
||||
TeslaFleetError,
|
||||
)
|
||||
|
||||
from homeassistant.components.teslemetry.const import CLIENT_ID, DOMAIN
|
||||
from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL
|
||||
|
||||
# Coordinator constants
|
||||
from homeassistant.components.teslemetry.coordinator import (
|
||||
ENERGY_HISTORY_INTERVAL,
|
||||
ENERGY_LIVE_INTERVAL,
|
||||
VEHICLE_INTERVAL,
|
||||
)
|
||||
from homeassistant.components.teslemetry.models import TeslemetryData
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
@@ -29,9 +37,16 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from . import setup_platform
|
||||
from .const import CONFIG_V1, PRODUCTS_MODERN, UNIQUE_ID, VEHICLE_DATA_ALT
|
||||
from .const import (
|
||||
CONFIG_V1,
|
||||
ENERGY_HISTORY,
|
||||
LIVE_STATUS,
|
||||
PRODUCTS_MODERN,
|
||||
UNIQUE_ID,
|
||||
VEHICLE_DATA_ALT,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
ERRORS = [
|
||||
(InvalidToken, ConfigEntryState.SETUP_ERROR),
|
||||
@@ -319,9 +334,7 @@ async def test_migrate_from_version_1_success(hass: HomeAssistant) -> None:
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_migrate.assert_called_once_with(
|
||||
CLIENT_ID, CONFIG_V1[CONF_ACCESS_TOKEN], hass.config.location_name
|
||||
)
|
||||
mock_migrate.assert_called_once_with(CLIENT_ID, hass.config.location_name)
|
||||
|
||||
assert mock_entry is not None
|
||||
assert mock_entry.version == 2
|
||||
@@ -356,9 +369,7 @@ async def test_migrate_from_version_1_token_endpoint_error(hass: HomeAssistant)
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_migrate.assert_called_once_with(
|
||||
CLIENT_ID, CONFIG_V1[CONF_ACCESS_TOKEN], hass.config.location_name
|
||||
)
|
||||
mock_migrate.assert_called_once_with(CLIENT_ID, hass.config.location_name)
|
||||
|
||||
entry = hass.config_entries.async_get_entry(mock_entry.entry_id)
|
||||
assert entry is not None
|
||||
@@ -430,3 +441,175 @@ async def test_migrate_from_future_version_fails(hass: HomeAssistant) -> None:
|
||||
assert entry is not None
|
||||
assert entry.state is ConfigEntryState.MIGRATION_ERROR
|
||||
assert entry.version == 3 # Version should remain unchanged
|
||||
|
||||
|
||||
RETRY_EXCEPTIONS = [
|
||||
(RateLimited(data={"after": 5}), 5.0),
|
||||
(InvalidResponse(), 10.0),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("exception", "expected_retry_after"), RETRY_EXCEPTIONS)
|
||||
async def test_site_info_retry_exceptions(
|
||||
hass: HomeAssistant,
|
||||
mock_site_info: AsyncMock,
|
||||
exception: TeslaFleetError,
|
||||
expected_retry_after: float,
|
||||
) -> None:
|
||||
"""Test UpdateFailed with retry_after for site info coordinator."""
|
||||
mock_site_info.side_effect = exception
|
||||
entry = await setup_platform(hass)
|
||||
# Retry exceptions during first refresh cause setup retry
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
# API should only be called once (no manual retries)
|
||||
assert mock_site_info.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("exception", "expected_retry_after"), RETRY_EXCEPTIONS)
|
||||
async def test_vehicle_data_retry_exceptions(
|
||||
hass: HomeAssistant,
|
||||
mock_vehicle_data: AsyncMock,
|
||||
mock_legacy: AsyncMock,
|
||||
exception: TeslaFleetError,
|
||||
expected_retry_after: float,
|
||||
) -> None:
|
||||
"""Test UpdateFailed with retry_after for vehicle data coordinator."""
|
||||
mock_vehicle_data.side_effect = exception
|
||||
entry = await setup_platform(hass)
|
||||
# Retry exceptions during first refresh cause setup retry
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
# API should only be called once (no manual retries)
|
||||
assert mock_vehicle_data.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("exception", "expected_retry_after"), RETRY_EXCEPTIONS)
|
||||
async def test_live_status_coordinator_retry_exceptions(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
mock_live_status: AsyncMock,
|
||||
exception: TeslaFleetError,
|
||||
expected_retry_after: float,
|
||||
) -> None:
|
||||
"""Test live status coordinator raises UpdateFailed with retry_after."""
|
||||
call_count = 0
|
||||
|
||||
def live_status_side_effect():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return deepcopy(LIVE_STATUS) # Initial call succeeds
|
||||
if call_count == 2:
|
||||
raise exception # Second call raises exception
|
||||
return deepcopy(LIVE_STATUS) # Subsequent calls succeed
|
||||
|
||||
mock_live_status.side_effect = live_status_side_effect
|
||||
|
||||
entry = await setup_platform(hass)
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert call_count == 1
|
||||
|
||||
# Trigger coordinator refresh - this will raise the exception
|
||||
freezer.tick(ENERGY_LIVE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# API was called exactly once for this refresh (no manual retry loop)
|
||||
assert call_count == 2
|
||||
# Entry stays loaded - UpdateFailed with retry_after doesn't break the entry
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("exception", "expected_retry_after"), RETRY_EXCEPTIONS)
|
||||
async def test_energy_history_coordinator_retry_exceptions(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
mock_energy_history: AsyncMock,
|
||||
exception: TeslaFleetError,
|
||||
expected_retry_after: float,
|
||||
) -> None:
|
||||
"""Test energy history coordinator raises UpdateFailed with retry_after."""
|
||||
call_count = 0
|
||||
|
||||
def energy_history_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise exception # First call raises exception
|
||||
return ENERGY_HISTORY # Subsequent calls succeed
|
||||
|
||||
mock_energy_history.side_effect = energy_history_side_effect
|
||||
|
||||
entry = await setup_platform(hass)
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
# Energy history doesn't have first_refresh during setup
|
||||
assert call_count == 0
|
||||
|
||||
# Trigger first coordinator refresh - this will raise the exception
|
||||
freezer.tick(ENERGY_HISTORY_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# API was called exactly once (no manual retry loop)
|
||||
assert call_count == 1
|
||||
# Entry stays loaded - UpdateFailed with retry_after doesn't break the entry
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
async def test_live_status_auth_error(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test live status coordinator handles auth errors."""
|
||||
call_count = 0
|
||||
|
||||
def live_status_side_effect():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return deepcopy(LIVE_STATUS)
|
||||
raise InvalidToken
|
||||
|
||||
with patch(
|
||||
"tesla_fleet_api.tesla.energysite.EnergySite.live_status",
|
||||
side_effect=live_status_side_effect,
|
||||
):
|
||||
entry = await setup_platform(hass)
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
|
||||
# Trigger a coordinator refresh by advancing time
|
||||
freezer.tick(ENERGY_LIVE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Auth error triggers reauth flow
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
async def test_live_status_generic_error(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test live status coordinator handles generic TeslaFleetError."""
|
||||
call_count = 0
|
||||
|
||||
def live_status_side_effect():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return deepcopy(LIVE_STATUS)
|
||||
raise TeslaFleetError
|
||||
|
||||
with patch(
|
||||
"tesla_fleet_api.tesla.energysite.EnergySite.live_status",
|
||||
side_effect=live_status_side_effect,
|
||||
):
|
||||
entry = await setup_platform(hass)
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
|
||||
# Trigger a coordinator refresh by advancing time
|
||||
freezer.tick(ENERGY_LIVE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Entry stays loaded but coordinator will have failed
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
|
||||
@@ -63,6 +63,40 @@ async def test_services(
|
||||
"sensor.energy_site_battery_power"
|
||||
).device_id
|
||||
|
||||
# Test set_scheduled_charging with enable=False (time should default to 0)
|
||||
with patch(
|
||||
"tesla_fleet_api.teslemetry.Vehicle.set_scheduled_charging",
|
||||
return_value=COMMAND_OK,
|
||||
) as set_scheduled_charging_off:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_SCHEDULED_CHARGING,
|
||||
{
|
||||
CONF_DEVICE_ID: vehicle_device,
|
||||
ATTR_ENABLE: False,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
set_scheduled_charging_off.assert_called_once_with(enable=False, time=0)
|
||||
|
||||
# Test set_scheduled_departure with enable=False (times should default to 0)
|
||||
with patch(
|
||||
"tesla_fleet_api.teslemetry.Vehicle.set_scheduled_departure",
|
||||
return_value=COMMAND_OK,
|
||||
) as set_scheduled_departure_off:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_SCHEDULED_DEPARTURE,
|
||||
{
|
||||
CONF_DEVICE_ID: vehicle_device,
|
||||
ATTR_ENABLE: False,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
set_scheduled_departure_off.assert_called_once_with(
|
||||
False, False, False, 0, False, False, 0
|
||||
)
|
||||
|
||||
with patch(
|
||||
"tesla_fleet_api.teslemetry.Vehicle.navigation_gps_request",
|
||||
return_value=COMMAND_OK,
|
||||
@@ -308,6 +342,8 @@ async def test_service_validation_errors(
|
||||
"""Tests that the custom services handle bad data."""
|
||||
|
||||
await setup_platform(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
vehicle_device = entity_registry.async_get("sensor.test_charging").device_id
|
||||
|
||||
# Bad device ID
|
||||
with pytest.raises(ServiceValidationError):
|
||||
@@ -320,3 +356,39 @@ async def test_service_validation_errors(
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Test set_scheduled_charging validation error (enable=True but no time)
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_SCHEDULED_CHARGING,
|
||||
{
|
||||
CONF_DEVICE_ID: vehicle_device,
|
||||
ATTR_ENABLE: True,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Test set_scheduled_departure validation error (preconditioning_enabled=True but no departure_time)
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_SCHEDULED_DEPARTURE,
|
||||
{
|
||||
CONF_DEVICE_ID: vehicle_device,
|
||||
ATTR_PRECONDITIONING_ENABLED: True,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Test set_scheduled_departure validation error (off_peak_charging_enabled=True but no end_off_peak_time)
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_SCHEDULED_DEPARTURE,
|
||||
{
|
||||
CONF_DEVICE_ID: vehicle_device,
|
||||
ATTR_OFF_PEAK_CHARGING_ENABLED: True,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
@@ -18,14 +18,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.typing import RecorderInstanceContextManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_recorder_before_hass(
|
||||
async_test_recorder: RecorderInstanceContextManager,
|
||||
) -> None:
|
||||
"""Set up recorder before hass fixture runs."""
|
||||
|
||||
|
||||
def create_tibber_device(
|
||||
@@ -140,15 +132,21 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tibber_mock() -> AsyncGenerator[MagicMock]:
|
||||
def _tibber_patches() -> AsyncGenerator[tuple[MagicMock, MagicMock]]:
|
||||
"""Patch the Tibber libraries used by the integration."""
|
||||
unique_user_id = "unique_user_id"
|
||||
title = "title"
|
||||
|
||||
with patch(
|
||||
"tibber.Tibber",
|
||||
autospec=True,
|
||||
) as mock_tibber:
|
||||
with (
|
||||
patch(
|
||||
"tibber.Tibber",
|
||||
autospec=True,
|
||||
) as mock_tibber,
|
||||
patch(
|
||||
"tibber.data_api.TibberDataAPI",
|
||||
autospec=True,
|
||||
) as mock_data_api_client,
|
||||
):
|
||||
tibber_mock = mock_tibber.return_value
|
||||
tibber_mock.update_info = AsyncMock(return_value=True)
|
||||
tibber_mock.user_id = unique_user_id
|
||||
@@ -156,21 +154,24 @@ def tibber_mock() -> AsyncGenerator[MagicMock]:
|
||||
tibber_mock.send_notification = AsyncMock()
|
||||
tibber_mock.rt_disconnect = AsyncMock()
|
||||
tibber_mock.get_homes = MagicMock(return_value=[])
|
||||
tibber_mock.set_access_token = MagicMock()
|
||||
|
||||
data_api_mock = MagicMock()
|
||||
data_api_mock.get_all_devices = AsyncMock(return_value={})
|
||||
data_api_mock.update_devices = AsyncMock(return_value={})
|
||||
data_api_mock.get_userinfo = AsyncMock()
|
||||
tibber_mock.data_api = data_api_mock
|
||||
data_api_client_mock = mock_data_api_client.return_value
|
||||
data_api_client_mock.get_all_devices = AsyncMock(return_value={})
|
||||
data_api_client_mock.update_devices = AsyncMock(return_value={})
|
||||
|
||||
yield tibber_mock
|
||||
yield tibber_mock, data_api_client_mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_api_client_mock(tibber_mock: MagicMock) -> MagicMock:
|
||||
def tibber_mock(_tibber_patches: tuple[MagicMock, MagicMock]) -> MagicMock:
|
||||
"""Return the patched Tibber connection mock."""
|
||||
return _tibber_patches[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_api_client_mock(_tibber_patches: tuple[MagicMock, MagicMock]) -> MagicMock:
|
||||
"""Return the patched Tibber Data API client mock."""
|
||||
return tibber_mock.data_api
|
||||
return _tibber_patches[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.components.tibber.application_credentials import TOKEN_URL
|
||||
from homeassistant.components.tibber.config_flow import (
|
||||
DATA_API_DEFAULT_SCOPES,
|
||||
ERR_CLIENT,
|
||||
ERR_TIMEOUT,
|
||||
ERR_TOKEN,
|
||||
)
|
||||
from homeassistant.components.tibber.const import AUTH_IMPLEMENTATION, DOMAIN
|
||||
@@ -54,164 +55,226 @@ def _mock_tibber(
|
||||
return tibber_mock
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("setup_credentials", "current_request_with_host")
|
||||
async def test_show_config_form(recorder_mock: Recorder, hass: HomeAssistant) -> None:
|
||||
"""Test show configuration form."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "expected_error"),
|
||||
[
|
||||
(builtins.TimeoutError(), ERR_TIMEOUT),
|
||||
(ClientError(), ERR_CLIENT),
|
||||
(InvalidLoginError(401), ERR_TOKEN),
|
||||
(RetryableHttpExceptionError(503), ERR_CLIENT),
|
||||
(FatalHttpExceptionError(404), ERR_CLIENT),
|
||||
],
|
||||
)
|
||||
async def test_oauth_create_entry_abort_exceptions(
|
||||
async def test_graphql_step_exceptions(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
tibber_mock: MagicMock,
|
||||
exception: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Validate fatal errors during OAuth finalization abort the flow."""
|
||||
"""Validate GraphQL errors are surfaced."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
handler = hass.config_entries.flow._progress[result["flow_id"]]
|
||||
|
||||
_mock_tibber(tibber_mock, update_side_effect=exception)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_ACCESS_TOKEN: "invalid"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"][CONF_ACCESS_TOKEN] == expected_error
|
||||
|
||||
|
||||
async def test_flow_entry_already_exists(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
config_entry,
|
||||
tibber_mock: MagicMock,
|
||||
) -> None:
|
||||
"""Test user input for config_entry that already exists."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
_mock_tibber(tibber_mock, user_id="tibber")
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_ACCESS_TOKEN: "valid"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_reauth_flow_steps(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the reauth flow goes through reauth_confirm to user step."""
|
||||
reauth_flow = await config_entry.start_reauth_flow(hass)
|
||||
|
||||
assert reauth_flow["type"] is FlowResultType.FORM
|
||||
assert reauth_flow["step_id"] == "reauth_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(reauth_flow["flow_id"])
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
reauth_flow["flow_id"],
|
||||
user_input={},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
|
||||
async def test_oauth_create_entry_missing_configuration(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Abort OAuth finalize if GraphQL step did not run."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
handler = hass.config_entries.flow._progress[result["flow_id"]]
|
||||
|
||||
flow_result = await handler.async_oauth_create_entry(
|
||||
{CONF_TOKEN: {CONF_ACCESS_TOKEN: "rest-token"}}
|
||||
)
|
||||
|
||||
assert flow_result["type"] is FlowResultType.ABORT
|
||||
assert flow_result["reason"] == expected_error
|
||||
assert flow_result["reason"] == "missing_configuration"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("setup_credentials", "current_request_with_host")
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[
|
||||
builtins.TimeoutError(),
|
||||
ClientError(),
|
||||
RetryableHttpExceptionError(503),
|
||||
],
|
||||
)
|
||||
async def test_oauth_create_entry_connection_error_retry(
|
||||
async def test_oauth_create_entry_cannot_connect_userinfo(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
tibber_mock: MagicMock,
|
||||
exception: Exception,
|
||||
data_api_client_mock: MagicMock,
|
||||
) -> None:
|
||||
"""Validate transient connection errors show retry form."""
|
||||
"""Abort OAuth finalize when Data API userinfo cannot be retrieved."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
handler = hass.config_entries.flow._progress[result["flow_id"]]
|
||||
handler._access_token = "graphql-token"
|
||||
|
||||
data_api_client_mock.get_userinfo = AsyncMock(side_effect=ClientError())
|
||||
flow_result = await handler.async_oauth_create_entry(
|
||||
{CONF_TOKEN: {CONF_ACCESS_TOKEN: "rest-token"}}
|
||||
)
|
||||
|
||||
_mock_tibber(tibber_mock, update_side_effect=exception)
|
||||
assert result["type"] is FlowResultType.EXTERNAL_STEP
|
||||
authorize_url = result["url"]
|
||||
state = parse_qs(urlparse(authorize_url).query)["state"][0]
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
|
||||
aioclient_mock.post(
|
||||
TOKEN_URL,
|
||||
json={
|
||||
"access_token": "mock-access-token",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "connection_error"
|
||||
|
||||
tibber_mock.update_info.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Mock Name"
|
||||
assert flow_result["type"] is FlowResultType.ABORT
|
||||
assert flow_result["reason"] == "cannot_connect"
|
||||
|
||||
|
||||
async def test_data_api_requires_credentials(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
tibber_mock: MagicMock,
|
||||
) -> None:
|
||||
"""Abort when OAuth credentials are missing."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
_mock_tibber(tibber_mock)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_ACCESS_TOKEN: "valid"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "missing_credentials"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("setup_credentials", "current_request_with_host")
|
||||
async def test_data_api_extra_authorize_scope(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
tibber_mock: MagicMock,
|
||||
) -> None:
|
||||
"""Ensure the OAuth implementation requests Tibber scopes."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
with patch("homeassistant.components.recorder.async_setup", return_value=True):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
handler = hass.config_entries.flow._progress[result["flow_id"]]
|
||||
assert handler.extra_authorize_data["scope"] == " ".join(DATA_API_DEFAULT_SCOPES)
|
||||
_mock_tibber(tibber_mock)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_ACCESS_TOKEN: "valid"}
|
||||
)
|
||||
|
||||
handler = hass.config_entries.flow._progress[result["flow_id"]]
|
||||
assert handler.extra_authorize_data["scope"] == " ".join(
|
||||
DATA_API_DEFAULT_SCOPES
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("setup_credentials", "current_request_with_host")
|
||||
async def test_full_flow_success(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
tibber_mock: MagicMock,
|
||||
data_api_client_mock: MagicMock,
|
||||
) -> None:
|
||||
"""Test configuring Tibber via OAuth."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
"""Test configuring Tibber via GraphQL + OAuth."""
|
||||
with patch("homeassistant.components.recorder.async_setup", return_value=True):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
_mock_tibber(tibber_mock)
|
||||
assert result["type"] is FlowResultType.EXTERNAL_STEP
|
||||
authorize_url = result["url"]
|
||||
state = parse_qs(urlparse(authorize_url).query)["state"][0]
|
||||
_mock_tibber(tibber_mock)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_ACCESS_TOKEN: "graphql-token"}
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
assert result["type"] is FlowResultType.EXTERNAL_STEP
|
||||
authorize_url = result["url"]
|
||||
state = parse_qs(urlparse(authorize_url).query)["state"][0]
|
||||
|
||||
aioclient_mock.post(
|
||||
TOKEN_URL,
|
||||
json={
|
||||
"access_token": "mock-access-token",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
},
|
||||
)
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
aioclient_mock.post(
|
||||
TOKEN_URL,
|
||||
json={
|
||||
"access_token": "mock-access-token",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
data = result["data"]
|
||||
assert data[CONF_TOKEN]["access_token"] == "mock-access-token"
|
||||
assert data[AUTH_IMPLEMENTATION] == DOMAIN
|
||||
assert result["title"] == "Mock Name"
|
||||
data_api_client_mock.get_userinfo = AsyncMock(
|
||||
return_value={"name": "Mock Name"}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
data = result["data"]
|
||||
assert data[CONF_TOKEN]["access_token"] == "mock-access-token"
|
||||
assert data[CONF_ACCESS_TOKEN] == "graphql-token"
|
||||
assert data[AUTH_IMPLEMENTATION] == DOMAIN
|
||||
assert result["title"] == "Mock Name"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("setup_credentials", "current_request_with_host")
|
||||
async def test_data_api_abort_when_already_configured(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
tibber_mock: MagicMock,
|
||||
) -> None:
|
||||
"""Ensure only a single Data API entry can be configured."""
|
||||
@@ -220,6 +283,7 @@ async def test_data_api_abort_when_already_configured(
|
||||
data={
|
||||
AUTH_IMPLEMENTATION: DOMAIN,
|
||||
CONF_TOKEN: {"access_token": "existing"},
|
||||
CONF_ACCESS_TOKEN: "stored-graphql",
|
||||
},
|
||||
unique_id="unique_user_id",
|
||||
title="Existing",
|
||||
@@ -231,133 +295,9 @@ async def test_data_api_abort_when_already_configured(
|
||||
)
|
||||
|
||||
_mock_tibber(tibber_mock)
|
||||
assert result["type"] is FlowResultType.EXTERNAL_STEP
|
||||
authorize_url = result["url"]
|
||||
state = parse_qs(urlparse(authorize_url).query)["state"][0]
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
|
||||
aioclient_mock.post(
|
||||
TOKEN_URL,
|
||||
json={
|
||||
"access_token": "mock-access-token",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
},
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_ACCESS_TOKEN: "new-token"}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("setup_credentials", "current_request_with_host")
|
||||
async def test_reauth_flow_success(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
tibber_mock: MagicMock,
|
||||
) -> None:
|
||||
"""Test successful reauthentication flow."""
|
||||
existing_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
AUTH_IMPLEMENTATION: DOMAIN,
|
||||
CONF_TOKEN: {"access_token": "old-token"},
|
||||
},
|
||||
unique_id="unique_user_id",
|
||||
title="Existing",
|
||||
)
|
||||
existing_entry.add_to_hass(hass)
|
||||
|
||||
result = await existing_entry.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={},
|
||||
)
|
||||
|
||||
_mock_tibber(tibber_mock)
|
||||
assert result["type"] is FlowResultType.EXTERNAL_STEP
|
||||
authorize_url = result["url"]
|
||||
state = parse_qs(urlparse(authorize_url).query)["state"][0]
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
|
||||
aioclient_mock.post(
|
||||
TOKEN_URL,
|
||||
json={
|
||||
"access_token": "new-access-token",
|
||||
"refresh_token": "new-refresh-token",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert existing_entry.data[CONF_TOKEN]["access_token"] == "new-access-token"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("setup_credentials", "current_request_with_host")
|
||||
async def test_reauth_flow_wrong_account(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
tibber_mock: MagicMock,
|
||||
) -> None:
|
||||
"""Test reauthentication with wrong account aborts."""
|
||||
existing_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
AUTH_IMPLEMENTATION: DOMAIN,
|
||||
CONF_TOKEN: {"access_token": "old-token"},
|
||||
},
|
||||
unique_id="original_user_id",
|
||||
title="Existing",
|
||||
)
|
||||
existing_entry.add_to_hass(hass)
|
||||
|
||||
result = await existing_entry.start_reauth_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={},
|
||||
)
|
||||
|
||||
# Mock a different user_id than the existing entry
|
||||
_mock_tibber(tibber_mock, user_id="different_user_id")
|
||||
assert result["type"] is FlowResultType.EXTERNAL_STEP
|
||||
authorize_url = result["url"]
|
||||
state = parse_qs(urlparse(authorize_url).query)["state"][0]
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == HTTPStatus.OK
|
||||
|
||||
aioclient_mock.post(
|
||||
TOKEN_URL,
|
||||
json={
|
||||
"access_token": "new-access-token",
|
||||
"refresh_token": "new-refresh-token",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "wrong_account"
|
||||
|
||||
@@ -36,18 +36,19 @@ async def test_data_api_runtime_creates_client(hass: HomeAssistant) -> None:
|
||||
|
||||
runtime = TibberRuntimeData(
|
||||
session=session,
|
||||
tibber_connection=MagicMock(),
|
||||
)
|
||||
|
||||
with patch("homeassistant.components.tibber.tibber.Tibber") as mock_client_cls:
|
||||
with patch(
|
||||
"homeassistant.components.tibber.tibber_data_api.TibberDataAPI"
|
||||
) as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.set_access_token = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
client = await runtime.async_get_client(hass)
|
||||
|
||||
mock_client_cls.assert_called_once_with(
|
||||
access_token="access-token", websession=ANY, time_zone=ANY, ssl=ANY
|
||||
)
|
||||
mock_client_cls.assert_called_once_with("access-token", websession=ANY)
|
||||
session.async_ensure_token_valid.assert_awaited_once()
|
||||
mock_client.set_access_token.assert_called_once_with("access-token")
|
||||
assert client is mock_client
|
||||
@@ -72,6 +73,7 @@ async def test_data_api_runtime_missing_token_raises(hass: HomeAssistant) -> Non
|
||||
|
||||
runtime = TibberRuntimeData(
|
||||
session=session,
|
||||
tibber_connection=MagicMock(),
|
||||
)
|
||||
|
||||
with pytest.raises(ConfigEntryAuthFailed):
|
||||
|
||||
Reference in New Issue
Block a user