Compare commits

..

19 Commits

Author SHA1 Message Date
mettolen
171b8dfa89 Add Presentation light to Liebherr (#166154) 2026-03-25 08:12:46 +01:00
jorgenvi
f299b009fa Add PARALLEL_UPDATES to Touchline SL climate platform (#166415)
Co-authored-by: Jørgen Vinne Iversen <jorgenvi@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:05:36 +01:00
Abode Systems
91e9eb0ab3 Fix Abode retrofit lock discovery (#166433) 2026-03-25 08:04:44 +01:00
Maciej Bieniek
a2b91a9ac0 Fix KeyError for device temperature sensor in Unifi integration (#166410) 2026-03-25 08:00:14 +01:00
Brett Adams
a3add179a0 Fix Tesla Fleet partner_login to not require vehicle scope. (#166435) 2026-03-25 07:46:47 +01:00
Franck Nijhof
6075becbab Improve siren action naming consistency (#166399) 2026-03-25 07:20:58 +01:00
Marc Mueller
193f519366 Warn about *.pth files in dependencies (#166411) 2026-03-25 07:18:47 +01:00
Brett Adams
b6508c2ca4 Bump Tesla Fleet API to 1.4.5 (#166432) 2026-03-25 07:12:52 +01:00
Paulus Schoutsen
3dc478a357 Filter out WiiM devices from LinkPlay discovery (#166436) 2026-03-25 07:11:38 +01:00
TheJulianJES
bd407872b0 Bump ZHA to 1.1.0 (#166438) 2026-03-25 07:10:00 +01:00
Franck Nijhof
8b696044c3 Improve select action naming consistency (#166398) 2026-03-25 06:55:13 +01:00
Abílio Costa
1a772b6df2 Add button platform to LG Infrared (#166375) 2026-03-25 06:53:16 +01:00
Allen Porter
a880ad2904 Update Roborock entities to handle unavailable data (#165618) 2026-03-24 20:20:37 -07:00
mettolen
ea73f2d0f1 Refactor Huum test fixtures (#166115) 2026-03-25 00:22:25 +01:00
Magnus Nordseth
11351500ea Update Touchline codeowner (#166420) 2026-03-25 00:20:01 +01:00
Erwin Douna
86901bfd80 Add suspend all button Proxmox (#166417) 2026-03-24 22:36:38 +00:00
Franck Nijhof
d2ef60125f Improve update action naming consistency (#166401) 2026-03-24 22:20:24 +00:00
Brett Adams
471b49f12b Mark Tessie docs-data-update quality scale item as done (#166404) 2026-03-24 22:03:08 +01:00
Andries Louw Wolthuizen
33e9e663da Add Conductivity (EC), pH, ORP support to Tuya DGNBJ (#159584)
Co-authored-by: ramarro123 <5493729+ramarro123@users.noreply.github.com>
Co-authored-by: Erik <erik@montnemery.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-24 21:58:17 +01:00
77 changed files with 3347 additions and 2036 deletions

2
CODEOWNERS generated
View File

@@ -1762,6 +1762,8 @@ build.json @home-assistant/supervisor
/tests/components/tomorrowio/ @raman325 @lymanepp
/homeassistant/components/totalconnect/ @austinmroczek
/tests/components/totalconnect/ @austinmroczek
/homeassistant/components/touchline/ @mnordseth
/tests/components/touchline/ @mnordseth
/homeassistant/components/touchline_sl/ @jnsgruk
/tests/components/touchline_sl/ @jnsgruk
/homeassistant/components/tplink/ @rytilahti @bdraco @sdb9696

View File

@@ -9,6 +9,6 @@
},
"iot_class": "cloud_push",
"loggers": ["jaraco.abode", "lomond"],
"requirements": ["jaraco.abode==6.2.1"],
"requirements": ["jaraco.abode==6.4.0"],
"single_config_entry": true
}

View File

@@ -1,150 +0,0 @@
"""Provides conditions for air quality."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.condition import (
Condition,
make_entity_numerical_condition,
make_entity_numerical_condition_with_unit,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import (
CarbonMonoxideConcentrationConverter,
MassVolumeConcentrationConverter,
NitrogenDioxideConcentrationConverter,
NitrogenMonoxideConcentrationConverter,
OzoneConcentrationConverter,
SulphurDioxideConcentrationConverter,
UnitlessRatioConverter,
)
def _make_detected_condition(
device_class: BinarySensorDeviceClass,
) -> type[Condition]:
"""Create a detected condition for a binary sensor device class."""
return make_entity_state_condition(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_ON
)
def _make_cleared_condition(
device_class: BinarySensorDeviceClass,
) -> type[Condition]:
"""Create a cleared condition for a binary sensor device class."""
return make_entity_state_condition(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_OFF
)
CONDITIONS: dict[str, type[Condition]] = {
# Binary sensor conditions (detected/cleared)
"is_gas_detected": _make_detected_condition(BinarySensorDeviceClass.GAS),
"is_gas_cleared": _make_cleared_condition(BinarySensorDeviceClass.GAS),
"is_co_detected": _make_detected_condition(BinarySensorDeviceClass.CO),
"is_co_cleared": _make_cleared_condition(BinarySensorDeviceClass.CO),
"is_smoke_detected": _make_detected_condition(BinarySensorDeviceClass.SMOKE),
"is_smoke_cleared": _make_cleared_condition(BinarySensorDeviceClass.SMOKE),
# Numerical sensor conditions with unit conversion
"is_co_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"is_ozone_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"is_voc_value": make_entity_numerical_condition_with_unit(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"is_voc_ratio_value": make_entity_numerical_condition_with_unit(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"is_no_value": make_entity_numerical_condition_with_unit(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROGEN_MONOXIDE
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"is_no2_value": make_entity_numerical_condition_with_unit(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROGEN_DIOXIDE
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"is_so2_value": make_entity_numerical_condition_with_unit(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.SULPHUR_DIOXIDE
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
# Numerical sensor conditions without unit conversion (single-unit device classes)
"is_co2_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"is_pm1_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm25_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm4_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm10_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_n2o_value": make_entity_numerical_condition(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROUS_OXIDE
)
},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the air quality conditions."""
return CONDITIONS

View File

@@ -1,588 +0,0 @@
# --- Common condition fields ---
.condition_behavior: &condition_behavior
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
# --- Number or entity selectors ---
.number_or_entity_co: &number_or_entity_co
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "ppb"
- "ppm"
- "mg/m³"
- "μg/m³"
- domain: sensor
device_class: carbon_monoxide
- domain: number
device_class: carbon_monoxide
translation_key: number_or_entity
.number_or_entity_co2: &number_or_entity_co2
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "ppm"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "ppm"
- domain: sensor
device_class: carbon_dioxide
- domain: number
device_class: carbon_dioxide
translation_key: number_or_entity
.number_or_entity_pm1: &number_or_entity_pm1
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "μg/m³"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm1
- domain: number
device_class: pm1
translation_key: number_or_entity
.number_or_entity_pm25: &number_or_entity_pm25
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "μg/m³"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm25
- domain: number
device_class: pm25
translation_key: number_or_entity
.number_or_entity_pm4: &number_or_entity_pm4
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "μg/m³"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm4
- domain: number
device_class: pm4
translation_key: number_or_entity
.number_or_entity_pm10: &number_or_entity_pm10
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "μg/m³"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm10
- domain: number
device_class: pm10
translation_key: number_or_entity
.number_or_entity_ozone: &number_or_entity_ozone
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "ppb"
- "ppm"
- "μg/m³"
- domain: sensor
device_class: ozone
- domain: number
device_class: ozone
translation_key: number_or_entity
.number_or_entity_voc: &number_or_entity_voc
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "μg/m³"
- "mg/m³"
- domain: sensor
device_class: volatile_organic_compounds
- domain: number
device_class: volatile_organic_compounds
translation_key: number_or_entity
.number_or_entity_voc_ratio: &number_or_entity_voc_ratio
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "ppb"
- "ppm"
- domain: sensor
device_class: volatile_organic_compounds_parts
- domain: number
device_class: volatile_organic_compounds_parts
translation_key: number_or_entity
.number_or_entity_no: &number_or_entity_no
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "ppb"
- "μg/m³"
- domain: sensor
device_class: nitrogen_monoxide
- domain: number
device_class: nitrogen_monoxide
translation_key: number_or_entity
.number_or_entity_no2: &number_or_entity_no2
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "ppb"
- "ppm"
- "μg/m³"
- domain: sensor
device_class: nitrogen_dioxide
- domain: number
device_class: nitrogen_dioxide
translation_key: number_or_entity
.number_or_entity_n2o: &number_or_entity_n2o
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "μg/m³"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: nitrous_oxide
- domain: number
device_class: nitrous_oxide
translation_key: number_or_entity
.number_or_entity_so2: &number_or_entity_so2
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "ppb"
- "μg/m³"
- domain: sensor
device_class: sulphur_dioxide
- domain: number
device_class: sulphur_dioxide
translation_key: number_or_entity
# --- Unit selectors ---
.unit_co: &unit_co
required: false
selector:
select:
options:
- "ppb"
- "ppm"
- "mg/m³"
- "μg/m³"
.unit_ozone: &unit_ozone
required: false
selector:
select:
options:
- "ppb"
- "ppm"
- "μg/m³"
.unit_no2: &unit_no2
required: false
selector:
select:
options:
- "ppb"
- "ppm"
- "μg/m³"
.unit_no: &unit_no
required: false
selector:
select:
options:
- "ppb"
- "μg/m³"
.unit_so2: &unit_so2
required: false
selector:
select:
options:
- "ppb"
- "μg/m³"
.unit_voc: &unit_voc
required: false
selector:
select:
options:
- "μg/m³"
- "mg/m³"
.unit_voc_ratio: &unit_voc_ratio
required: false
selector:
select:
options:
- "ppb"
- "ppm"
# --- Binary sensor targets ---
.target_gas: &target_gas
entity:
- domain: binary_sensor
device_class: gas
.target_co_binary: &target_co_binary
entity:
- domain: binary_sensor
device_class: carbon_monoxide
.target_smoke: &target_smoke
entity:
- domain: binary_sensor
device_class: smoke
# --- Sensor targets ---
.target_co_sensor: &target_co_sensor
entity:
- domain: sensor
device_class: carbon_monoxide
.target_co2: &target_co2
entity:
- domain: sensor
device_class: carbon_dioxide
.target_pm1: &target_pm1
entity:
- domain: sensor
device_class: pm1
.target_pm25: &target_pm25
entity:
- domain: sensor
device_class: pm25
.target_pm4: &target_pm4
entity:
- domain: sensor
device_class: pm4
.target_pm10: &target_pm10
entity:
- domain: sensor
device_class: pm10
.target_ozone: &target_ozone
entity:
- domain: sensor
device_class: ozone
.target_voc: &target_voc
entity:
- domain: sensor
device_class: volatile_organic_compounds
.target_voc_ratio: &target_voc_ratio
entity:
- domain: sensor
device_class: volatile_organic_compounds_parts
.target_no: &target_no
entity:
- domain: sensor
device_class: nitrogen_monoxide
.target_no2: &target_no2
entity:
- domain: sensor
device_class: nitrogen_dioxide
.target_n2o: &target_n2o
entity:
- domain: sensor
device_class: nitrous_oxide
.target_so2: &target_so2
entity:
- domain: sensor
device_class: sulphur_dioxide
# --- Binary sensor conditions ---
.condition_binary_common: &condition_binary_common
fields:
behavior: *condition_behavior
is_gas_detected:
<<: *condition_binary_common
target: *target_gas
is_gas_cleared:
<<: *condition_binary_common
target: *target_gas
is_co_detected:
<<: *condition_binary_common
target: *target_co_binary
is_co_cleared:
<<: *condition_binary_common
target: *target_co_binary
is_smoke_detected:
<<: *condition_binary_common
target: *target_smoke
is_smoke_cleared:
<<: *condition_binary_common
target: *target_smoke
# --- Numerical sensor conditions with unit conversion ---
is_co_value:
target: *target_co_sensor
fields:
behavior: *condition_behavior
above: *number_or_entity_co
below: *number_or_entity_co
unit: *unit_co
is_ozone_value:
target: *target_ozone
fields:
behavior: *condition_behavior
above: *number_or_entity_ozone
below: *number_or_entity_ozone
unit: *unit_ozone
is_voc_value:
target: *target_voc
fields:
behavior: *condition_behavior
above: *number_or_entity_voc
below: *number_or_entity_voc
unit: *unit_voc
is_voc_ratio_value:
target: *target_voc_ratio
fields:
behavior: *condition_behavior
above: *number_or_entity_voc_ratio
below: *number_or_entity_voc_ratio
unit: *unit_voc_ratio
is_no_value:
target: *target_no
fields:
behavior: *condition_behavior
above: *number_or_entity_no
below: *number_or_entity_no
unit: *unit_no
is_no2_value:
target: *target_no2
fields:
behavior: *condition_behavior
above: *number_or_entity_no2
below: *number_or_entity_no2
unit: *unit_no2
is_so2_value:
target: *target_so2
fields:
behavior: *condition_behavior
above: *number_or_entity_so2
below: *number_or_entity_so2
unit: *unit_so2
# --- Numerical sensor conditions without unit conversion ---
is_co2_value:
target: *target_co2
fields:
behavior: *condition_behavior
above: *number_or_entity_co2
below: *number_or_entity_co2
is_pm1_value:
target: *target_pm1
fields:
behavior: *condition_behavior
above: *number_or_entity_pm1
below: *number_or_entity_pm1
is_pm25_value:
target: *target_pm25
fields:
behavior: *condition_behavior
above: *number_or_entity_pm25
below: *number_or_entity_pm25
is_pm4_value:
target: *target_pm4
fields:
behavior: *condition_behavior
above: *number_or_entity_pm4
below: *number_or_entity_pm4
is_pm10_value:
target: *target_pm10
fields:
behavior: *condition_behavior
above: *number_or_entity_pm10
below: *number_or_entity_pm10
is_n2o_value:
target: *target_n2o
fields:
behavior: *condition_behavior
above: *number_or_entity_n2o
below: *number_or_entity_n2o

View File

@@ -1,63 +1,4 @@
{
"conditions": {
"is_co2_value": {
"condition": "mdi:molecule-co2"
},
"is_co_cleared": {
"condition": "mdi:check-circle"
},
"is_co_detected": {
"condition": "mdi:molecule-co"
},
"is_co_value": {
"condition": "mdi:molecule-co"
},
"is_gas_cleared": {
"condition": "mdi:check-circle"
},
"is_gas_detected": {
"condition": "mdi:gas-cylinder"
},
"is_n2o_value": {
"condition": "mdi:factory"
},
"is_no2_value": {
"condition": "mdi:factory"
},
"is_no_value": {
"condition": "mdi:factory"
},
"is_ozone_value": {
"condition": "mdi:weather-sunny-alert"
},
"is_pm10_value": {
"condition": "mdi:blur"
},
"is_pm1_value": {
"condition": "mdi:blur"
},
"is_pm25_value": {
"condition": "mdi:blur"
},
"is_pm4_value": {
"condition": "mdi:blur"
},
"is_smoke_cleared": {
"condition": "mdi:check-circle"
},
"is_smoke_detected": {
"condition": "mdi:smoke-detector-variant"
},
"is_so2_value": {
"condition": "mdi:factory"
},
"is_voc_ratio_value": {
"condition": "mdi:air-filter"
},
"is_voc_value": {
"condition": "mdi:air-filter"
}
},
"entity_component": {
"_": {
"default": "mdi:air-filter"

View File

@@ -1,13 +1,5 @@
{
"common": {
"condition_above_description": "Require the value to be above this value.",
"condition_above_name": "Above",
"condition_behavior_description": "How the value should match on the targeted entities.",
"condition_behavior_name": "Behavior",
"condition_below_description": "Require the value to be below this value.",
"condition_below_name": "Below",
"condition_unit_description": "All values will be converted to this unit when evaluating the condition.",
"condition_unit_name": "Unit of measurement",
"trigger_behavior_description": "The behavior of the targeted entities to trigger on.",
"trigger_behavior_name": "Behavior",
"trigger_changed_above_name": "Above",
@@ -21,337 +13,7 @@
"trigger_unit_description": "All values will be converted to this unit when evaluating the trigger.",
"trigger_unit_name": "Unit of measurement"
},
"conditions": {
"is_co2_value": {
"description": "Tests the carbon dioxide level of one or more entities.",
"fields": {
"above": {
"description": "[%key:component::air_quality::common::condition_above_description%]",
"name": "[%key:component::air_quality::common::condition_above_name%]"
},
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"below": {
"description": "[%key:component::air_quality::common::condition_below_description%]",
"name": "[%key:component::air_quality::common::condition_below_name%]"
}
},
"name": "Carbon dioxide value"
},
"is_co_cleared": {
"description": "Tests if one or more carbon monoxide sensors are cleared.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Carbon monoxide cleared"
},
"is_co_detected": {
"description": "Tests if one or more carbon monoxide sensors are detecting carbon monoxide.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Carbon monoxide detected"
},
"is_co_value": {
"description": "Tests the carbon monoxide level of one or more entities.",
"fields": {
"above": {
"description": "[%key:component::air_quality::common::condition_above_description%]",
"name": "[%key:component::air_quality::common::condition_above_name%]"
},
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"below": {
"description": "[%key:component::air_quality::common::condition_below_description%]",
"name": "[%key:component::air_quality::common::condition_below_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::condition_unit_description%]",
"name": "[%key:component::air_quality::common::condition_unit_name%]"
}
},
"name": "Carbon monoxide value"
},
"is_gas_cleared": {
"description": "Tests if one or more gas sensors are cleared.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Gas cleared"
},
"is_gas_detected": {
"description": "Tests if one or more gas sensors are detecting gas.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Gas detected"
},
"is_n2o_value": {
"description": "Tests the nitrous oxide level of one or more entities.",
"fields": {
"above": {
"description": "[%key:component::air_quality::common::condition_above_description%]",
"name": "[%key:component::air_quality::common::condition_above_name%]"
},
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"below": {
"description": "[%key:component::air_quality::common::condition_below_description%]",
"name": "[%key:component::air_quality::common::condition_below_name%]"
}
},
"name": "Nitrous oxide value"
},
"is_no2_value": {
"description": "Tests the nitrogen dioxide level of one or more entities.",
"fields": {
"above": {
"description": "[%key:component::air_quality::common::condition_above_description%]",
"name": "[%key:component::air_quality::common::condition_above_name%]"
},
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"below": {
"description": "[%key:component::air_quality::common::condition_below_description%]",
"name": "[%key:component::air_quality::common::condition_below_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::condition_unit_description%]",
"name": "[%key:component::air_quality::common::condition_unit_name%]"
}
},
"name": "Nitrogen dioxide value"
},
"is_no_value": {
"description": "Tests the nitrogen monoxide level of one or more entities.",
"fields": {
"above": {
"description": "[%key:component::air_quality::common::condition_above_description%]",
"name": "[%key:component::air_quality::common::condition_above_name%]"
},
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"below": {
"description": "[%key:component::air_quality::common::condition_below_description%]",
"name": "[%key:component::air_quality::common::condition_below_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::condition_unit_description%]",
"name": "[%key:component::air_quality::common::condition_unit_name%]"
}
},
"name": "Nitrogen monoxide value"
},
"is_ozone_value": {
"description": "Tests the ozone level of one or more entities.",
"fields": {
"above": {
"description": "[%key:component::air_quality::common::condition_above_description%]",
"name": "[%key:component::air_quality::common::condition_above_name%]"
},
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"below": {
"description": "[%key:component::air_quality::common::condition_below_description%]",
"name": "[%key:component::air_quality::common::condition_below_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::condition_unit_description%]",
"name": "[%key:component::air_quality::common::condition_unit_name%]"
}
},
"name": "Ozone value"
},
"is_pm10_value": {
"description": "Tests the PM10 level of one or more entities.",
"fields": {
"above": {
"description": "[%key:component::air_quality::common::condition_above_description%]",
"name": "[%key:component::air_quality::common::condition_above_name%]"
},
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"below": {
"description": "[%key:component::air_quality::common::condition_below_description%]",
"name": "[%key:component::air_quality::common::condition_below_name%]"
}
},
"name": "PM10 value"
},
"is_pm1_value": {
"description": "Tests the PM1 level of one or more entities.",
"fields": {
"above": {
"description": "[%key:component::air_quality::common::condition_above_description%]",
"name": "[%key:component::air_quality::common::condition_above_name%]"
},
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"below": {
"description": "[%key:component::air_quality::common::condition_below_description%]",
"name": "[%key:component::air_quality::common::condition_below_name%]"
}
},
"name": "PM1 value"
},
"is_pm25_value": {
"description": "Tests the PM2.5 level of one or more entities.",
"fields": {
"above": {
"description": "[%key:component::air_quality::common::condition_above_description%]",
"name": "[%key:component::air_quality::common::condition_above_name%]"
},
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"below": {
"description": "[%key:component::air_quality::common::condition_below_description%]",
"name": "[%key:component::air_quality::common::condition_below_name%]"
}
},
"name": "PM2.5 value"
},
"is_pm4_value": {
"description": "Tests the PM4 level of one or more entities.",
"fields": {
"above": {
"description": "[%key:component::air_quality::common::condition_above_description%]",
"name": "[%key:component::air_quality::common::condition_above_name%]"
},
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"below": {
"description": "[%key:component::air_quality::common::condition_below_description%]",
"name": "[%key:component::air_quality::common::condition_below_name%]"
}
},
"name": "PM4 value"
},
"is_smoke_cleared": {
"description": "Tests if one or more smoke sensors are cleared.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Smoke cleared"
},
"is_smoke_detected": {
"description": "Tests if one or more smoke sensors are detecting smoke.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
"name": "Smoke detected"
},
"is_so2_value": {
"description": "Tests the sulphur dioxide level of one or more entities.",
"fields": {
"above": {
"description": "[%key:component::air_quality::common::condition_above_description%]",
"name": "[%key:component::air_quality::common::condition_above_name%]"
},
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"below": {
"description": "[%key:component::air_quality::common::condition_below_description%]",
"name": "[%key:component::air_quality::common::condition_below_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::condition_unit_description%]",
"name": "[%key:component::air_quality::common::condition_unit_name%]"
}
},
"name": "Sulphur dioxide value"
},
"is_voc_ratio_value": {
"description": "Tests the volatile organic compounds ratio of one or more entities.",
"fields": {
"above": {
"description": "[%key:component::air_quality::common::condition_above_description%]",
"name": "[%key:component::air_quality::common::condition_above_name%]"
},
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"below": {
"description": "[%key:component::air_quality::common::condition_below_description%]",
"name": "[%key:component::air_quality::common::condition_below_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::condition_unit_description%]",
"name": "[%key:component::air_quality::common::condition_unit_name%]"
}
},
"name": "Volatile organic compounds ratio value"
},
"is_voc_value": {
"description": "Tests the volatile organic compounds level of one or more entities.",
"fields": {
"above": {
"description": "[%key:component::air_quality::common::condition_above_description%]",
"name": "[%key:component::air_quality::common::condition_above_name%]"
},
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"below": {
"description": "[%key:component::air_quality::common::condition_below_description%]",
"name": "[%key:component::air_quality::common::condition_below_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::condition_unit_description%]",
"name": "[%key:component::air_quality::common::condition_unit_name%]"
}
},
"name": "Volatile organic compounds value"
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"number_or_entity": {
"choices": {
"entity": "Entity",

View File

@@ -118,7 +118,6 @@ SERVICE_TRIGGER = "trigger"
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
_EXPERIMENTAL_CONDITION_PLATFORMS = {
"air_quality",
"alarm_control_panel",
"assist_satellite",
"battery",

View File

@@ -39,10 +39,7 @@ rules:
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage:
status: todo
comment: |
PLANNED: Use freezer-based time advancement instead of directly calling async_refresh().
test-coverage: todo
# Gold
devices: done

View File

@@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View File

@@ -0,0 +1,147 @@
"""Button platform for LG IR integration."""
from __future__ import annotations
from dataclasses import dataclass
from infrared_protocols.codes.lg.tv import LGTVCode
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, LGDeviceType
from .entity import LgIrEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class LgIrButtonEntityDescription(ButtonEntityDescription):
"""Describes LG IR button entity."""
command_code: LGTVCode
TV_BUTTON_DESCRIPTIONS: tuple[LgIrButtonEntityDescription, ...] = (
LgIrButtonEntityDescription(
key="power_on", translation_key="power_on", command_code=LGTVCode.POWER_ON
),
LgIrButtonEntityDescription(
key="power_off", translation_key="power_off", command_code=LGTVCode.POWER_OFF
),
LgIrButtonEntityDescription(
key="hdmi_1", translation_key="hdmi_1", command_code=LGTVCode.HDMI_1
),
LgIrButtonEntityDescription(
key="hdmi_2", translation_key="hdmi_2", command_code=LGTVCode.HDMI_2
),
LgIrButtonEntityDescription(
key="hdmi_3", translation_key="hdmi_3", command_code=LGTVCode.HDMI_3
),
LgIrButtonEntityDescription(
key="hdmi_4", translation_key="hdmi_4", command_code=LGTVCode.HDMI_4
),
LgIrButtonEntityDescription(
key="exit", translation_key="exit", command_code=LGTVCode.EXIT
),
LgIrButtonEntityDescription(
key="info", translation_key="info", command_code=LGTVCode.INFO
),
LgIrButtonEntityDescription(
key="guide", translation_key="guide", command_code=LGTVCode.GUIDE
),
LgIrButtonEntityDescription(
key="up", translation_key="up", command_code=LGTVCode.NAV_UP
),
LgIrButtonEntityDescription(
key="down", translation_key="down", command_code=LGTVCode.NAV_DOWN
),
LgIrButtonEntityDescription(
key="left", translation_key="left", command_code=LGTVCode.NAV_LEFT
),
LgIrButtonEntityDescription(
key="right", translation_key="right", command_code=LGTVCode.NAV_RIGHT
),
LgIrButtonEntityDescription(
key="ok", translation_key="ok", command_code=LGTVCode.OK
),
LgIrButtonEntityDescription(
key="back", translation_key="back", command_code=LGTVCode.BACK
),
LgIrButtonEntityDescription(
key="home", translation_key="home", command_code=LGTVCode.HOME
),
LgIrButtonEntityDescription(
key="menu", translation_key="menu", command_code=LGTVCode.MENU
),
LgIrButtonEntityDescription(
key="input", translation_key="input", command_code=LGTVCode.INPUT
),
LgIrButtonEntityDescription(
key="num_0", translation_key="num_0", command_code=LGTVCode.NUM_0
),
LgIrButtonEntityDescription(
key="num_1", translation_key="num_1", command_code=LGTVCode.NUM_1
),
LgIrButtonEntityDescription(
key="num_2", translation_key="num_2", command_code=LGTVCode.NUM_2
),
LgIrButtonEntityDescription(
key="num_3", translation_key="num_3", command_code=LGTVCode.NUM_3
),
LgIrButtonEntityDescription(
key="num_4", translation_key="num_4", command_code=LGTVCode.NUM_4
),
LgIrButtonEntityDescription(
key="num_5", translation_key="num_5", command_code=LGTVCode.NUM_5
),
LgIrButtonEntityDescription(
key="num_6", translation_key="num_6", command_code=LGTVCode.NUM_6
),
LgIrButtonEntityDescription(
key="num_7", translation_key="num_7", command_code=LGTVCode.NUM_7
),
LgIrButtonEntityDescription(
key="num_8", translation_key="num_8", command_code=LGTVCode.NUM_8
),
LgIrButtonEntityDescription(
key="num_9", translation_key="num_9", command_code=LGTVCode.NUM_9
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LG IR buttons from config entry."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
device_type = entry.data[CONF_DEVICE_TYPE]
if device_type == LGDeviceType.TV:
async_add_entities(
LgIrButton(entry, infrared_entity_id, description)
for description in TV_BUTTON_DESCRIPTIONS
)
class LgIrButton(LgIrEntity, ButtonEntity):
"""LG IR button entity."""
entity_description: LgIrButtonEntityDescription
def __init__(
self,
entry: ConfigEntry,
infrared_entity_id: str,
description: LgIrButtonEntityDescription,
) -> None:
"""Initialize LG IR button."""
super().__init__(entry, infrared_entity_id, unique_id_suffix=description.key)
self.entity_description = description
async def async_press(self) -> None:
"""Press the button."""
await self._send_command(self.entity_description.command_code)

View File

@@ -19,6 +19,94 @@
}
}
},
"entity": {
"button": {
"back": {
"name": "Back"
},
"down": {
"name": "Down"
},
"exit": {
"name": "Exit"
},
"guide": {
"name": "Guide"
},
"hdmi_1": {
"name": "HDMI 1"
},
"hdmi_2": {
"name": "HDMI 2"
},
"hdmi_3": {
"name": "HDMI 3"
},
"hdmi_4": {
"name": "HDMI 4"
},
"home": {
"name": "Home"
},
"info": {
"name": "Info"
},
"input": {
"name": "Input"
},
"left": {
"name": "Left"
},
"menu": {
"name": "Menu"
},
"num_0": {
"name": "Number 0"
},
"num_1": {
"name": "Number 1"
},
"num_2": {
"name": "Number 2"
},
"num_3": {
"name": "Number 3"
},
"num_4": {
"name": "Number 4"
},
"num_5": {
"name": "Number 5"
},
"num_6": {
"name": "Number 6"
},
"num_7": {
"name": "Number 7"
},
"num_8": {
"name": "Number 8"
},
"num_9": {
"name": "Number 9"
},
"ok": {
"name": "OK"
},
"power_off": {
"name": "Power off"
},
"power_on": {
"name": "Power on"
},
"right": {
"name": "Right"
},
"up": {
"name": "Up"
}
}
},
"selector": {
"device_type": {
"options": {

View File

@@ -26,6 +26,7 @@ from .coordinator import LiebherrConfigEntry, LiebherrCoordinator, LiebherrData
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.LIGHT,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,

View File

@@ -0,0 +1,132 @@
"""Light platform for Liebherr integration."""
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any
from pyliebherrhomeapi import PresentationLightControl
from pyliebherrhomeapi.const import CONTROL_PRESENTATION_LIGHT
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import LiebherrEntity
DEFAULT_MAX_BRIGHTNESS_LEVEL = 5
PARALLEL_UPDATES = 1
def _create_light_entities(
coordinators: list[LiebherrCoordinator],
) -> list[LiebherrPresentationLight]:
"""Create light entities for the given coordinators."""
return [
LiebherrPresentationLight(coordinator=coordinator)
for coordinator in coordinators
for control in coordinator.data.controls
if isinstance(control, PresentationLightControl)
and control.name == CONTROL_PRESENTATION_LIGHT
]
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr light entities."""
async_add_entities(
_create_light_entities(list(entry.runtime_data.coordinators.values()))
)
@callback
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
"""Add light entities for new devices."""
async_add_entities(_create_light_entities(coordinators))
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
)
)
class LiebherrPresentationLight(LiebherrEntity, LightEntity):
"""Representation of a Liebherr presentation light."""
_attr_translation_key = "presentation_light"
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
def __init__(
self,
coordinator: LiebherrCoordinator,
) -> None:
"""Initialize the presentation light entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.device_id}_presentation_light"
@property
def _light_control(self) -> PresentationLightControl | None:
"""Get the presentation light control."""
controls = self.coordinator.data.get_presentation_light_controls()
return controls.get(CONTROL_PRESENTATION_LIGHT)
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._light_control is not None
@property
def is_on(self) -> bool | None:
"""Return true if the light is on."""
control = self._light_control
if TYPE_CHECKING:
assert control is not None
if control.value is None:
return None
return control.value > 0
@property
def brightness(self) -> int | None:
"""Return the brightness of the light (0-255)."""
control = self._light_control
if TYPE_CHECKING:
assert control is not None
if control.value is None or control.max is None or control.max == 0:
return None
return math.ceil(control.value * 255 / control.max)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
control = self._light_control
if TYPE_CHECKING:
assert control is not None
max_level = control.max or DEFAULT_MAX_BRIGHTNESS_LEVEL
if ATTR_BRIGHTNESS in kwargs:
target = max(1, round(kwargs[ATTR_BRIGHTNESS] * max_level / 255))
else:
target = max_level
await self._async_send_command(
self.coordinator.client.set_presentation_light(
device_id=self.coordinator.device_id,
target=target,
)
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self._async_send_command(
self.coordinator.client.set_presentation_light(
device_id=self.coordinator.device_id,
target=0,
)
)

View File

@@ -33,6 +33,11 @@
}
},
"entity": {
"light": {
"presentation_light": {
"name": "Presentation light"
}
},
"number": {
"setpoint_temperature": {
"name": "Setpoint"

View File

@@ -7,6 +7,7 @@ from aiohttp import ClientSession
from linkplay.bridge import LinkPlayBridge
from linkplay.discovery import linkplay_factory_httpapi_bridge
from linkplay.exceptions import LinkPlayRequestException
from linkplay.manufacturers import MANUFACTURER_WIIM
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -45,6 +46,9 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self.async_abort(reason="cannot_connect")
if bridge.device.manufacturer == MANUFACTURER_WIIM:
return self.async_abort(reason="not_linkplay_device")
self.data[CONF_HOST] = discovery_info.host
self.data[CONF_MODEL] = bridge.device.name

View File

@@ -2,7 +2,8 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"not_linkplay_device": "This device should be set up with the WiiM integration."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",

View File

@@ -82,6 +82,14 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = (
).stopall.post(),
entity_category=EntityCategory.CONFIG,
),
ProxmoxNodeButtonNodeEntityDescription(
key="suspend_all",
translation_key="suspend_all",
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
node
).suspendall.post(),
entity_category=EntityCategory.CONFIG,
),
)
VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = (

View File

@@ -13,8 +13,17 @@
"start": {
"default": "mdi:play"
},
"start_all": {
"default": "mdi:play"
},
"stop": {
"default": "mdi:stop"
},
"stop_all": {
"default": "mdi:stop"
},
"suspend_all": {
"default": "mdi:pause"
}
},
"sensor": {

View File

@@ -130,6 +130,9 @@
},
"stop_all": {
"name": "Stop all"
},
"suspend_all": {
"name": "Suspend all"
}
},
"sensor": {

View File

@@ -159,7 +159,11 @@ async def async_setup_entry(
)
for coordinator in config_entry.runtime_data.v1
for description in BINARY_SENSOR_DESCRIPTIONS
if description.value_fn(coordinator.data) is not None
# Note: Currently coordinator.data is always available on startup but won't be in the future
if (
coordinator.data is not None
and description.value_fn(coordinator.data) is not None
)
]
entities.extend(
RoborockBinarySensorEntityA01(
@@ -193,9 +197,11 @@ class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity
self.entity_description = description
@property
def is_on(self) -> bool:
def is_on(self) -> bool | None:
"""Return the value reported by the sensor."""
return bool(self.entity_description.value_fn(self.coordinator.data))
if (data := self.coordinator.data) is not None:
return bool(self.entity_description.value_fn(data))
return None
class RoborockBinarySensorEntityA01(RoborockCoordinatedEntityA01, BinarySensorEntity):

View File

@@ -83,7 +83,7 @@ class RoborockCoordinators:
type RoborockConfigEntry = ConfigEntry[RoborockCoordinators]
class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState]):
class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState | None]):
"""Class to manage fetching data from the API."""
config_entry: RoborockConfigEntry
@@ -229,7 +229,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState]):
)
_LOGGER.debug("Updated device properties")
async def _async_update_data(self) -> DeviceState:
async def _async_update_data(self) -> DeviceState | None:
"""Update data via library."""
await self._verify_api()
try:

View File

@@ -3,7 +3,6 @@
from typing import Any
from roborock.devices.traits.v1.command import CommandTrait
from roborock.devices.traits.v1.status import StatusTrait
from roborock.exceptions import RoborockException
from roborock.roborock_typing import RoborockCommand
@@ -94,12 +93,6 @@ class RoborockCoordinatedEntityV1(
CoordinatorEntity.__init__(self, coordinator=coordinator)
self._attr_unique_id = unique_id
@property
def _device_status(self) -> StatusTrait:
"""Return the status of the device."""
data = self.coordinator.data
return data.status
async def send(
self,
command: RoborockCommand | str,

View File

@@ -9,7 +9,7 @@ from roborock.devices.traits.v1.map_content import MapContent
from homeassistant.components.image import ImageEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -73,6 +73,7 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
self.map_flag = map_flag
self.cached_map: bytes | None = None
self._attr_entity_category = EntityCategory.DIAGNOSTIC
self._attr_image_last_updated = None
async def async_added_to_hass(self) -> None:
"""When entity is added to hass load any previously cached maps from disk."""
@@ -88,17 +89,17 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
return map_content
return None
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.
If the coordinator has updated the map, we can update the image.
"""
if (map_content := self._map_content) is None:
if self.coordinator.data is None or (map_content := self._map_content) is None:
return
if self.cached_map != map_content.image_content:
self.cached_map = map_content.image_content
self._attr_image_last_updated = self.coordinator.last_home_update
super()._handle_coordinator_update()
async def async_image(self) -> bytes | None:

View File

@@ -427,7 +427,11 @@ async def async_setup_entry(
)
for coordinator in coordinators.v1
for description in SENSOR_DESCRIPTIONS
if description.value_fn(coordinator.data) is not None
# Note: Currently coordinator.data is always available on startup but won't be in the future
if (
coordinator.data is not None
and description.value_fn(coordinator.data) is not None
)
]
entities.extend(RoborockCurrentRoom(coordinator) for coordinator in coordinators.v1)
entities.extend(
@@ -480,6 +484,8 @@ class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity):
@property
def native_value(self) -> StateType | datetime.datetime:
"""Return the value reported by the sensor."""
if self.coordinator.data is None:
return None
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -150,6 +150,7 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
coordinator.duid_slug,
coordinator,
)
self._status_trait = coordinator.properties_api.status
self._home_trait = coordinator.properties_api.home
self._maps_trait = coordinator.properties_api.maps
@@ -176,31 +177,37 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
@property
def fan_speed_list(self) -> list[str]:
"""Get the list of available fan speeds."""
return [mode.value for mode in self._device_status.fan_speed_options]
if self.coordinator.data is None:
return []
return [mode.value for mode in self._status_trait.fan_speed_options]
@property
def activity(self) -> VacuumActivity | None:
"""Return the status of the vacuum cleaner."""
assert self._device_status.state is not None
return STATE_CODE_TO_STATE.get(self._device_status.state)
if self.coordinator.data is None or self._status_trait.state is None:
return None
return STATE_CODE_TO_STATE.get(self._status_trait.state)
@property
def fan_speed(self) -> str | None:
"""Return the fan speed of the vacuum cleaner."""
return self._device_status.fan_speed_name
if self.coordinator.data is None:
return None
return self._status_trait.fan_speed_name
async def async_start(self) -> None:
"""Start the vacuum."""
if self._device_status.in_returning == 1:
await self.send(RoborockCommand.APP_CHARGE)
elif self._device_status.in_cleaning == 2:
await self.send(RoborockCommand.RESUME_ZONED_CLEAN)
elif self._device_status.in_cleaning == 3:
await self.send(RoborockCommand.RESUME_SEGMENT_CLEAN)
elif self._device_status.in_cleaning == 4:
await self.send(RoborockCommand.APP_RESUME_BUILD_MAP)
else:
await self.send(RoborockCommand.APP_START)
command = RoborockCommand.APP_START
if self.coordinator.data is not None:
if self._status_trait.in_returning == 1:
command = RoborockCommand.APP_CHARGE
elif self._status_trait.in_cleaning == 2:
command = RoborockCommand.RESUME_ZONED_CLEAN
elif self._status_trait.in_cleaning == 3:
command = RoborockCommand.RESUME_SEGMENT_CLEAN
elif self._status_trait.in_cleaning == 4:
command = RoborockCommand.APP_RESUME_BUILD_MAP
await self.send(command)
async def async_pause(self) -> None:
"""Pause the vacuum."""
@@ -224,10 +231,15 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set vacuum fan speed."""
if self.coordinator.data is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="update_options_failed",
)
await self.send(
RoborockCommand.SET_CUSTOM_MODE,
[
{v: k for k, v in self._device_status.fan_speed_mapping.items()}[
{v: k for k, v in self._status_trait.fan_speed_mapping.items()}[
fan_speed
]
],

View File

@@ -38,42 +38,42 @@
},
"services": {
"select_first": {
"description": "Selects the first option.",
"name": "First"
"description": "Selects the first option of a select.",
"name": "Select first option"
},
"select_last": {
"description": "Selects the last option.",
"name": "Last"
"description": "Selects the last option of a select.",
"name": "Select last option"
},
"select_next": {
"description": "Selects the next option.",
"description": "Selects the next option of a select.",
"fields": {
"cycle": {
"description": "If the option should cycle from the last to the first.",
"name": "Cycle"
}
},
"name": "Next"
"name": "Select next option"
},
"select_option": {
"description": "Selects an option.",
"description": "Selects an option of a select.",
"fields": {
"option": {
"description": "Option to be selected.",
"name": "Option"
}
},
"name": "Select"
"name": "Select option"
},
"select_previous": {
"description": "Selects the previous option.",
"description": "Selects the previous option of a select.",
"fields": {
"cycle": {
"description": "If the option should cycle from the first to the last.",
"name": "Cycle"
}
},
"name": "Previous"
"name": "Select previous option"
}
},
"title": "Select",

View File

@@ -58,15 +58,15 @@
},
"services": {
"toggle": {
"description": "Toggles the siren on/off.",
"name": "[%key:common::action::toggle%]"
"description": "Toggles a siren on/off.",
"name": "Toggle siren"
},
"turn_off": {
"description": "Turns the siren off.",
"name": "[%key:common::action::turn_off%]"
"description": "Turns off a siren.",
"name": "Turn off siren"
},
"turn_on": {
"description": "Turns the siren on.",
"description": "Turns on a siren.",
"fields": {
"duration": {
"description": "Number of seconds the sound is played. Must be supported by the integration.",
@@ -81,7 +81,7 @@
"name": "Volume"
}
},
"name": "[%key:common::action::turn_on%]"
"name": "Turn on siren"
}
},
"title": "Siren",

View File

@@ -9,8 +9,14 @@ from typing import Any, cast
import jwt
from tesla_fleet_api import TeslaFleetApi
from tesla_fleet_api.const import SERVERS
from tesla_fleet_api.exceptions import PreconditionFailed, TeslaFleetError
from tesla_fleet_api.const import SERVERS, Scope
from tesla_fleet_api.exceptions import (
InvalidToken,
LoginRequired,
OAuthExpired,
PreconditionFailed,
TeslaFleetError,
)
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
@@ -69,6 +75,7 @@ class OAuth2FlowHandler(
# OAuth done, setup Partner API connections for all regions
implementation = cast(TeslaUserImplementation, self.flow_impl)
session = async_get_clientsession(self.hass)
failed_regions: list[str] = []
for region, server_url in SERVERS.items():
if region == "cn":
@@ -84,11 +91,37 @@ class OAuth2FlowHandler(
vehicle_scope=False,
)
await api.get_private_key(self.hass.config.path("tesla_fleet.key"))
await api.partner_login(
implementation.client_id, implementation.client_secret
)
try:
await api.partner_login(
implementation.client_id,
implementation.client_secret,
[Scope.OPENID],
)
except (InvalidToken, OAuthExpired, LoginRequired) as err:
LOGGER.warning(
"Partner login failed for %s due to an authentication error: %s",
server_url,
err,
)
return self.async_abort(reason="oauth_error")
except TeslaFleetError as err:
LOGGER.warning("Partner login failed for %s: %s", server_url, err)
failed_regions.append(server_url)
continue
self.apis.append(api)
if not self.apis:
LOGGER.warning(
"Partner login failed for all regions: %s", ", ".join(failed_regions)
)
return self.async_abort(reason="oauth_error")
if failed_regions:
LOGGER.warning(
"Partner login succeeded on some regions but failed on: %s",
", ".join(failed_regions),
)
return await self.async_step_domain_input()
async def async_step_domain_input(

View File

@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
"requirements": ["tesla-fleet-api==1.4.3"]
"requirements": ["tesla-fleet-api==1.4.5"]
}

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
"quality_scale": "platinum",
"requirements": ["tesla-fleet-api==1.4.3", "teslemetry-stream==0.9.0"]
"requirements": ["tesla-fleet-api==1.4.5", "teslemetry-stream==0.9.0"]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["tessie", "tesla-fleet-api"],
"quality_scale": "silver",
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.4.3"]
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.4.5"]
}

View File

@@ -60,7 +60,7 @@ rules:
status: exempt
comment: |
Cloud-based service without local discovery capabilities.
docs-data-update: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done

View File

@@ -1,7 +1,7 @@
{
"domain": "touchline",
"name": "Roth Touchline",
"codeowners": [],
"codeowners": ["@mnordseth"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/touchline",
"integration_type": "hub",

View File

@@ -15,6 +15,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TouchlineSLConfigEntry, TouchlineSLModuleCoordinator
from .entity import TouchlineSLZoneEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,

View File

@@ -17,6 +17,7 @@ from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
Platform,
UnitOfConductivity,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
@@ -705,6 +706,7 @@ class DPCode(StrEnum):
DOORCONTACT_STATE_2 = "doorcontact_state_2"
DOORCONTACT_STATE_3 = "doorcontact_state_3"
DUSTER_CLOTH = "duster_cloth"
EC_CURRENT = "ec_current"
ECO2 = "eco2"
EDGE_BRUSH = "edge_brush"
ELECTRICITY_LEFT = "electricity_left"
@@ -784,6 +786,7 @@ class DPCode(StrEnum):
MUFFLING = "muffling" # Muffling
NEAR_DETECTION = "near_detection"
OPPOSITE = "opposite"
ORP_CURRENT = "orp_current"
OUTPUT_POWER_LIMIT = "output_power_limit"
OXYGEN = "oxygen" # Oxygen bar
PAUSE = "pause"
@@ -796,6 +799,7 @@ class DPCode(StrEnum):
PHASE_A = "phase_a"
PHASE_B = "phase_b"
PHASE_C = "phase_c"
PH_CURRENT = "ph_current"
PIR = "pir" # Motion sensor
PM1 = "pm1"
PM10 = "pm10"
@@ -1167,6 +1171,20 @@ UNITS = (
aliases={"mv", "millivolt"},
device_classes={SensorDeviceClass.VOLTAGE},
),
UnitOfMeasurement(
unit="",
aliases={"ph"},
device_classes={
SensorDeviceClass.PH,
},
),
UnitOfMeasurement(
unit=UnitOfConductivity.MICROSIEMENS_PER_CM,
aliases={"us"},
device_classes={
SensorDeviceClass.CONDUCTIVITY,
},
),
)

View File

@@ -315,6 +315,11 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
TuyaSensorEntityDescription(
key=DPCode.EC_CURRENT,
device_class=SensorDeviceClass.CONDUCTIVITY,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.CH2O_VALUE,
translation_key="formaldehyde",
@@ -342,6 +347,17 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.ORP_CURRENT,
translation_key="oxydo_reduction_potential",
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.PH_CURRENT,
device_class=SensorDeviceClass.PH,
# pH is unitless; treat any Tuya-reported "pH"/"ph" unit as unitless
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.SMOKE_SENSOR_VALUE,
translation_key="smoke_amount",

View File

@@ -756,6 +756,9 @@
"work": "Working"
}
},
"oxydo_reduction_potential": {
"name": "Oxydo reduction potential"
},
"phase_a_current": {
"name": "Phase A current"
},

View File

@@ -300,13 +300,22 @@ def make_wan_latency_sensors() -> tuple[UnifiSensorEntityDescription, ...]:
@callback
def async_device_temperatures_value_fn(
temperature_name: str, hub: UnifiHub, device: Device
) -> float:
) -> float | None:
"""Retrieve the temperature of the device."""
return_value: float = 0
if device.temperatures:
temperature = _device_temperature(temperature_name, device.temperatures)
return_value = temperature if temperature is not None else 0
return return_value
return _device_temperature(temperature_name, device.temperatures)
return None
@callback
def async_device_temperatures_available_fn(
temperature_name: str, hub: UnifiHub, obj_id: str
) -> bool:
"""Determine if a device temperature has a value."""
device = hub.api.devices[obj_id]
if not async_device_available_fn(hub, obj_id):
return False
return _device_temperature(temperature_name, device.temperatures or []) is not None
@callback
@@ -315,7 +324,11 @@ def async_device_temperatures_supported_fn(
) -> bool:
"""Determine if an device have a temperatures."""
if (device := hub.api.devices[obj_id]) and device.temperatures:
return _device_temperature(temperature_name, device.temperatures) is not None
return any(
temperature_name in temperature["name"]
for temperature in device.temperatures
)
return False
@@ -326,7 +339,8 @@ def _device_temperature(
"""Return the temperature of the device."""
for temperature in temperatures:
if temperature_name in temperature["name"]:
return temperature["value"]
if (value := temperature.get("value")) is not None:
return value
return None
@@ -344,7 +358,7 @@ def make_device_temperatur_sensors() -> tuple[UnifiSensorEntityDescription, ...]
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_registry_enabled_default=False,
api_handler_fn=lambda api: api.devices,
available_fn=async_device_available_fn,
available_fn=partial(async_device_temperatures_available_fn, name),
device_info_fn=async_device_device_info_fn,
name_fn=lambda device: f"{device.name} {name} Temperature",
object_fn=lambda api, obj_id: api.devices[obj_id],

View File

@@ -88,7 +88,7 @@
"name": "Install update"
},
"skip": {
"description": "Marks currently available update as skipped.",
"description": "Marks a currently available update as skipped.",
"name": "Skip update"
}
},

View File

@@ -23,7 +23,7 @@
"universal_silabs_flasher",
"serialx"
],
"requirements": ["zha==1.0.2", "serialx==0.6.2"],
"requirements": ["zha==1.1.0", "serialx==0.6.2"],
"usb": [
{
"description": "*2652*",

View File

@@ -294,30 +294,51 @@
"ias_zone": {
"name": "IAS zone"
},
"interconnectable": {
"name": "Interconnectable"
},
"led_indicator": {
"name": "LED indicator"
},
"lifecycle": {
"name": "Lifecycle"
},
"linkage_alarm_state": {
"name": "Linkage alarm state"
},
"mounting_mode_active": {
"name": "Mounting mode active"
},
"muted": {
"name": "Muted"
},
"open_window_detection_status": {
"name": "Open window detection status"
},
"preheat": {
"name": "Preheat"
},
"preheat_active": {
"name": "Preheat active"
},
"preheat_status": {
"name": "Pre-heat status"
},
"rain_detected": {
"name": "Rain detected"
},
"replace_filter": {
"name": "Replace filter"
},
"self_test_state": {
"name": "Self-test"
},
"silence_alarm": {
"name": "Silence alarm"
},
"test": {
"name": "Test"
},
"valve_alarm": {
"name": "Valve alarm"
},
@@ -335,15 +356,27 @@
}
},
"button": {
"allow_remote_binding": {
"name": "Allow remote binding"
},
"boost_mode": {
"name": "Boost mode"
},
"calibrate_sensor": {
"name": "Calibrate sensor"
},
"calibrate_tvoc_sensor": {
"name": "Calibrate TVOC sensor"
},
"calibrate_valve": {
"name": "Calibrate valve"
},
"calibrate_z_axis": {
"name": "Calibrate Z axis"
},
"configure_limits": {
"name": "Configure cover limits"
},
"delete_all_limits": {
"name": "Delete all limits"
},
@@ -368,6 +401,9 @@
"prepare_manual_calibration": {
"name": "Prepare manual calibration"
},
"remote_test": {
"name": "Remote test"
},
"reset_alarm": {
"name": "Reset alarm"
},
@@ -461,6 +497,9 @@
"additional_steps": {
"name": "Additional steps"
},
"air_threshold": {
"name": "Air threshold"
},
"alarm_duration": {
"name": "Alarm duration"
},
@@ -677,6 +716,9 @@
"installation_height": {
"name": "Height from sensor to tank bottom"
},
"integral_factor": {
"name": "Integral factor"
},
"interval_time": {
"name": "Interval time"
},
@@ -707,6 +749,9 @@
"led_intensity_when_on": {
"name": "Default all LED on intensity"
},
"length": {
"name": "Length"
},
"lift_drive_down_time": {
"name": "Lift drive down time"
},
@@ -869,9 +914,18 @@
"presence_sensitivity": {
"name": "Presence sensitivity"
},
"presence_sensor_sensitivity": {
"name": "Presence sensor sensitivity"
},
"presence_timeout": {
"name": "Fade time"
},
"proportional_gain": {
"name": "Proportional gain (Kp)"
},
"proportional_shift": {
"name": "Proportional shift (N)"
},
"pulse_configuration": {
"name": "Pulse configuration"
},
@@ -911,6 +965,9 @@
"sensitivity_level": {
"name": "Sensitivity level"
},
"sensor_sensitivity": {
"name": "Sensor sensitivity"
},
"serving_size": {
"name": "Serving to dispense"
},
@@ -938,6 +995,9 @@
"sound_volume": {
"name": "Sound volume"
},
"speed": {
"name": "Speed"
},
"start_up_color_temperature": {
"name": "Start-up color temperature"
},
@@ -956,6 +1016,9 @@
"static_detection_sensitivity": {
"name": "Static detection sensitivity"
},
"summer_backup_heating_demand": {
"name": "Summer backup heating demand"
},
"sustain_time": {
"name": "Sustain time"
},
@@ -998,6 +1061,9 @@
"timer_time_left": {
"name": "Timer time left"
},
"total_cycle_times": {
"name": "Total cycle times"
},
"transmit_power": {
"name": "Transmit power"
},
@@ -1060,6 +1126,9 @@
},
"water_interval": {
"name": "Water interval"
},
"winter_backup_heating_demand": {
"name": "Winter backup heating demand"
}
},
"select": {
@@ -1087,6 +1156,15 @@
"approach_distance": {
"name": "Approach distance"
},
"audio": {
"name": "Audio"
},
"audio_effect": {
"name": "Audio effect"
},
"audio_sensitivity": {
"name": "Audio sensitivity"
},
"backlight_mode": {
"name": "Backlight mode"
},
@@ -1105,6 +1183,9 @@
"click_mode": {
"name": "Click mode"
},
"color_temperature_channel": {
"name": "Color temperature channel"
},
"control_type": {
"name": "Control type"
},
@@ -1126,6 +1207,9 @@
"default_strobe_level": {
"name": "Default strobe level"
},
"detach_relay": {
"name": "Detach relay"
},
"detection_distance": {
"name": "Detection distance"
},
@@ -1401,6 +1485,12 @@
"brightness_level": {
"name": "Brightness level"
},
"calibrated": {
"name": "Calibrated"
},
"chamber_contamination": {
"name": "Chamber contamination"
},
"control_status": {
"name": "Control status"
},
@@ -1464,6 +1554,9 @@
"formaldehyde": {
"name": "Formaldehyde concentration"
},
"gust_speed": {
"name": "Gust speed"
},
"heating_demand": {
"name": "Heating demand"
},
@@ -1532,6 +1625,9 @@
"last_pin_code": {
"name": "Last PIN code"
},
"last_remaining_battery_percentage": {
"name": "Last remaining battery percentage"
},
"last_valve_open_duration": {
"name": "Last valve open duration"
},
@@ -1544,6 +1640,9 @@
"lifetime": {
"name": "Lifetime"
},
"light_level": {
"name": "Light level"
},
"liquid_depth": {
"name": "Liquid depth"
},
@@ -1607,9 +1706,18 @@
"preheat_time": {
"name": "Pre-heat time"
},
"rebooted_count": {
"name": "Rebooted count"
},
"rejoined_count": {
"name": "Rejoined count"
},
"remaining_watering_time": {
"name": "Remaining watering time"
},
"reported_packages": {
"name": "Reported packages"
},
"rms_current_ph_b": {
"name": "Current phase B"
},
@@ -1646,6 +1754,9 @@
"smoke_density": {
"name": "Smoke density"
},
"smoke_level": {
"name": "Smoke level"
},
"software_error": {
"name": "Software error",
"state": {
@@ -1742,6 +1853,12 @@
"total_power_factor": {
"name": "Total power factor"
},
"total_volatile_organic_compounds": {
"name": "Total volatile organic compounds"
},
"uv_index": {
"name": "UV index"
},
"valve_adapt_status": {
"name": "Valve adaptation status"
},
@@ -1771,6 +1888,9 @@
},
"window_covering_type": {
"name": "Window covering type"
},
"work_mode": {
"name": "Work mode"
}
},
"switch": {
@@ -1780,6 +1900,9 @@
"adaptive_mode": {
"name": "Adaptive mode"
},
"alarm_switch": {
"name": "Alarm switch"
},
"auto_clean": {
"name": "Autoclean"
},
@@ -1810,6 +1933,9 @@
"detach_relay": {
"name": "Detach relay"
},
"detach_relay_id": {
"name": "Detach relay {id}"
},
"detached": {
"name": "Detached mode"
},
@@ -1822,6 +1948,9 @@
"disable_clear_notifications_double_tap": {
"name": "Disable config 2x tap to clear notifications"
},
"disable_double_click": {
"name": "Disable double click"
},
"disable_led": {
"name": "Disable LED"
},
@@ -1921,6 +2050,9 @@
"mute_siren": {
"name": "Mute siren"
},
"network_led": {
"name": "Network LED"
},
"on_only_when_dark": {
"name": "On only when dark"
},
@@ -1972,6 +2104,9 @@
"sound_enabled": {
"name": "Sound enabled"
},
"summer_mode": {
"name": "Summer mode"
},
"switch": {
"name": "[%key:component::switch::title%]"
},

View File

@@ -235,3 +235,12 @@ auth0-python<5.0
# Setuptools >=82.0.0 doesn't contain pkg_resources anymore
setuptools<82.0.0
# Pin dependencies with '.pth' files to exact versions, only update manually!
# https://github.com/Azure/azure-kusto-python/ -> '.pth' files removed with >=5.0.5
# https://github.com/xolox/python-coloredlogs -> unmaintained
# https://github.com/pypa/setuptools
azure-kusto-data==4.5.1
azure-kusto-ingest==4.5.1
coloredlogs==15.0.1
setuptools==81.0.0

6
requirements_all.txt generated
View File

@@ -1355,7 +1355,7 @@ ismartgate==5.0.2
israel-rail-api==0.1.4
# homeassistant.components.abode
jaraco.abode==6.2.1
jaraco.abode==6.4.0
# homeassistant.components.jellyfin
jellyfin-apiclient-python==1.11.0
@@ -3085,7 +3085,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
tesla-fleet-api==1.4.3
tesla-fleet-api==1.4.5
# homeassistant.components.powerwall
tesla-powerwall==0.5.2
@@ -3389,7 +3389,7 @@ zeroconf==0.148.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==1.0.2
zha==1.1.0
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13

View File

@@ -1201,7 +1201,7 @@ ismartgate==5.0.2
israel-rail-api==0.1.4
# homeassistant.components.abode
jaraco.abode==6.2.1
jaraco.abode==6.4.0
# homeassistant.components.jellyfin
jellyfin-apiclient-python==1.11.0
@@ -2603,7 +2603,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
tesla-fleet-api==1.4.3
tesla-fleet-api==1.4.5
# homeassistant.components.powerwall
tesla-powerwall==0.5.2
@@ -2865,7 +2865,7 @@ zeroconf==0.148.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==1.0.2
zha==1.1.0
# homeassistant.components.zinvolt
zinvolt==0.3.0

View File

@@ -225,6 +225,15 @@ auth0-python<5.0
# Setuptools >=82.0.0 doesn't contain pkg_resources anymore
setuptools<82.0.0
# Pin dependencies with '.pth' files to exact versions, only update manually!
# https://github.com/Azure/azure-kusto-python/ -> '.pth' files removed with >=5.0.5
# https://github.com/xolox/python-coloredlogs -> unmaintained
# https://github.com/pypa/setuptools
azure-kusto-data==4.5.1
azure-kusto-ingest==4.5.1
coloredlogs==15.0.1
setuptools==81.0.0
"""
GENERATED_MESSAGE = (

View File

@@ -95,6 +95,8 @@ FORBIDDEN_PACKAGES = {
"async-timeout": "be replaced by asyncio.timeout (Python 3.11+)",
# Only needed for tests
"codecov": "not be a runtime dependency",
# Coloredlogs is unmaintained and contains a '.pth' file
"coloredlogs": "be replaced with colorlog",
# Only needed for docs
"mkdocs": "not be a runtime dependency",
# Does blocking I/O and should be replaced by pyserial-asyncio-fast
@@ -149,11 +151,13 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
},
"flux_led": {"flux-led": {"async-timeout"}},
"foobot": {"foobot-async": {"async-timeout"}},
"google_maps": {"locationsharinglib": {"coloredlogs"}},
"harmony": {"aioharmony": {"async-timeout"}},
"here_travel_time": {
"here-routing": {"async-timeout"},
"here-transit": {"async-timeout"},
},
"homeassistant_hardware": {"universal-silabs-flasher": {"coloredlogs"}},
"homewizard": {"python-homewizard-energy": {"async-timeout"}},
"imeon_inverter": {"imeon-inverter-api": {"async-timeout"}},
"izone": {"python-izone": {"async-timeout"}},
@@ -217,6 +221,7 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
# https://github.com/waveform80/colorzero/issues/9
# zha > zigpy-zigate > gpiozero > colorzero > setuptools
"colorzero": {"setuptools"},
"zigpy-znp": {"coloredlogs"},
},
}
@@ -236,16 +241,42 @@ FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = {
# - reasonX should be the name of the invalid dependency
# https://github.com/jaraco/jaraco.net
"abode": {"jaraco-abode": {"jaraco-net"}},
# https://github.com/Azure/azure-kusto-python/
"azure_data_explorer": {
# Legacy namespace packages, resolved with >=5.0.5
# azure_kusto_data-*-nspkg.pth
# azure_kusto_ingest-*-nspkg.pth
"homeassistant": {"azure-kusto-data", "azure-kusto-ingest"},
"azure-kusto-ingest": {"azure-kusto-data"},
},
# https://github.com/coinbase/coinbase-advanced-py
"cmus": {
# Setuptools - distutils-precedence.pth
"pbr": {"setuptools"}
},
"coinbase": {"homeassistant": {"coinbase-advanced-py"}},
# https://github.com/u9n/dlms-cosem
"dsmr": {"dsmr-parser": {"dlms-cosem"}},
# https://github.com/ChrisMandich/PyFlume # Fixed with >=0.7.1
"fitbit": {
# Setuptools - distutils-precedence.pth
"fitbit": {"setuptools"}
},
"flume": {"homeassistant": {"pyflume"}},
# https://github.com/fortinet-solutions-cse/fortiosapi
"fortios": {"homeassistant": {"fortiosapi"}},
# https://github.com/manzanotti/geniushub-client
"geniushub": {"homeassistant": {"geniushub-client"}},
# https://github.com/costastf/locationsharinglib
"google_maps": {
# Coloredlogs, unmaintained - coloredlogs.pth
"locationsharinglib": {"coloredlogs"},
},
# https://github.com/NabuCasa/universal-silabs-flasher
"homeassistant_hardware": {
# Coloredlogs, unmaintained - coloredlogs.pth
"universal-silabs-flasher": {"coloredlogs"},
},
# https://github.com/basnijholt/aiokef
"kef": {"homeassistant": {"aiokef"}},
# https://github.com/danifus/pyzipper
@@ -257,11 +288,26 @@ FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = {
# https://github.com/timmo001/aiolyric
"lyric": {"homeassistant": {"aiolyric"}},
# https://github.com/microBeesTech/pythonSDK/
"microbees": {"homeassistant": {"microbeespy"}},
"microbees": {
"homeassistant": {"microbeespy"},
"microbeespy": {"setuptools"},
},
"mochad": {
# Setuptools - distutils-precedence.pth
"pbr": {"setuptools"}
},
# https://github.com/ejpenney/pyobihai
"obihai": {"homeassistant": {"pyobihai"}},
"opnsense": {
# Setuptools - distutils-precedence.pth
"pbr": {"setuptools"}
},
# https://github.com/iamkubi/pydactyl
"pterodactyl": {"homeassistant": {"py-dactyl"}},
"remote_rpi_gpio": {
# Setuptools - distutils-precedence.pth
"colorzero": {"setuptools"}
},
# https://github.com/sstallion/sensorpush-api
"sensorpush_cloud": {
"homeassistant": {"sensorpush-api"},
@@ -273,6 +319,14 @@ FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = {
"watergate": {"homeassistant": {"watergate-local-api"}},
# https://github.com/markusressel/xs1-api-client
"xs1": {"homeassistant": {"xs1-api-client"}},
# https://github.com/zigpy/zigpy-znp
"zha": {
# Setuptools - distutils-precedence.pth
"colorzero": {"setuptools"},
# Coloredlogs, unmaintained - coloredlogs.pth
# https://github.com/xolox/python-coloredlogs/blob/15.0.1/coloredlogs.pth
"zigpy-znp": {"coloredlogs"},
},
}
PYTHON_VERSION_CHECK_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
@@ -670,8 +724,10 @@ def check_dependency_files(
for file in files(pkg) or ():
if not (top := file.parts[0].lower()).endswith((".dist-info", ".py")):
top_level.add(top)
if (name := str(file)).lower() in FORBIDDEN_FILE_NAMES:
file_names.add(name)
if (name := str(file).lower()) in FORBIDDEN_FILE_NAMES or (
name.endswith(".pth") and len(file.parts) == 1
):
file_names.add(str(file))
results = _PackageFilesCheckResult(
top_level=FORBIDDEN_PACKAGE_NAMES & top_level,
file_names=file_names,
@@ -687,7 +743,8 @@ def check_dependency_files(
f"Package {pkg} has a forbidden top level directory '{dir_name}' in {package}",
)
for file_name in results["file_names"]:
integration.add_error(
integration.add_warning_or_error(
pkg in package_exceptions,
"requirements",
f"Package {pkg} has a forbidden file '{file_name}' in {package}",
)

View File

@@ -1,7 +1,11 @@
"""Tests for the Abode lock device."""
import json
from unittest.mock import patch
from jaraco.abode.helpers import urls as URL
from requests_mock import Mocker
from homeassistant.components.abode import ATTR_DEVICE_ID
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState
from homeassistant.const import (
@@ -15,6 +19,8 @@ from homeassistant.helpers import entity_registry as er
from .common import setup_platform
from tests.common import async_load_fixture
DEVICE_ID = "lock.test_lock"
@@ -63,3 +69,23 @@ async def test_unlock(hass: HomeAssistant) -> None:
)
await hass.async_block_till_done()
mock_unlock.assert_called_once()
async def test_retrofit_lock_discovered(
hass: HomeAssistant, requests_mock: Mocker
) -> None:
"""Test retrofit locks are discovered as lock entities."""
devices = json.loads(await async_load_fixture(hass, "devices.json", "abode"))
for device in devices:
if device["type_tag"] == "device_type.door_lock":
device["type_tag"] = "device_type.retrofit_lock"
device["type"] = "Retrofit Lock"
break
requests_mock.get(URL.DEVICES, text=json.dumps(devices))
await setup_platform(hass, LOCK_DOMAIN)
state = hass.states.get(DEVICE_ID)
assert state is not None
assert state.state == LockState.LOCKED

View File

@@ -1,577 +0,0 @@
"""Test air quality conditions."""
from typing import Any
import pytest
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONF_ABOVE,
CONF_BELOW,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
assert_numerical_condition_unit_conversion,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_numerical_condition_above_below_all,
parametrize_numerical_condition_above_below_any,
parametrize_target_entities,
target_entities,
)
_UGM3_CONDITION_OPTIONS = {"unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}
_UGM3_UNIT_ATTRIBUTES = {
ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
}
_PPB_CONDITION_OPTIONS = {"unit": CONCENTRATION_PARTS_PER_BILLION}
_PPB_UNIT_ATTRIBUTES = {ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION}
_PPM_UNIT_ATTRIBUTES = {ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION}
@pytest.fixture
async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple binary sensor entities associated with different targets."""
return await target_entities(hass, "binary_sensor")
@pytest.fixture
async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple sensor entities associated with different targets."""
return await target_entities(hass, "sensor")
@pytest.mark.parametrize(
"condition",
[
"air_quality.is_gas_detected",
"air_quality.is_gas_cleared",
"air_quality.is_co_detected",
"air_quality.is_co_cleared",
"air_quality.is_smoke_detected",
"air_quality.is_smoke_cleared",
"air_quality.is_co_value",
"air_quality.is_co2_value",
"air_quality.is_pm1_value",
"air_quality.is_pm25_value",
"air_quality.is_pm4_value",
"air_quality.is_pm10_value",
"air_quality.is_ozone_value",
"air_quality.is_voc_value",
"air_quality.is_voc_ratio_value",
"air_quality.is_no_value",
"air_quality.is_no2_value",
"air_quality.is_n2o_value",
"air_quality.is_so2_value",
],
)
async def test_air_quality_conditions_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
) -> None:
"""Test the air quality conditions are gated by the labs flag."""
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_condition_states_any(
condition="air_quality.is_gas_detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.GAS},
),
*parametrize_condition_states_any(
condition="air_quality.is_gas_cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.GAS},
),
*parametrize_condition_states_any(
condition="air_quality.is_co_detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CO},
),
*parametrize_condition_states_any(
condition="air_quality.is_co_cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CO},
),
*parametrize_condition_states_any(
condition="air_quality.is_smoke_detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
required_filter_attributes={
ATTR_DEVICE_CLASS: BinarySensorDeviceClass.SMOKE
},
),
*parametrize_condition_states_any(
condition="air_quality.is_smoke_cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
required_filter_attributes={
ATTR_DEVICE_CLASS: BinarySensorDeviceClass.SMOKE
},
),
],
)
async def test_air_quality_binary_condition_behavior_any(
hass: HomeAssistant,
target_binary_sensors: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the air quality binary sensor condition with 'any' behavior."""
await assert_condition_behavior_any(
hass,
target_entities=target_binary_sensors,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_condition_states_all(
condition="air_quality.is_gas_detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.GAS},
),
*parametrize_condition_states_all(
condition="air_quality.is_gas_cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.GAS},
),
*parametrize_condition_states_all(
condition="air_quality.is_co_detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CO},
),
*parametrize_condition_states_all(
condition="air_quality.is_co_cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
required_filter_attributes={ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CO},
),
*parametrize_condition_states_all(
condition="air_quality.is_smoke_detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
required_filter_attributes={
ATTR_DEVICE_CLASS: BinarySensorDeviceClass.SMOKE
},
),
*parametrize_condition_states_all(
condition="air_quality.is_smoke_cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
required_filter_attributes={
ATTR_DEVICE_CLASS: BinarySensorDeviceClass.SMOKE
},
),
],
)
async def test_air_quality_binary_condition_behavior_all(
hass: HomeAssistant,
target_binary_sensors: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the air quality binary sensor condition with 'all' behavior."""
await assert_condition_behavior_all(
hass,
target_entities=target_binary_sensors,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("sensor"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_numerical_condition_above_below_any(
"air_quality.is_co_value",
device_class="carbon_monoxide",
condition_options=_UGM3_CONDITION_OPTIONS,
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_any(
"air_quality.is_ozone_value",
device_class="ozone",
condition_options=_UGM3_CONDITION_OPTIONS,
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_any(
"air_quality.is_voc_value",
device_class="volatile_organic_compounds",
condition_options=_UGM3_CONDITION_OPTIONS,
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_any(
"air_quality.is_voc_ratio_value",
device_class="volatile_organic_compounds_parts",
condition_options=_PPB_CONDITION_OPTIONS,
unit_attributes=_PPB_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_any(
"air_quality.is_no_value",
device_class="nitrogen_monoxide",
condition_options=_UGM3_CONDITION_OPTIONS,
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_any(
"air_quality.is_no2_value",
device_class="nitrogen_dioxide",
condition_options=_UGM3_CONDITION_OPTIONS,
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_any(
"air_quality.is_so2_value",
device_class="sulphur_dioxide",
condition_options=_UGM3_CONDITION_OPTIONS,
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
],
)
async def test_air_quality_numerical_with_unit_condition_behavior_any(
hass: HomeAssistant,
target_sensors: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test air quality numerical conditions with unit conversion and 'any' behavior."""
await assert_condition_behavior_any(
hass,
target_entities=target_sensors,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("sensor"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_numerical_condition_above_below_all(
"air_quality.is_co_value",
device_class="carbon_monoxide",
condition_options=_UGM3_CONDITION_OPTIONS,
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_all(
"air_quality.is_ozone_value",
device_class="ozone",
condition_options=_UGM3_CONDITION_OPTIONS,
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_all(
"air_quality.is_voc_value",
device_class="volatile_organic_compounds",
condition_options=_UGM3_CONDITION_OPTIONS,
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_all(
"air_quality.is_voc_ratio_value",
device_class="volatile_organic_compounds_parts",
condition_options=_PPB_CONDITION_OPTIONS,
unit_attributes=_PPB_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_all(
"air_quality.is_no_value",
device_class="nitrogen_monoxide",
condition_options=_UGM3_CONDITION_OPTIONS,
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_all(
"air_quality.is_no2_value",
device_class="nitrogen_dioxide",
condition_options=_UGM3_CONDITION_OPTIONS,
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_all(
"air_quality.is_so2_value",
device_class="sulphur_dioxide",
condition_options=_UGM3_CONDITION_OPTIONS,
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
],
)
async def test_air_quality_numerical_with_unit_condition_behavior_all(
hass: HomeAssistant,
target_sensors: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test air quality numerical conditions with unit conversion and 'all' behavior."""
await assert_condition_behavior_all(
hass,
target_entities=target_sensors,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("sensor"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_numerical_condition_above_below_any(
"air_quality.is_co2_value",
device_class="carbon_dioxide",
unit_attributes=_PPM_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_any(
"air_quality.is_pm1_value",
device_class="pm1",
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_any(
"air_quality.is_pm25_value",
device_class="pm25",
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_any(
"air_quality.is_pm4_value",
device_class="pm4",
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_any(
"air_quality.is_pm10_value",
device_class="pm10",
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_any(
"air_quality.is_n2o_value",
device_class="nitrous_oxide",
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
],
)
async def test_air_quality_numerical_no_unit_condition_behavior_any(
hass: HomeAssistant,
target_sensors: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test air quality numerical conditions without unit conversion and 'any' behavior."""
await assert_condition_behavior_any(
hass,
target_entities=target_sensors,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("sensor"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_numerical_condition_above_below_all(
"air_quality.is_co2_value",
device_class="carbon_dioxide",
unit_attributes=_PPM_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_all(
"air_quality.is_pm1_value",
device_class="pm1",
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_all(
"air_quality.is_pm25_value",
device_class="pm25",
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_all(
"air_quality.is_pm4_value",
device_class="pm4",
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_all(
"air_quality.is_pm10_value",
device_class="pm10",
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
*parametrize_numerical_condition_above_below_all(
"air_quality.is_n2o_value",
device_class="nitrous_oxide",
unit_attributes=_UGM3_UNIT_ATTRIBUTES,
),
],
)
async def test_air_quality_numerical_no_unit_condition_behavior_all(
hass: HomeAssistant,
target_sensors: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test air quality numerical conditions without unit conversion and 'all' behavior."""
await assert_condition_behavior_all(
hass,
target_entities=target_sensors,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
async def test_air_quality_condition_unit_conversion_co(
hass: HomeAssistant,
) -> None:
"""Test that the CO condition converts units correctly."""
_unit_ugm3 = {ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}
_unit_ppm = {ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION}
_unit_invalid = {ATTR_UNIT_OF_MEASUREMENT: "not_a_valid_unit"}
await assert_numerical_condition_unit_conversion(
hass,
condition="air_quality.is_co_value",
entity_id="sensor.test",
pass_states=[
{
"state": "500",
"attributes": {
"device_class": "carbon_monoxide",
ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
}
],
fail_states=[
{
"state": "100",
"attributes": {
"device_class": "carbon_monoxide",
ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
}
],
numerical_condition_options=[
{
CONF_ABOVE: 0.2,
CONF_BELOW: 0.8,
"unit": CONCENTRATION_PARTS_PER_MILLION,
},
{
CONF_ABOVE: 200,
CONF_BELOW: 800,
"unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
],
limit_entity_condition_options={
CONF_ABOVE: "sensor.above",
CONF_BELOW: "sensor.below",
},
limit_entities=("sensor.above", "sensor.below"),
limit_entity_states=[
(
{"state": "0.2", "attributes": _unit_ppm},
{"state": "0.8", "attributes": _unit_ppm},
),
(
{"state": "200", "attributes": _unit_ugm3},
{"state": "800", "attributes": _unit_ugm3},
),
],
invalid_limit_entity_states=[
(
{"state": "0.2", "attributes": _unit_invalid},
{"state": "0.8", "attributes": _unit_invalid},
),
(
{"state": "200", "attributes": _unit_invalid},
{"state": "800", "attributes": _unit_invalid},
),
],
)

View File

@@ -1,18 +1 @@
"""Tests for the huum integration."""
from unittest.mock import patch
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_with_selected_platforms(
hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform]
) -> None:
"""Set up the Huum integration with the selected platforms."""
entry.add_to_hass(hass)
with patch("homeassistant.components.huum.PLATFORMS", platforms):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()

View File

@@ -1,93 +1,54 @@
"""Configuration for Huum tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, patch
from huum.const import SaunaStatus
from huum.schemas import HuumStatusResponse, SaunaConfig
import pytest
from homeassistant.components.huum.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.fixture
def mock_huum() -> Generator[AsyncMock]:
"""Mock data from the API."""
huum = AsyncMock()
def mock_huum_client() -> Generator[AsyncMock]:
"""Mock the Huum API client."""
with (
patch(
"homeassistant.components.huum.config_flow.Huum.status",
return_value=huum,
"homeassistant.components.huum.coordinator.Huum",
autospec=True,
) as mock_cls,
patch(
"homeassistant.components.huum.config_flow.Huum",
new=mock_cls,
),
patch(
"homeassistant.components.huum.coordinator.Huum.status",
return_value=huum,
),
patch(
"homeassistant.components.huum.coordinator.Huum.turn_on",
return_value=huum,
) as turn_on,
patch(
"homeassistant.components.huum.coordinator.Huum.toggle_light",
return_value=huum,
) as toggle_light,
):
huum.status = SaunaStatus.ONLINE_NOT_HEATING
huum.config = 3
huum.door_closed = True
huum.temperature = 30
huum.sauna_name = "Home sauna"
huum.target_temperature = 80
huum.payment_end_date = "2026-12-31"
huum.light = 1
huum.humidity = 0
huum.target_humidity = 5
huum.sauna_config.child_lock = "OFF"
huum.sauna_config.max_heating_time = 3
huum.sauna_config.min_heating_time = 0
huum.sauna_config.max_temp = 110
huum.sauna_config.min_temp = 40
huum.sauna_config.max_timer = 0
huum.sauna_config.min_timer = 0
def _to_dict() -> dict[str, object]:
return {
"status": huum.status,
"config": huum.config,
"door_closed": huum.door_closed,
"temperature": huum.temperature,
"sauna_name": huum.sauna_name,
"target_temperature": huum.target_temperature,
"start_date": None,
"end_date": None,
"duration": None,
"steamer_error": None,
"payment_end_date": huum.payment_end_date,
"is_private": None,
"show_modal": None,
"light": huum.light,
"humidity": huum.humidity,
"target_humidity": huum.target_humidity,
"remote_safety_state": None,
"sauna_config": {
"child_lock": huum.sauna_config.child_lock,
"max_heating_time": huum.sauna_config.max_heating_time,
"min_heating_time": huum.sauna_config.min_heating_time,
"max_temp": huum.sauna_config.max_temp,
"min_temp": huum.sauna_config.min_temp,
"max_timer": huum.sauna_config.max_timer,
"min_timer": huum.sauna_config.min_timer,
},
}
huum.to_dict = Mock(side_effect=_to_dict)
huum.turn_on = turn_on
huum.toggle_light = toggle_light
yield huum
client = mock_cls.return_value
client.status.return_value = HuumStatusResponse(
status=SaunaStatus.ONLINE_NOT_HEATING,
door_closed=True,
temperature=30,
sauna_name="123456",
target_temperature=80,
config=3,
light=1,
humidity=0,
target_humidity=5,
sauna_config=SaunaConfig(
child_lock="OFF",
max_heating_time=3,
min_heating_time=0,
max_temp=110,
min_temp=40,
max_timer=0,
min_timer=0,
),
)
yield client
@pytest.fixture
@@ -110,3 +71,32 @@ def mock_config_entry() -> MockConfigEntry:
},
entry_id="AABBCC112233",
)
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.LIGHT,
Platform.NUMBER,
Platform.SENSOR,
]
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_huum_client: AsyncMock,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the Huum integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.huum.PLATFORMS", platforms):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View File

@@ -13,7 +13,7 @@
'humidity': 0,
'is_private': None,
'light': 1,
'payment_end_date': '**REDACTED**',
'payment_end_date': None,
'remote_safety_state': None,
'sauna_config': dict({
'child_lock': 'OFF',

View File

@@ -1,29 +1,29 @@
"""Tests for the Huum climate entity."""
from unittest.mock import AsyncMock
"""Tests for the Huum binary sensor entity."""
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_with_selected_platforms
from tests.common import MockConfigEntry, snapshot_platform
ENTITY_ID = "binary_sensor.huum_sauna_door"
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.BINARY_SENSOR]
@pytest.mark.usefixtures("init_integration")
async def test_binary_sensor(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test the initial parameters."""
await setup_with_selected_platforms(
hass, mock_config_entry, [Platform.BINARY_SENSOR]
)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -1,8 +1,11 @@
"""Tests for the Huum climate entity."""
from datetime import timedelta
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
from huum.const import SaunaStatus
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.climate import (
@@ -20,34 +23,35 @@ from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_with_selected_platforms
from tests.common import MockConfigEntry, snapshot_platform
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
ENTITY_ID = "climate.huum_sauna"
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.CLIMATE]
@pytest.mark.usefixtures("init_integration")
async def test_climate_entity(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test the initial parameters."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("init_integration")
async def test_set_hvac_mode(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_huum_client: AsyncMock,
) -> None:
"""Test setting HVAC mode."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
mock_huum.status = SaunaStatus.ONLINE_HEATING
mock_huum_client.status.return_value.status = SaunaStatus.ONLINE_HEATING
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
@@ -58,18 +62,16 @@ async def test_set_hvac_mode(
state = hass.states.get(ENTITY_ID)
assert state.state == HVACMode.HEAT
mock_huum.turn_on.assert_called_once()
mock_huum_client.turn_on.assert_awaited_once()
@pytest.mark.usefixtures("init_integration")
async def test_set_temperature(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_huum_client: AsyncMock,
) -> None:
"""Test setting the temperature."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
mock_huum.status = SaunaStatus.ONLINE_HEATING
mock_huum_client.status.return_value.status = SaunaStatus.ONLINE_HEATING
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
@@ -80,39 +82,40 @@ async def test_set_temperature(
blocking=True,
)
mock_huum.turn_on.assert_called_once_with(60)
mock_huum_client.turn_on.assert_awaited_once_with(60)
@pytest.mark.usefixtures("init_integration")
async def test_temperature_range(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_huum_client: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the temperature range."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
# API response.
state = hass.states.get(ENTITY_ID)
assert state.attributes["min_temp"] == 40
assert state.attributes["max_temp"] == 110
# Empty/unconfigured API response should return default values.
mock_huum.sauna_config.min_temp = 0
mock_huum.sauna_config.max_temp = 0
mock_huum_client.status.return_value.sauna_config.min_temp = 0
mock_huum_client.status.return_value.sauna_config.max_temp = 0
await mock_config_entry.runtime_data.async_refresh()
await hass.async_block_till_done()
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(ENTITY_ID)
assert state.attributes["min_temp"] == CONFIG_DEFAULT_MIN_TEMP
assert state.attributes["max_temp"] == CONFIG_DEFAULT_MAX_TEMP
# Custom configured API response.
mock_huum.sauna_config.min_temp = 50
mock_huum.sauna_config.max_temp = 80
mock_huum_client.status.return_value.sauna_config.min_temp = 50
mock_huum_client.status.return_value.sauna_config.max_temp = 80
await mock_config_entry.runtime_data.async_refresh()
await hass.async_block_till_done()
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(ENTITY_ID)
assert state.attributes["min_temp"] == 50

View File

@@ -1,6 +1,6 @@
"""Test the huum config flow."""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock
from huum.exceptions import Forbidden
import pytest
@@ -17,9 +17,8 @@ TEST_USERNAME = "huum@sauna.org"
TEST_PASSWORD = "ukuuku"
async def test_form(
hass: HomeAssistant, mock_huum: AsyncMock, mock_setup_entry: AsyncMock
) -> None:
@pytest.mark.usefixtures("mock_huum_client")
async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
@@ -46,9 +45,9 @@ async def test_form(
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_huum_client")
async def test_signup_flow_already_set_up(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
@@ -82,7 +81,7 @@ async def test_signup_flow_already_set_up(
)
async def test_huum_errors(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_huum_client: AsyncMock,
mock_setup_entry: AsyncMock,
raises: Exception,
error_base: str,
@@ -92,21 +91,19 @@ async def test_huum_errors(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.huum.config_flow.Huum.status",
side_effect=raises,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
)
mock_huum_client.status.side_effect = raises
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error_base}
mock_huum_client.status.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
@@ -117,9 +114,9 @@ async def test_huum_errors(
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("mock_huum_client")
async def test_reauth_flow(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauthentication flow succeeds with valid credentials."""
@@ -155,7 +152,7 @@ async def test_reauth_flow(
)
async def test_reauth_errors(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_huum_client: AsyncMock,
mock_config_entry: MockConfigEntry,
raises: Exception,
error_base: str,
@@ -165,19 +162,17 @@ async def test_reauth_errors(
result = await mock_config_entry.start_reauth_flow(hass)
with patch(
"homeassistant.components.huum.config_flow.Huum.status",
side_effect=raises,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "wrong_password"},
)
mock_huum_client.status.side_effect = raises
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "wrong_password"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error_base}
# Recover with valid credentials
mock_huum_client.status.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "new_password"},

View File

@@ -2,30 +2,31 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from . import setup_with_selected_platforms
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.CLIMATE]
@pytest.mark.usefixtures("init_integration")
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_huum: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
diagnostics = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)

View File

@@ -1,6 +1,6 @@
"""Tests for the Huum __init__."""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock
from huum.exceptions import Forbidden, NotAuthenticated
import pytest
@@ -8,20 +8,16 @@ import pytest
from homeassistant import config_entries
from homeassistant.components.huum.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from . import setup_with_selected_platforms
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("init_integration")
async def test_loading_and_unloading_config_entry(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_huum: AsyncMock
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test loading and unloading a config entry."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert mock_config_entry.state is ConfigEntryState.LOADED
@@ -35,17 +31,15 @@ async def test_loading_and_unloading_config_entry(
async def test_auth_error_triggers_reauth(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_huum_client: AsyncMock,
side_effect: type[Exception],
) -> None:
"""Test that an auth error during coordinator refresh triggers reauth."""
mock_config_entry.add_to_hass(hass)
mock_huum_client.status.side_effect = side_effect
with patch(
"homeassistant.components.huum.coordinator.Huum.status",
side_effect=side_effect,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
assert any(

View File

@@ -1,7 +1,8 @@
"""Tests for the Huum light entity."""
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
@@ -16,33 +17,34 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_with_selected_platforms
from tests.common import MockConfigEntry, snapshot_platform
ENTITY_ID = "light.huum_sauna_light"
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.LIGHT]
@pytest.mark.usefixtures("init_integration")
async def test_light(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test the initial parameters."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("init_integration")
async def test_light_turn_off(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_huum_client: AsyncMock,
) -> None:
"""Test turning off light."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT])
state = hass.states.get(ENTITY_ID)
assert state.state == STATE_ON
@@ -52,18 +54,21 @@ async def test_light_turn_off(
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
mock_huum.toggle_light.assert_called_once()
mock_huum_client.toggle_light.assert_awaited_once()
async def test_light_turn_on(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_huum_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test turning on light."""
mock_huum.light = 0
mock_huum_client.status.return_value.light = 0
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT])
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.huum.PLATFORMS", [Platform.LIGHT]):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state.state == STATE_OFF
@@ -74,4 +79,4 @@ async def test_light_turn_on(
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
mock_huum.toggle_light.assert_called_once()
mock_huum_client.toggle_light.assert_awaited_once()

View File

@@ -3,6 +3,7 @@
from unittest.mock import AsyncMock
from huum.const import SaunaStatus
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.number import (
@@ -14,34 +15,35 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_with_selected_platforms
from tests.common import MockConfigEntry, snapshot_platform
ENTITY_ID = "number.huum_sauna_humidity"
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.NUMBER]
@pytest.mark.usefixtures("init_integration")
async def test_number_entity(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test the initial parameters."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("init_integration")
async def test_set_humidity(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_huum_client: AsyncMock,
) -> None:
"""Test setting the humidity."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER])
mock_huum.status = SaunaStatus.ONLINE_HEATING
mock_huum_client.status.return_value.status = SaunaStatus.ONLINE_HEATING
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
@@ -52,18 +54,16 @@ async def test_set_humidity(
blocking=True,
)
mock_huum.turn_on.assert_called_once_with(temperature=80, humidity=5)
mock_huum_client.turn_on.assert_awaited_once_with(temperature=80, humidity=5)
@pytest.mark.usefixtures("init_integration")
async def test_dont_set_humidity_when_sauna_not_heating(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_huum_client: AsyncMock,
) -> None:
"""Test setting the humidity."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER])
mock_huum.status = SaunaStatus.ONLINE_NOT_HEATING
mock_huum_client.status.return_value.status = SaunaStatus.ONLINE_NOT_HEATING
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
@@ -74,4 +74,4 @@ async def test_dont_set_humidity_when_sauna_not_heating(
blocking=True,
)
mock_huum.turn_on.assert_not_called()
mock_huum_client.turn_on.assert_not_called()

View File

@@ -1,25 +1,27 @@
"""Tests for the Huum sensor entity."""
from unittest.mock import AsyncMock
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_with_selected_platforms
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.SENSOR]
@pytest.mark.usefixtures("init_integration")
async def test_sensor(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test the temperature sensor."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -13,6 +13,7 @@ from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
InfraredEntity,
)
from homeassistant.components.lg_infrared import PLATFORMS
from homeassistant.components.lg_infrared.const import (
CONF_DEVICE_TYPE,
CONF_INFRARED_ENTITY_ID,
@@ -68,7 +69,7 @@ def mock_infrared_entity() -> MockInfraredEntity:
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return [Platform.MEDIA_PLAYER]
return PLATFORMS
@pytest.fixture

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
"""Tests for the LG Infrared button platform."""
from __future__ import annotations
from infrared_protocols.codes.lg.tv import LGTVCode
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .conftest import MockInfraredEntity
from .utils import check_availability_follows_ir_entity
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return [Platform.BUTTON]
@pytest.mark.usefixtures("init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test all button entities are created with correct attributes."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
# Verify all entities belong to the same device
device_entry = device_registry.async_get_device(
identifiers={("lg_infrared", mock_config_entry.entry_id)}
)
assert device_entry
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
for entity_entry in entity_entries:
assert entity_entry.device_id == device_entry.id
@pytest.mark.parametrize(
("entity_id", "expected_code"),
[
("button.lg_tv_power_on", LGTVCode.POWER_ON),
("button.lg_tv_power_off", LGTVCode.POWER_OFF),
("button.lg_tv_hdmi_1", LGTVCode.HDMI_1),
("button.lg_tv_hdmi_2", LGTVCode.HDMI_2),
("button.lg_tv_hdmi_3", LGTVCode.HDMI_3),
("button.lg_tv_hdmi_4", LGTVCode.HDMI_4),
("button.lg_tv_exit", LGTVCode.EXIT),
("button.lg_tv_info", LGTVCode.INFO),
("button.lg_tv_guide", LGTVCode.GUIDE),
("button.lg_tv_up", LGTVCode.NAV_UP),
("button.lg_tv_down", LGTVCode.NAV_DOWN),
("button.lg_tv_left", LGTVCode.NAV_LEFT),
("button.lg_tv_right", LGTVCode.NAV_RIGHT),
("button.lg_tv_ok", LGTVCode.OK),
("button.lg_tv_back", LGTVCode.BACK),
("button.lg_tv_home", LGTVCode.HOME),
("button.lg_tv_menu", LGTVCode.MENU),
("button.lg_tv_input", LGTVCode.INPUT),
("button.lg_tv_number_0", LGTVCode.NUM_0),
("button.lg_tv_number_1", LGTVCode.NUM_1),
("button.lg_tv_number_2", LGTVCode.NUM_2),
("button.lg_tv_number_3", LGTVCode.NUM_3),
("button.lg_tv_number_4", LGTVCode.NUM_4),
("button.lg_tv_number_5", LGTVCode.NUM_5),
("button.lg_tv_number_6", LGTVCode.NUM_6),
("button.lg_tv_number_7", LGTVCode.NUM_7),
("button.lg_tv_number_8", LGTVCode.NUM_8),
("button.lg_tv_number_9", LGTVCode.NUM_9),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_button_press_sends_correct_code(
hass: HomeAssistant,
mock_infrared_entity: MockInfraredEntity,
entity_id: str,
expected_code: LGTVCode,
) -> None:
"""Test pressing a button sends the correct IR code."""
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert len(mock_infrared_entity.send_command_calls) == 1
assert mock_infrared_entity.send_command_calls[0] == expected_code
@pytest.mark.usefixtures("init_integration")
async def test_button_availability_follows_ir_entity(
hass: HomeAssistant,
) -> None:
"""Test button becomes unavailable when IR entity is unavailable."""
entity_id = "button.lg_tv_power_on"
await check_availability_follows_ir_entity(hass, entity_id)

View File

@@ -19,11 +19,12 @@ from homeassistant.components.media_player import (
SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_UP,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .conftest import MOCK_INFRARED_ENTITY_ID, MockInfraredEntity
from .conftest import MockInfraredEntity
from .utils import check_availability_follows_ir_entity
from tests.common import MockConfigEntry, snapshot_platform
@@ -99,23 +100,4 @@ async def test_media_player_availability_follows_ir_entity(
hass: HomeAssistant,
) -> None:
"""Test media player becomes unavailable when IR entity is unavailable."""
# Initially available
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state is not None
assert state.state != STATE_UNAVAILABLE
# Make IR entity unavailable
hass.states.async_set(MOCK_INFRARED_ENTITY_ID, STATE_UNAVAILABLE)
await hass.async_block_till_done()
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
# Restore IR entity
hass.states.async_set(MOCK_INFRARED_ENTITY_ID, "2026-01-01T00:00:00.000")
await hass.async_block_till_done()
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state is not None
assert state.state != STATE_UNAVAILABLE
await check_availability_follows_ir_entity(hass, MEDIA_PLAYER_ENTITY_ID)

View File

@@ -0,0 +1,33 @@
"""Tests for the LG Infrared integration."""
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from .conftest import MOCK_INFRARED_ENTITY_ID
async def check_availability_follows_ir_entity(
hass: HomeAssistant,
entity_id: str,
) -> None:
"""Check that entity becomes unavailable when IR entity is unavailable."""
# Initially available
state = hass.states.get(entity_id)
assert state is not None
assert state.state != STATE_UNAVAILABLE
# Make IR entity unavailable
hass.states.async_set(MOCK_INFRARED_ENTITY_ID, STATE_UNAVAILABLE)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
# Restore IR entity
hass.states.async_set(MOCK_INFRARED_ENTITY_ID, "2026-01-01T00:00:00.000")
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state != STATE_UNAVAILABLE

View File

@@ -15,6 +15,7 @@ from pyliebherrhomeapi import (
HydroBreezeMode,
IceMakerControl,
IceMakerMode,
PresentationLightControl,
TemperatureControl,
TemperatureUnit,
ToggleControl,
@@ -115,6 +116,12 @@ MOCK_DEVICE_STATE = DeviceState(
BioFreshPlusMode.MINUS_TWO_ZERO,
],
),
PresentationLightControl(
name="presentationlight",
type="PresentationLightControl",
value=3,
max=5,
),
],
)
@@ -175,6 +182,7 @@ def mock_liebherr_client() -> Generator[MagicMock]:
client.set_ice_maker = AsyncMock()
client.set_hydro_breeze = AsyncMock()
client.set_bio_fresh_plus = AsyncMock()
client.set_presentation_light = AsyncMock()
yield client

View File

@@ -87,6 +87,12 @@
'type': 'BioFreshPlusControl',
'zone_id': 1,
}),
dict({
'max': 5,
'name': 'presentationlight',
'type': 'PresentationLightControl',
'value': 3,
}),
]),
'device': dict({
'device_id': 'test_device_id',

View File

@@ -0,0 +1,61 @@
# serializer version: 1
# name: test_lights[light.test_fridge_presentation_light-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.test_fridge_presentation_light',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Presentation light',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Presentation light',
'platform': 'liebherr',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'presentation_light',
'unique_id': 'test_device_id_presentation_light',
'unit_of_measurement': None,
})
# ---
# name: test_lights[light.test_fridge_presentation_light-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'brightness': 153,
'color_mode': <ColorMode.BRIGHTNESS: 'brightness'>,
'friendly_name': 'Test Fridge Presentation light',
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
'supported_features': <LightEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'light.test_fridge_presentation_light',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -0,0 +1,267 @@
"""Test the Liebherr light platform."""
import copy
from datetime import timedelta
from typing import Any
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from pyliebherrhomeapi import Device, DeviceState, DeviceType, PresentationLightControl
from pyliebherrhomeapi.exceptions import LiebherrConnectionError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.liebherr.const import DOMAIN
from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
pytestmark = pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.LIGHT]
@pytest.mark.usefixtures("init_integration")
async def test_lights(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test all light entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("init_integration")
async def test_light_state(
hass: HomeAssistant,
) -> None:
"""Test light entity reports correct state."""
entity_id = "light.test_fridge_presentation_light"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON
# value=3, max=5 → brightness = ceil(3 * 255 / 5) = 153
assert state.attributes[ATTR_BRIGHTNESS] == 153
@pytest.mark.parametrize(
("service", "service_data", "expected_target"),
[
(SERVICE_TURN_ON, {}, 5),
(SERVICE_TURN_ON, {ATTR_BRIGHTNESS: 255}, 5),
(SERVICE_TURN_ON, {ATTR_BRIGHTNESS: 128}, 3),
(SERVICE_TURN_ON, {ATTR_BRIGHTNESS: 51}, 1),
(SERVICE_TURN_ON, {ATTR_BRIGHTNESS: 1}, 1),
(SERVICE_TURN_OFF, {}, 0),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_light_service_calls(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
service: str,
service_data: dict[str, Any],
expected_target: int,
) -> None:
"""Test light turn on/off service calls."""
entity_id = "light.test_fridge_presentation_light"
initial_call_count = mock_liebherr_client.get_device_state.call_count
await hass.services.async_call(
LIGHT_DOMAIN,
service,
{ATTR_ENTITY_ID: entity_id, **service_data},
blocking=True,
)
mock_liebherr_client.set_presentation_light.assert_called_once_with(
device_id="test_device_id",
target=expected_target,
)
# Verify coordinator refresh was triggered
assert mock_liebherr_client.get_device_state.call_count > initial_call_count
@pytest.mark.usefixtures("init_integration")
async def test_light_failure(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
) -> None:
"""Test light fails gracefully on connection error."""
entity_id = "light.test_fridge_presentation_light"
mock_liebherr_client.set_presentation_light.side_effect = LiebherrConnectionError(
"Connection failed"
)
with pytest.raises(
HomeAssistantError,
match="An error occurred while communicating with the device",
):
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
@pytest.mark.usefixtures("init_integration")
async def test_light_when_control_missing(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test light entity behavior when control is removed."""
entity_id = "light.test_fridge_presentation_light"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON
# Device stops reporting presentation light control
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: DeviceState(
device=MOCK_DEVICE, controls=[]
)
freezer.tick(timedelta(seconds=61))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
("value", "max_value", "expected_state", "expected_brightness"),
[
(0, 5, STATE_OFF, None),
(None, 5, STATE_UNKNOWN, None),
(1, 0, STATE_ON, None),
],
ids=["off", "null_value", "zero_max"],
)
@pytest.mark.usefixtures("init_integration")
async def test_light_state_updates(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
freezer: FrozenDateTimeFactory,
value: int | None,
max_value: int,
expected_state: str,
expected_brightness: int | None,
) -> None:
"""Test light entity state after coordinator update."""
entity_id = "light.test_fridge_presentation_light"
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: DeviceState(
device=MOCK_DEVICE,
controls=[
PresentationLightControl(
name="presentationlight",
type="PresentationLightControl",
value=value,
max=max_value,
),
],
)
freezer.tick(timedelta(seconds=61))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == expected_state
assert state.attributes.get(ATTR_BRIGHTNESS) == expected_brightness
async def test_no_light_entity_without_control(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
mock_config_entry: MockConfigEntry,
platforms: list[Platform],
) -> None:
"""Test no light entity created when device has no presentation light control."""
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: DeviceState(
device=MOCK_DEVICE, controls=[]
)
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.liebherr.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("light.test_fridge_presentation_light") is None
async def test_dynamic_device_discovery_light(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_liebherr_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test new devices with presentation light are automatically discovered."""
mock_config_entry.add_to_hass(hass)
with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", [Platform.LIGHT]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("light.test_fridge_presentation_light") is not None
assert hass.states.get("light.new_fridge_presentation_light") is None
new_device = Device(
device_id="new_device_id",
nickname="New Fridge",
device_type=DeviceType.FRIDGE,
device_name="K2601",
)
new_device_state = DeviceState(
device=new_device,
controls=[
PresentationLightControl(
name="presentationlight",
type="PresentationLightControl",
value=2,
max=5,
),
],
)
mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE, new_device]
mock_liebherr_client.get_device_state.side_effect = lambda device_id, **kw: (
copy.deepcopy(
new_device_state if device_id == "new_device_id" else MOCK_DEVICE_STATE
)
)
freezer.tick(timedelta(minutes=5, seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("light.new_fridge_presentation_light")
assert state is not None
assert state.state == STATE_ON

View File

@@ -41,6 +41,7 @@ def mock_linkplay_factory_bridge() -> Generator[AsyncMock]:
bridge.device = AsyncMock(spec=LinkPlayDevice)
bridge.device.uuid = UUID
bridge.device.name = NAME
bridge.device.manufacturer = "Linkplay"
conf_factory.return_value = bridge
yield conf_factory

View File

@@ -4,6 +4,7 @@ from ipaddress import ip_address
from unittest.mock import AsyncMock
from linkplay.exceptions import LinkPlayRequestException
from linkplay.manufacturers import MANUFACTURER_WIIM
import pytest
from homeassistant.components.linkplay.const import DOMAIN
@@ -179,6 +180,24 @@ async def test_zeroconf_flow_errors(
assert result["reason"] == "cannot_connect"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_zeroconf_flow_ignores_wiim_device(
hass: HomeAssistant,
mock_linkplay_factory_bridge: AsyncMock,
) -> None:
"""Test Zeroconf discovery is ignored for WiiM devices."""
mock_linkplay_factory_bridge.return_value.device.manufacturer = MANUFACTURER_WIIM
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "not_linkplay_device"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_errors(
hass: HomeAssistant,

View File

@@ -502,6 +502,56 @@
'state': 'unknown',
})
# ---
# name: test_all_button_entities[button.pve1_suspend_all-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.pve1_suspend_all',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Suspend all',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Suspend all',
'platform': 'proxmoxve',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'suspend_all',
'unique_id': '1234_node/pve1_suspend_all',
'unit_of_measurement': None,
})
# ---
# name: test_all_button_entities[button.pve1_suspend_all-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'pve1 Suspend all',
}),
'context': <ANY>,
'entity_id': 'button.pve1_suspend_all',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_button_entities[button.vm_db_hibernate-entry]
EntityRegistryEntrySnapshot({
'aliases': list([

View File

@@ -82,9 +82,10 @@ async def test_node_buttons(
[
("button.pve1_start_all", "startall"),
("button.pve1_stop_all", "stopall"),
("button.pve1_suspend_all", "suspendall"),
],
)
async def test_node_startall_stopall_buttons(
async def test_node_all_actions_buttons(
hass: HomeAssistant,
mock_proxmox_client: MagicMock,
mock_config_entry: MockConfigEntry,

View File

@@ -6,6 +6,7 @@ from urllib.parse import parse_qs, urlparse
import pytest
from tesla_fleet_api.exceptions import (
InvalidResponse,
LoginRequired,
PreconditionFailed,
TeslaFleetError,
)
@@ -87,6 +88,121 @@ def mock_private_key():
return private_key
@pytest.mark.usefixtures("current_request_with_host")
async def test_partner_login_auth_error(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
access_token: str,
mock_private_key,
) -> None:
"""Test partner login auth errors abort the flow cleanly."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT,
},
)
client = await hass_client_no_auth()
await client.get(f"/auth/external/callback?code=abcd&state={state}")
aioclient_mock.post(
TOKEN_URL,
json={
"refresh_token": "mock-refresh-token",
"access_token": access_token,
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi"
) as mock_api_class:
mock_api = AsyncMock()
mock_api.private_key = mock_private_key
mock_api.get_private_key = AsyncMock()
mock_api.partner_login = AsyncMock(side_effect=LoginRequired)
mock_api_class.return_value = mock_api
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "oauth_error"
@pytest.mark.usefixtures("current_request_with_host")
async def test_partner_login_partial_failure(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
access_token: str,
mock_private_key,
) -> None:
"""Test partner login succeeds when one region fails."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT,
},
)
client = await hass_client_no_auth()
await client.get(f"/auth/external/callback?code=abcd&state={state}")
aioclient_mock.post(
TOKEN_URL,
json={
"refresh_token": "mock-refresh-token",
"access_token": access_token,
"type": "Bearer",
"expires_in": 60,
},
)
public_key = "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122"
mock_api_na = AsyncMock()
mock_api_na.private_key = mock_private_key
mock_api_na.get_private_key = AsyncMock()
mock_api_na.partner_login = AsyncMock()
mock_api_na.public_uncompressed_point = public_key
mock_api_na.partner.register.return_value = {"response": {"public_key": public_key}}
mock_api_eu = AsyncMock()
mock_api_eu.private_key = mock_private_key
mock_api_eu.get_private_key = AsyncMock()
mock_api_eu.partner_login = AsyncMock(
side_effect=TeslaFleetError("EU partner login failed")
)
with patch(
"homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi",
side_effect=[mock_api_na, mock_api_eu],
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "domain_input"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_DOMAIN: "example.com"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "registration_complete"
@pytest.mark.usefixtures("current_request_with_host")
async def test_full_flow_with_domain_registration(
hass: HomeAssistant,

View File

@@ -24384,6 +24384,173 @@
'state': '100.0',
})
# ---
# name: test_platform_setup_and_discovery[sensor.wifi_smart_online_8_in_1_tester_conductivity-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.wifi_smart_online_8_in_1_tester_conductivity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Conductivity',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.CONDUCTIVITY: 'conductivity'>,
'original_icon': None,
'original_name': 'Conductivity',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'tuya.frmfrbds0jixxyaljbngdec_current',
'unit_of_measurement': <UnitOfConductivity.MICROSIEMENS_PER_CM: 'μS/cm'>,
})
# ---
# name: test_platform_setup_and_discovery[sensor.wifi_smart_online_8_in_1_tester_conductivity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'conductivity',
'friendly_name': 'WiFi smart online 8 in 1 tester Conductivity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfConductivity.MICROSIEMENS_PER_CM: 'μS/cm'>,
}),
'context': <ANY>,
'entity_id': 'sensor.wifi_smart_online_8_in_1_tester_conductivity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_platform_setup_and_discovery[sensor.wifi_smart_online_8_in_1_tester_oxydo_reduction_potential-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.wifi_smart_online_8_in_1_tester_oxydo_reduction_potential',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Oxydo reduction potential',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Oxydo reduction potential',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'oxydo_reduction_potential',
'unique_id': 'tuya.frmfrbds0jixxyaljbngdorp_current',
'unit_of_measurement': 'mV',
})
# ---
# name: test_platform_setup_and_discovery[sensor.wifi_smart_online_8_in_1_tester_oxydo_reduction_potential-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'WiFi smart online 8 in 1 tester Oxydo reduction potential',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'mV',
}),
'context': <ANY>,
'entity_id': 'sensor.wifi_smart_online_8_in_1_tester_oxydo_reduction_potential',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_platform_setup_and_discovery[sensor.wifi_smart_online_8_in_1_tester_ph-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.wifi_smart_online_8_in_1_tester_ph',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'pH',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.PH: 'ph'>,
'original_icon': None,
'original_name': 'pH',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'tuya.frmfrbds0jixxyaljbngdph_current',
'unit_of_measurement': '',
})
# ---
# name: test_platform_setup_and_discovery[sensor.wifi_smart_online_8_in_1_tester_ph-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'ph',
'friendly_name': 'WiFi smart online 8 in 1 tester pH',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '',
}),
'context': <ANY>,
'entity_id': 'sensor.wifi_smart_online_8_in_1_tester_ph',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_platform_setup_and_discovery[sensor.wifi_smart_online_8_in_1_tester_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([

View File

@@ -1774,6 +1774,75 @@ async def test_device_temperatures(
assert hass.states.get(entity_id).state == updated_state
@pytest.mark.parametrize(
"device_payload",
[
[
{
"board_rev": 3,
"device_id": "mock-id",
"has_fan": True,
"fan_level": 0,
"ip": "10.0.1.1",
"last_seen": 1562600145,
"mac": "00:00:00:00:01:01",
"model": "US16P150",
"name": "Device",
"next_interval": 20,
"overheating": True,
"state": 1,
"type": "usw",
"upgradable": True,
"uptime": 60,
"version": "4.0.42.10433",
"temperatures": [
{"name": "CPU", "type": "cpu", "value": 66.0},
],
}
]
],
)
@pytest.mark.usefixtures("config_entry_setup")
async def test_device_temperature_with_missing_value(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_websocket_message,
device_payload: list[dict[str, Any]],
) -> None:
"""Verify that device temperatures sensors are working as expected."""
entity_id = "sensor.device_device_cpu_temperature"
temperature_entity = entity_registry.async_get(entity_id)
assert temperature_entity.disabled_by == RegistryEntryDisabler.INTEGRATION
# Enable entity
entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None)
await hass.async_block_till_done()
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done()
# Verify sensor state
assert hass.states.get(entity_id).state == "66.0"
# Remove temperature value from payload
device = deepcopy(device_payload[0])
device["temperatures"][0].pop("value")
mock_websocket_message(message=MessageKey.DEVICE, data=device)
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# Send original payload again to verify sensor recovers
mock_websocket_message(message=MessageKey.DEVICE, data=device_payload[0])
assert hass.states.get(entity_id).state == "66.0"
@pytest.mark.parametrize(
"device_payload",
[

View File

@@ -375,10 +375,17 @@ async def test_firmware_update_success(
attrs[ATTR_LATEST_VERSION] == f"0x{fw_image.firmware.header.file_version:08x}"
)
ota_completed = False
async def endpoint_reply(cluster, sequence, data, **kwargs):
nonlocal ota_completed
if cluster == general.Ota.cluster_id:
_hdr, cmd = ota_cluster.deserialize(data)
if isinstance(cmd, general.Ota.ImageNotifyCommand):
if ota_completed:
# Post-OTA image_notify: ignore or don't respond
return
zha_device.device.device.packet_received(
make_packet(
zha_device.device.device,
@@ -394,6 +401,12 @@ async def test_firmware_update_success(
elif isinstance(
cmd, general.Ota.ClientCommandDefs.query_next_image_response.schema
):
# After a successful OTA, zigpy sends a post-OTA image_notify
# which triggers a query_next_image -> NO_IMAGE_AVAILABLE exchange
if cmd.status == foundation.Status.NO_IMAGE_AVAILABLE:
assert ota_completed
return
assert cmd.status == foundation.Status.SUCCESS
assert cmd.manufacturer_code == fw_image.firmware.header.manufacturer_id
assert cmd.image_type == fw_image.firmware.header.image_type
@@ -486,6 +499,8 @@ async def test_firmware_update_success(
assert cmd.current_time == 0
assert cmd.upgrade_time == 0
ota_completed = True
def read_new_fw_version(*args, **kwargs):
ota_cluster.update_attribute(
attrid=general.Ota.AttributeDefs.current_file_version.id,
@@ -590,6 +605,9 @@ async def test_firmware_update_raises(
elif isinstance(
cmd, general.Ota.ClientCommandDefs.query_next_image_response.schema
):
if cmd.status == foundation.Status.NO_IMAGE_AVAILABLE:
return
assert cmd.status == foundation.Status.SUCCESS
assert cmd.manufacturer_code == fw_image.firmware.header.manufacturer_id
assert cmd.image_type == fw_image.firmware.header.image_type

View File

@@ -275,6 +275,7 @@ def test_check_dependency_file_names(integration: Integration) -> None:
pkg_files = [
PackagePath("py.typed"),
PackagePath("my_package.py"),
PackagePath("some_script.Pth"),
PackagePath("my_package-1.0.0.dist-info/METADATA"),
]
with (
@@ -285,17 +286,23 @@ def test_check_dependency_file_names(integration: Integration) -> None:
):
assert not _packages_checked_files_cache
assert check_dependency_files(integration, package, pkg, ()) is False
assert _packages_checked_files_cache[pkg]["file_names"] == {"py.typed"}
assert len(integration.errors) == 1
assert _packages_checked_files_cache[pkg]["file_names"] == {
"py.typed",
"some_script.Pth",
}
assert len(integration.errors) == 2
assert f"Package {pkg} has a forbidden file 'py.typed' in {package}" in [
x.error for x in integration.errors
]
assert f"Package {pkg} has a forbidden file 'some_script.Pth' in {package}" in [
x.error for x in integration.errors
]
integration.errors.clear()
# Repeated call should use cache
assert check_dependency_files(integration, package, pkg, ()) is False
assert mock_files.call_count == 1
assert len(integration.errors) == 1
assert len(integration.errors) == 2
integration.errors.clear()
# All good