Compare commits

...

26 Commits

Author SHA1 Message Date
dependabot[bot]
20102cd83f Bump j178/prek-action from 1.0.11 to 1.0.12 (#160902)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 10:28:11 +01:00
Erik Montnemery
6d6324dae5 Fix some reversed asserts in sensor group tests (#160905) 2026-01-14 09:43:26 +01:00
Erik Montnemery
2ee5410a6c Remove set of _attr_extra_state_attributes in sensor group (#160846)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-01-14 09:21:54 +01:00
Erik Montnemery
56f02a41ca Adjust sensor group behavior (#152167) 2026-01-14 08:23:34 +01:00
Erwin Douna
d43102de1b Bump pyportainer 1.0.23 (#160878) 2026-01-14 07:09:35 +01:00
Ludovic BOUÉ
2bcd02b296 Add MatterOutdoorTemperature attribute to Matter binary sensor discovery schema only if OutdoorTemperature exists (#160879) 2026-01-14 06:58:55 +01:00
Brett Adams
ad11c72488 Add retry logic to Teslemetry coordinators (#160756)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 01:36:43 +01:00
Manu
ddfa6f83c3 Refactor Namecheap DNS update logic to use a coordinator (#160863) 2026-01-14 01:34:27 +01:00
epenet
85baf7a41d Improve type hints in mobile_app notify (#160853)
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
2026-01-14 01:26:10 +01:00
epenet
bf4d5a0bab Improve type hints in telegram notify (#160855) 2026-01-14 01:26:00 +01:00
Erwin Douna
16527ba707 Melcloud small config flow refactor (#160892) 2026-01-14 01:15:36 +01:00
Brett Adams
0612ea4ee8 Bump tesla-fleet-api to 1.4.2 (#159616)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-14 01:14:58 +01:00
Ville Skyttä
9e842152f7 Upgrade prettier-plugin-sort to 4.2.0 (#160894) 2026-01-14 01:13:16 +01:00
Erwin Douna
63e79c3639 Firefly III add asyncio.gather pattern (#160886) 2026-01-14 01:12:44 +01:00
Erwin Douna
d0e4a7fa75 Melcloud Pythonic refactor init (#160891) 2026-01-14 00:38:41 +01:00
Glenn de Haan
815976b9a4 Add HDFury sensor platform (#160628) 2026-01-14 00:35:48 +01:00
scheric
86a5cc5edb Add keep_alive to generic_thermostat config flow (#156641)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-01-13 23:20:40 +00:00
Björn Ebbinghaus
3ebc08c5ec Prefer explicit DeviceClass over hint in entity_id in homekit (#152507)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-01-13 23:00:58 +00:00
Paul Bottein
1bcbebb00c Use config entity category for Matter door lock operating mode (#160507) 2026-01-13 23:46:54 +01:00
Jan Bouwhuis
2895225552 Improve test coverage on mobile app legacy notify service action (#160869) 2026-01-13 22:39:01 +01:00
Erwin Douna
f4f772ea31 Bump pyfirefly 0.1.11 (#160877) 2026-01-13 22:37:32 +01:00
Manu
66f60e6757 Add reconfigure flow to Namecheap integration (#160870) 2026-01-13 19:47:50 +00:00
Lukas
72d299f088 Mark pooldose as strictly typed (#160779)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-01-13 19:40:52 +00:00
Thomas55555
9c66561381 Make pollutants dynamic in Google Air Quality (#160747) 2026-01-13 19:28:41 +00:00
Erik Montnemery
e762f839fa Improve sensor group tests (#160854) 2026-01-13 20:16:06 +01:00
Joost Lekkerkerker
0c9d97c89f Unmark integrations with a config flow as legacy (#160861) 2026-01-13 19:59:39 +01:00
68 changed files with 2056 additions and 415 deletions

View File

@@ -260,7 +260,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@91fd7d7cf70ae1dee9f4f44e7dfa5d1073fe6623 # v1.0.11
uses: j178/prek-action@9d6a3097e0c1865ecce00cfb89fe80f2ee91b547 # v1.0.12
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config
RUFF_OUTPUT_FORMAT: github

View File

@@ -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:

View File

@@ -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.*

View File

@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "local_push",
"loggers": ["datadog"],
"quality_scale": "legacy",
"requirements": ["datadog==0.52.0"]
}

View File

@@ -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,

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.10"]
"requirements": ["pyfirefly==0.1.11"]
}

View File

@@ -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"

View File

@@ -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

View File

@@ -33,4 +33,5 @@ CONF_PRESETS = {
)
}
CONF_SENSOR = "target_sensor"
CONF_KEEP_ALIVE = "keep_alive"
DEFAULT_TOLERANCE = 0.3

View File

@@ -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%]"
}

View File

@@ -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,
),
)

View File

@@ -346,7 +346,6 @@ class SensorGroup(GroupEntity, SensorEntity):
self._attr_name = name
if name == DEFAULT_NAME:
self._attr_name = f"{DEFAULT_NAME} {sensor_type}".capitalize()
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
self._ignore_non_numeric = ignore_non_numeric
self.mode = all if ignore_non_numeric is False else any
@@ -374,7 +373,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 +434,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 +448,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

View File

@@ -8,6 +8,7 @@ from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
PLATFORMS = [
Platform.BUTTON,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]

View File

@@ -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"

View 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]

View File

@@ -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"

View File

@@ -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)",

View File

@@ -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
}

View File

@@ -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(

View File

@@ -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=(

View File

@@ -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,

View File

@@ -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 (

View File

@@ -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()

View File

@@ -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

View File

@@ -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},
)

View 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

View 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

View File

@@ -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%]",

View File

@@ -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"]
}

View File

@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyrail"],
"quality_scale": "legacy",
"requirements": ["pyrail==0.4.1"]
}

View File

@@ -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.

View File

@@ -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"]
}

View File

@@ -71,4 +71,4 @@ rules:
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
strict-typing: done

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyportainer==1.0.22"]
"requirements": ["pyportainer==1.0.23"]
}

View File

@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["prowl"],
"quality_scale": "legacy",
"requirements": ["prowlpy==1.1.1"]
}

View File

@@ -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
)

View File

@@ -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,

View File

@@ -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."""

View File

@@ -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

View File

@@ -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."""

View File

@@ -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"]
}

View File

@@ -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",
),
)

View File

@@ -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

View File

@@ -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

View File

@@ -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"]
}

View File

@@ -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(

View File

@@ -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}"
},

View File

@@ -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"]
}

View File

@@ -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
View File

@@ -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

6
requirements_all.txt generated
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,37 +206,37 @@ 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")
assert state.state not in [STATE_UNAVAILABLE, STATE_UNKNOWN]
assert entity_ids[1] == state.attributes.get("max_entity_id")
assert state.attributes.get("max_entity_id") == entity_ids[1]
hass.states.async_set(entity_ids[2], STATE_UNKNOWN)
await hass.async_block_till_done()
state = hass.states.get("sensor.test_max")
assert state.state not in [STATE_UNAVAILABLE, STATE_UNKNOWN]
assert entity_ids[1] == state.attributes.get("max_entity_id")
assert state.attributes.get("max_entity_id") == entity_ids[1]
hass.states.async_set(entity_ids[1], STATE_UNAVAILABLE)
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,12 +847,18 @@ async def test_last_sensor(hass: HomeAssistant) -> None:
entity_ids = config["sensor"]["entities"]
for entity_id, value in dict(zip(entity_ids, VALUES, strict=False)).items():
hass.states.async_set(entity_id, value)
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 str(float(value)) == state.state
assert entity_id == state.attributes.get("last_entity_id")
assert state.state == STATE_UNKNOWN
for entity_id, value in dict(zip(entity_ids, VALUES, strict=False)).items():
hass.states.async_set(entity_id, str(value))
await hass.async_block_till_done()
state = hass.states.get("sensor.test_last")
assert state.state == str(float(value))
assert state.attributes.get("last_entity_id") == entity_id
async def test_sensors_attributes_added_when_entity_info_available(
@@ -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,
},

View File

@@ -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(

View File

@@ -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',

View 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',
})
# ---

View 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)

View File

@@ -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

View File

@@ -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({

View File

@@ -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,

View File

@@ -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"

View File

@@ -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"

View File

@@ -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",

View File

@@ -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,

View File

@@ -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",

View File

@@ -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

View File

@@ -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,
)