Compare commits

..

27 Commits

Author SHA1 Message Date
Robert Resch
384d85d747 Merge branch 'dev' into python-3.14.3 2026-03-16 22:52:22 +01:00
Robert Resch
75978d8837 Fix demo tests for Python 3.14.3 (#165724) 2026-03-16 22:52:04 +01:00
Robert Resch
a2da13a0b3 Fix kitchen_sink tests for Python 3.14.3 (#165730) 2026-03-16 22:45:36 +01:00
Robert Resch
ce081d7e71 Fix local_file tests for Python 3.14.3 (#165731) 2026-03-16 22:45:15 +01:00
Robert Resch
037e123e11 Fix media_player tests for Python 3.14.3 (#165732) 2026-03-16 22:44:52 +01:00
Robert Resch
592b7e5594 Fix wake_on_lan tests for Python 3.14.3 (#165733) 2026-03-16 22:44:23 +01:00
Cyril MARIN
a963eed3a7 Add bearer token as optional setting to Ollama (#165325)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-16 22:14:33 +01:00
Devin Slick
2042f2e2bd Add Lojack integration (#162047)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-16 22:09:10 +01:00
mettolen
3580fab26e Initialize quality scale for Huum integration (#164902) 2026-03-16 22:08:43 +01:00
Matt Zimmerman
1817522107 Clean up SmartTub integration and tests (#165517)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-16 22:06:23 +01:00
Matt Zimmerman
98a9ce3a64 Add quality scale file for SmartTub integration (#162376)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 21:48:09 +01:00
johanzander
163bfb0fdd Add SPH inverter support to Growatt Server integration (#165314)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 21:46:48 +01:00
Jeff Terrace
66f04c702c Update onvif parsers library to latest parsing multiple (#165571)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-16 21:40:37 +01:00
Khole
41c497c49e Hive: Fix bug in config flow for authentication and device registration (#165061) 2026-03-16 21:07:34 +01:00
Ludovic BOUÉ
c25a664365 Fix Matter firmware update detection when version strings are identical (#165509) 2026-03-16 21:07:03 +01:00
Raj Laud
3dec70abce Add AC charger sensor support to victron_ble (#165497)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 20:59:30 +01:00
Robert Resch
3c2f696a23 Improve type hints for pilight (#165719) 2026-03-16 20:55:04 +01:00
Nathan Spencer
54745dc1f2 Remove stale devices at setup in Whisker (#165721) 2026-03-16 20:54:02 +01:00
Raj Laud
e4345c72d9 Fix SmartLithium 8-cell support in victron_ble (#165496)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 20:49:43 +01:00
J. Diego Rodríguez Royo
7acb253ae2 Add bread baking and dough proving programs to Home Connect (#165717) 2026-03-16 20:47:20 +01:00
J. Diego Rodríguez Royo
812c63eeb7 Bump aiohomeconnect to 0.32.0 (#165716) 2026-03-16 20:46:22 +01:00
Erwin Douna
7f13731035 Start orphaned entries in normal mode only (#164815)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-16 20:45:33 +01:00
Christian Lackas
879178e8a2 Add light support for HmIP-MP3P (Combination Signalling Device) (#162825) 2026-03-16 20:43:36 +01:00
Brett Adams
4d8cedb061 Add dynamic device discovery for Teslemetry (#162143)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Erwin Douna <e.douna@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-16 20:31:05 +01:00
Christian Lackas
e9f0d8a550 vicare: Remove heating type config, defaulting to auto-detection (#165649) 2026-03-16 20:26:02 +01:00
Jan Čermák
d1d105a3a2 Sleep twice as suggested in PR 2026-03-13 16:29:19 +01:00
Jan Čermák
3102fad342 Bump base image to 2026.02.0 with Python 3.14.3, use 3.14.3 in CI
This also bumps libcec used in the base image to 7.1.1, full changelog:
* https://github.com/home-assistant/docker/releases/tag/2026.02.0

Python changelog:
* https://docs.python.org/release/3.14.3/whatsnew/changelog.html
2026-03-13 16:28:33 +01:00
107 changed files with 8616 additions and 512 deletions

View File

@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.01.0"
BASE_IMAGE_VERSION: "2026.02.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}

View File

@@ -1 +1 @@
3.14.2
3.14.3

2
CODEOWNERS generated
View File

@@ -974,6 +974,8 @@ build.json @home-assistant/supervisor
/tests/components/logbook/ @home-assistant/core
/homeassistant/components/logger/ @home-assistant/core
/tests/components/logger/ @home-assistant/core
/homeassistant/components/lojack/ @devinslick
/tests/components/lojack/ @devinslick
/homeassistant/components/london_underground/ @jpbede
/tests/components/london_underground/ @jpbede
/homeassistant/components/lookin/ @ANMalko @bdraco

View File

@@ -239,6 +239,9 @@ def _login_classic_api(
return login_response
V1_DEVICE_TYPES: dict[int, str] = {5: "sph", 7: "min"}
def get_device_list_v1(
api, config: Mapping[str, str]
) -> tuple[list[dict[str, str]], str]:
@@ -260,18 +263,17 @@ def get_device_list_v1(
f"API error during device list: {e.error_msg or str(e)} (Code: {e.error_code})"
) from e
devices = devices_dict.get("devices", [])
# Only MIN device (type = 7) support implemented in current V1 API
supported_devices = [
{
"deviceSn": device.get("device_sn", ""),
"deviceType": "min",
"deviceType": V1_DEVICE_TYPES[device.get("type")],
}
for device in devices
if device.get("type") == 7
if device.get("type") in V1_DEVICE_TYPES
]
for device in devices:
if device.get("type") != 7:
if device.get("type") not in V1_DEVICE_TYPES:
_LOGGER.warning(
"Device %s with type %s not supported in Open API V1, skipping",
device.get("device_sn", ""),
@@ -348,7 +350,7 @@ async def async_setup_entry(
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
)
for device in devices
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min"]
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min", "sph"]
}
# Perform the first refresh for the total coordinator

View File

@@ -167,6 +167,36 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
**storage_info_detail["storageDetailBean"],
**storage_energy_overview,
}
elif self.device_type == "sph":
try:
sph_detail = self.api.sph_detail(self.device_id)
sph_energy = self.api.sph_energy(self.device_id)
except growattServer.GrowattV1ApiError as err:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
) from err
raise UpdateFailed(f"Error fetching SPH device data: {err}") from err
combined = {**sph_detail, **sph_energy}
# Parse last update timestamp from sph_energy "time" field
time_str = sph_energy.get("time")
if time_str:
try:
parsed = datetime.datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S")
combined["lastdataupdate"] = parsed.replace(
tzinfo=dt_util.get_default_time_zone()
)
except ValueError, TypeError:
_LOGGER.debug(
"Could not parse SPH time field for %s: %r",
self.device_id,
time_str,
)
self.data = combined
_LOGGER.debug("sph_info for device %s: %r", self.device_id, self.data)
elif self.device_type == "mix":
mix_info = self.api.mix_info(self.device_id)
mix_totals = self.api.mix_totals(self.device_id, self.plant_id)
@@ -448,3 +478,123 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return "00:00"
else:
return f"{hour:02d}:{minute:02d}"
async def update_ac_charge_times(
self,
charge_power: int,
charge_stop_soc: int,
mains_enabled: bool,
periods: list[dict],
) -> None:
"""Update AC charge time periods for SPH device.
Args:
charge_power: Charge power limit (0-100 %)
charge_stop_soc: Stop charging at this SOC level (0-100 %)
mains_enabled: Whether AC (mains) charging is enabled
periods: List of up to 3 dicts with keys start_time, end_time, enabled
"""
if self.api_version != "v1":
raise ServiceValidationError(
"Updating AC charge times requires token authentication"
)
try:
await self.hass.async_add_executor_job(
self.api.sph_write_ac_charge_times,
self.device_id,
charge_power,
charge_stop_soc,
mains_enabled,
periods,
)
except growattServer.GrowattV1ApiError as err:
raise HomeAssistantError(
f"API error updating AC charge times: {err}"
) from err
if self.data:
self.data["chargePowerCommand"] = charge_power
self.data["wchargeSOCLowLimit"] = charge_stop_soc
self.data["acChargeEnable"] = 1 if mains_enabled else 0
for i, period in enumerate(periods, 1):
self.data[f"forcedChargeTimeStart{i}"] = period["start_time"].strftime(
"%H:%M"
)
self.data[f"forcedChargeTimeStop{i}"] = period["end_time"].strftime(
"%H:%M"
)
self.data[f"forcedChargeStopSwitch{i}"] = (
1 if period.get("enabled", False) else 0
)
self.async_set_updated_data(self.data)
async def update_ac_discharge_times(
self,
discharge_power: int,
discharge_stop_soc: int,
periods: list[dict],
) -> None:
"""Update AC discharge time periods for SPH device.
Args:
discharge_power: Discharge power limit (0-100 %)
discharge_stop_soc: Stop discharging at this SOC level (0-100 %)
periods: List of up to 3 dicts with keys start_time, end_time, enabled
"""
if self.api_version != "v1":
raise ServiceValidationError(
"Updating AC discharge times requires token authentication"
)
try:
await self.hass.async_add_executor_job(
self.api.sph_write_ac_discharge_times,
self.device_id,
discharge_power,
discharge_stop_soc,
periods,
)
except growattServer.GrowattV1ApiError as err:
raise HomeAssistantError(
f"API error updating AC discharge times: {err}"
) from err
if self.data:
self.data["disChargePowerCommand"] = discharge_power
self.data["wdisChargeSOCLowLimit"] = discharge_stop_soc
for i, period in enumerate(periods, 1):
self.data[f"forcedDischargeTimeStart{i}"] = period[
"start_time"
].strftime("%H:%M")
self.data[f"forcedDischargeTimeStop{i}"] = period["end_time"].strftime(
"%H:%M"
)
self.data[f"forcedDischargeStopSwitch{i}"] = (
1 if period.get("enabled", False) else 0
)
self.async_set_updated_data(self.data)
async def read_ac_charge_times(self) -> dict:
"""Read AC charge time settings from SPH device cache."""
if self.api_version != "v1":
raise ServiceValidationError(
"Reading AC charge times requires token authentication"
)
if not self.data:
await self.async_refresh()
return self.api.sph_read_ac_charge_times(settings_data=self.data)
async def read_ac_discharge_times(self) -> dict:
"""Read AC discharge time settings from SPH device cache."""
if self.api_version != "v1":
raise ServiceValidationError(
"Reading AC discharge times requires token authentication"
)
if not self.data:
await self.async_refresh()
return self.api.sph_read_ac_discharge_times(settings_data=self.data)

View File

@@ -1,10 +1,22 @@
{
"services": {
"read_ac_charge_times": {
"service": "mdi:battery-clock-outline"
},
"read_ac_discharge_times": {
"service": "mdi:battery-clock-outline"
},
"read_time_segments": {
"service": "mdi:clock-outline"
},
"update_time_segment": {
"service": "mdi:clock-edit"
},
"write_ac_charge_times": {
"service": "mdi:battery-clock"
},
"write_ac_discharge_times": {
"service": "mdi:battery-clock"
}
}
}

View File

@@ -15,6 +15,7 @@ from ..coordinator import GrowattConfigEntry, GrowattCoordinator
from .inverter import INVERTER_SENSOR_TYPES
from .mix import MIX_SENSOR_TYPES
from .sensor_entity_description import GrowattSensorEntityDescription
from .sph import SPH_SENSOR_TYPES
from .storage import STORAGE_SENSOR_TYPES
from .tlx import TLX_SENSOR_TYPES
from .total import TOTAL_SENSOR_TYPES
@@ -57,6 +58,8 @@ async def async_setup_entry(
sensor_descriptions = list(STORAGE_SENSOR_TYPES)
elif device_coordinator.device_type == "mix":
sensor_descriptions = list(MIX_SENSOR_TYPES)
elif device_coordinator.device_type == "sph":
sensor_descriptions = list(SPH_SENSOR_TYPES)
else:
_LOGGER.debug(
"Device type %s was found but is not supported right now",

View File

@@ -0,0 +1,291 @@
"""Growatt Sensor definitions for the SPH type."""
from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import (
PERCENTAGE,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
UnitOfTemperature,
)
from .sensor_entity_description import GrowattSensorEntityDescription
SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
# Values from 'sph_detail' API call
GrowattSensorEntityDescription(
key="mix_statement_of_charge",
translation_key="mix_statement_of_charge",
api_key="bmsSOC",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
),
GrowattSensorEntityDescription(
key="mix_battery_voltage",
translation_key="mix_battery_voltage",
api_key="vbat",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
),
GrowattSensorEntityDescription(
key="mix_pv1_voltage",
translation_key="mix_pv1_voltage",
api_key="vpv1",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
),
GrowattSensorEntityDescription(
key="mix_pv2_voltage",
translation_key="mix_pv2_voltage",
api_key="vpv2",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
),
GrowattSensorEntityDescription(
key="mix_grid_voltage",
translation_key="mix_grid_voltage",
api_key="vac1",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
),
GrowattSensorEntityDescription(
key="mix_battery_charge",
translation_key="mix_battery_charge",
api_key="pcharge1",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="mix_battery_discharge_w",
translation_key="mix_battery_discharge_w",
api_key="pdischarge1",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="mix_export_to_grid",
translation_key="mix_export_to_grid",
api_key="pacToGridTotal",
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="mix_import_from_grid",
translation_key="mix_import_from_grid",
api_key="pacToUserR",
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="sph_grid_frequency",
translation_key="sph_grid_frequency",
api_key="fac",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="sph_temperature_1",
translation_key="sph_temperature_1",
api_key="temp1",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="sph_temperature_2",
translation_key="sph_temperature_2",
api_key="temp2",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="sph_temperature_3",
translation_key="sph_temperature_3",
api_key="temp3",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="sph_temperature_4",
translation_key="sph_temperature_4",
api_key="temp4",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="sph_temperature_5",
translation_key="sph_temperature_5",
api_key="temp5",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
# Values from 'sph_energy' API call
GrowattSensorEntityDescription(
key="mix_wattage_pv_1",
translation_key="mix_wattage_pv_1",
api_key="ppv1",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="mix_wattage_pv_2",
translation_key="mix_wattage_pv_2",
api_key="ppv2",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="mix_wattage_pv_all",
translation_key="mix_wattage_pv_all",
api_key="ppv",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="mix_battery_charge_today",
translation_key="mix_battery_charge_today",
api_key="echarge1Today",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_battery_charge_lifetime",
translation_key="mix_battery_charge_lifetime",
api_key="echarge1Total",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
never_resets=True,
),
GrowattSensorEntityDescription(
key="mix_battery_discharge_today",
translation_key="mix_battery_discharge_today",
api_key="edischarge1Today",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_battery_discharge_lifetime",
translation_key="mix_battery_discharge_lifetime",
api_key="edischarge1Total",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
never_resets=True,
),
GrowattSensorEntityDescription(
key="mix_solar_generation_today",
translation_key="mix_solar_generation_today",
api_key="epvtoday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_solar_generation_lifetime",
translation_key="mix_solar_generation_lifetime",
api_key="epvTotal",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
never_resets=True,
),
GrowattSensorEntityDescription(
key="mix_system_production_today",
translation_key="mix_system_production_today",
api_key="esystemtoday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_self_consumption_today",
translation_key="mix_self_consumption_today",
api_key="eselfToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_import_from_grid_today",
translation_key="mix_import_from_grid_today",
api_key="etoUserToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_export_to_grid_today",
translation_key="mix_export_to_grid_today",
api_key="etoGridToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_export_to_grid_lifetime",
translation_key="mix_export_to_grid_lifetime",
api_key="etogridTotal",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
never_resets=True,
),
GrowattSensorEntityDescription(
key="mix_load_consumption_today",
translation_key="mix_load_consumption_today",
api_key="elocalLoadToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_load_consumption_lifetime",
translation_key="mix_load_consumption_lifetime",
api_key="elocalLoadTotal",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
never_resets=True,
),
GrowattSensorEntityDescription(
key="mix_load_consumption_battery_today",
translation_key="mix_load_consumption_battery_today",
api_key="echarge1",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_load_consumption_solar_today",
translation_key="mix_load_consumption_solar_today",
api_key="eChargeToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
# Synthetic timestamp from 'time' field in sph_energy response
GrowattSensorEntityDescription(
key="mix_last_update",
translation_key="mix_last_update",
api_key="lastdataupdate",
device_class=SensorDeviceClass.TIMESTAMP,
),
)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime
from datetime import datetime, time
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigEntryState
@@ -21,67 +21,77 @@ if TYPE_CHECKING:
from .coordinator import GrowattCoordinator
def _get_coordinators(
hass: HomeAssistant, device_type: str
) -> dict[str, GrowattCoordinator]:
"""Get all coordinators of a given device type with V1 API."""
coordinators: dict[str, GrowattCoordinator] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.state != ConfigEntryState.LOADED:
continue
for coord in entry.runtime_data.devices.values():
if coord.device_type == device_type and coord.api_version == "v1":
coordinators[coord.device_id] = coord
return coordinators
def _get_coordinator(
hass: HomeAssistant, device_id: str, device_type: str
) -> GrowattCoordinator:
"""Get coordinator by device registry ID and device type."""
coordinators = _get_coordinators(hass, device_type)
if not coordinators:
raise ServiceValidationError(
f"No {device_type.upper()} devices with token authentication are configured. "
f"Services require {device_type.upper()} devices with V1 API access."
)
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id)
if not device_entry:
raise ServiceValidationError(f"Device '{device_id}' not found")
serial_number = None
for identifier in device_entry.identifiers:
if identifier[0] == DOMAIN:
serial_number = identifier[1]
break
if not serial_number:
raise ServiceValidationError(f"Device '{device_id}' is not a Growatt device")
if serial_number not in coordinators:
raise ServiceValidationError(
f"{device_type.upper()} device '{serial_number}' not found or not configured for services"
)
return coordinators[serial_number]
def _parse_time_str(time_str: str, field_name: str) -> time:
"""Parse a time string (HH:MM or HH:MM:SS) to a datetime.time object."""
parts = time_str.split(":")
if len(parts) not in (2, 3):
raise ServiceValidationError(
f"{field_name} must be in HH:MM or HH:MM:SS format"
)
try:
return datetime.strptime(f"{parts[0]}:{parts[1]}", "%H:%M").time()
except (ValueError, IndexError) as err:
raise ServiceValidationError(
f"{field_name} must be in HH:MM or HH:MM:SS format"
) from err
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services for Growatt Server integration."""
def get_min_coordinators() -> dict[str, GrowattCoordinator]:
"""Get all MIN coordinators with V1 API from loaded config entries."""
min_coordinators: dict[str, GrowattCoordinator] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.state != ConfigEntryState.LOADED:
continue
# Add MIN coordinators from this entry
for coord in entry.runtime_data.devices.values():
if coord.device_type == "min" and coord.api_version == "v1":
min_coordinators[coord.device_id] = coord
return min_coordinators
def get_coordinator(device_id: str) -> GrowattCoordinator:
"""Get coordinator by device_id.
Args:
device_id: Device registry ID (not serial number)
"""
# Get current coordinators (they may have changed since service registration)
min_coordinators = get_min_coordinators()
if not min_coordinators:
raise ServiceValidationError(
"No MIN devices with token authentication are configured. "
"Services require MIN devices with V1 API access."
)
# Device registry ID provided - map to serial number
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id)
if not device_entry:
raise ServiceValidationError(f"Device '{device_id}' not found")
# Extract serial number from device identifiers
serial_number = None
for identifier in device_entry.identifiers:
if identifier[0] == DOMAIN:
serial_number = identifier[1]
break
if not serial_number:
raise ServiceValidationError(
f"Device '{device_id}' is not a Growatt device"
)
# Find coordinator by serial number
if serial_number not in min_coordinators:
raise ServiceValidationError(
f"MIN device '{serial_number}' not found or not configured for services"
)
return min_coordinators[serial_number]
async def handle_update_time_segment(call: ServiceCall) -> None:
"""Handle update_time_segment service call."""
segment_id: int = int(call.data["segment_id"])
@@ -91,13 +101,11 @@ def async_setup_services(hass: HomeAssistant) -> None:
enabled: bool = call.data["enabled"]
device_id: str = call.data["device_id"]
# Validate segment_id range
if not 1 <= segment_id <= 9:
raise ServiceValidationError(
f"segment_id must be between 1 and 9, got {segment_id}"
)
# Validate and convert batt_mode string to integer
valid_modes = {
"load_first": BATT_MODE_LOAD_FIRST,
"battery_first": BATT_MODE_BATTERY_FIRST,
@@ -109,50 +117,121 @@ def async_setup_services(hass: HomeAssistant) -> None:
)
batt_mode: int = valid_modes[batt_mode_str]
# Convert time strings to datetime.time objects
# UI time selector sends HH:MM:SS, but we only need HH:MM (strip seconds)
try:
# Take only HH:MM part (ignore seconds if present)
start_parts = start_time_str.split(":")
start_time_hhmm = f"{start_parts[0]}:{start_parts[1]}"
start_time = datetime.strptime(start_time_hhmm, "%H:%M").time()
except (ValueError, IndexError) as err:
raise ServiceValidationError(
"start_time must be in HH:MM or HH:MM:SS format"
) from err
try:
# Take only HH:MM part (ignore seconds if present)
end_parts = end_time_str.split(":")
end_time_hhmm = f"{end_parts[0]}:{end_parts[1]}"
end_time = datetime.strptime(end_time_hhmm, "%H:%M").time()
except (ValueError, IndexError) as err:
raise ServiceValidationError(
"end_time must be in HH:MM or HH:MM:SS format"
) from err
# Get the appropriate MIN coordinator
coordinator: GrowattCoordinator = get_coordinator(device_id)
start_time = _parse_time_str(start_time_str, "start_time")
end_time = _parse_time_str(end_time_str, "end_time")
coordinator: GrowattCoordinator = _get_coordinator(hass, device_id, "min")
await coordinator.update_time_segment(
segment_id,
batt_mode,
start_time,
end_time,
enabled,
segment_id, batt_mode, start_time, end_time, enabled
)
async def handle_read_time_segments(call: ServiceCall) -> dict[str, Any]:
"""Handle read_time_segments service call."""
device_id: str = call.data["device_id"]
# Get the appropriate MIN coordinator
coordinator: GrowattCoordinator = get_coordinator(device_id)
coordinator: GrowattCoordinator = _get_coordinator(
hass, call.data["device_id"], "min"
)
time_segments: list[dict[str, Any]] = await coordinator.read_time_segments()
return {"time_segments": time_segments}
async def handle_write_ac_charge_times(call: ServiceCall) -> None:
"""Handle write_ac_charge_times service call for SPH devices."""
coordinator: GrowattCoordinator = _get_coordinator(
hass, call.data["device_id"], "sph"
)
# Read current settings first — the SPH API requires all 3 periods in
# every write call. Any period not supplied by the caller is filled in
# from the cache so existing settings are not overwritten with zeros.
current = await coordinator.read_ac_charge_times()
charge_power: int = int(call.data.get("charge_power", current["charge_power"]))
charge_stop_soc: int = int(
call.data.get("charge_stop_soc", current["charge_stop_soc"])
)
mains_enabled: bool = call.data.get("mains_enabled", current["mains_enabled"])
if not 0 <= charge_power <= 100:
raise ServiceValidationError(
f"charge_power must be between 0 and 100, got {charge_power}"
)
if not 0 <= charge_stop_soc <= 100:
raise ServiceValidationError(
f"charge_stop_soc must be between 0 and 100, got {charge_stop_soc}"
)
periods = []
for i in range(1, 4):
cached = current["periods"][i - 1]
start = _parse_time_str(
call.data.get(f"period_{i}_start", cached["start_time"]),
f"period_{i}_start",
)
end = _parse_time_str(
call.data.get(f"period_{i}_end", cached["end_time"]),
f"period_{i}_end",
)
enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"])
periods.append({"start_time": start, "end_time": end, "enabled": enabled})
await coordinator.update_ac_charge_times(
charge_power, charge_stop_soc, mains_enabled, periods
)
async def handle_write_ac_discharge_times(call: ServiceCall) -> None:
"""Handle write_ac_discharge_times service call for SPH devices."""
coordinator: GrowattCoordinator = _get_coordinator(
hass, call.data["device_id"], "sph"
)
# Read current settings first — same read-merge-write pattern as charge.
current = await coordinator.read_ac_discharge_times()
discharge_power: int = int(
call.data.get("discharge_power", current["discharge_power"])
)
discharge_stop_soc: int = int(
call.data.get("discharge_stop_soc", current["discharge_stop_soc"])
)
if not 0 <= discharge_power <= 100:
raise ServiceValidationError(
f"discharge_power must be between 0 and 100, got {discharge_power}"
)
if not 0 <= discharge_stop_soc <= 100:
raise ServiceValidationError(
f"discharge_stop_soc must be between 0 and 100, got {discharge_stop_soc}"
)
periods = []
for i in range(1, 4):
cached = current["periods"][i - 1]
start = _parse_time_str(
call.data.get(f"period_{i}_start", cached["start_time"]),
f"period_{i}_start",
)
end = _parse_time_str(
call.data.get(f"period_{i}_end", cached["end_time"]),
f"period_{i}_end",
)
enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"])
periods.append({"start_time": start, "end_time": end, "enabled": enabled})
await coordinator.update_ac_discharge_times(
discharge_power, discharge_stop_soc, periods
)
async def handle_read_ac_charge_times(call: ServiceCall) -> dict[str, Any]:
"""Handle read_ac_charge_times service call for SPH devices."""
coordinator: GrowattCoordinator = _get_coordinator(
hass, call.data["device_id"], "sph"
)
return await coordinator.read_ac_charge_times()
async def handle_read_ac_discharge_times(call: ServiceCall) -> dict[str, Any]:
"""Handle read_ac_discharge_times service call for SPH devices."""
coordinator: GrowattCoordinator = _get_coordinator(
hass, call.data["device_id"], "sph"
)
return await coordinator.read_ac_discharge_times()
# Register services without schema - services.yaml will provide UI definition
# Schema validation happens in the handler functions
hass.services.async_register(
@@ -168,3 +247,31 @@ def async_setup_services(hass: HomeAssistant) -> None:
handle_read_time_segments,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"write_ac_charge_times",
handle_write_ac_charge_times,
supports_response=SupportsResponse.NONE,
)
hass.services.async_register(
DOMAIN,
"write_ac_discharge_times",
handle_write_ac_discharge_times,
supports_response=SupportsResponse.NONE,
)
hass.services.async_register(
DOMAIN,
"read_ac_charge_times",
handle_read_ac_charge_times,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"read_ac_discharge_times",
handle_read_ac_discharge_times,
supports_response=SupportsResponse.ONLY,
)

View File

@@ -48,3 +48,162 @@ read_time_segments:
selector:
device:
integration: growatt_server
write_ac_charge_times:
fields:
device_id:
required: true
selector:
device:
integration: growatt_server
charge_power:
required: false
example: 100
selector:
number:
min: 0
max: 100
mode: slider
charge_stop_soc:
required: false
example: 100
selector:
number:
min: 0
max: 100
mode: slider
mains_enabled:
required: false
example: true
selector:
boolean:
period_1_start:
required: false
example: "00:00"
selector:
time:
period_1_end:
required: false
example: "00:00"
selector:
time:
period_1_enabled:
required: false
example: false
selector:
boolean:
period_2_start:
required: false
example: "00:00"
selector:
time:
period_2_end:
required: false
example: "00:00"
selector:
time:
period_2_enabled:
required: false
example: false
selector:
boolean:
period_3_start:
required: false
example: "00:00"
selector:
time:
period_3_end:
required: false
example: "00:00"
selector:
time:
period_3_enabled:
required: false
example: false
selector:
boolean:
write_ac_discharge_times:
fields:
device_id:
required: true
selector:
device:
integration: growatt_server
discharge_power:
required: false
example: 100
selector:
number:
min: 0
max: 100
mode: slider
discharge_stop_soc:
required: false
example: 20
selector:
number:
min: 0
max: 100
mode: slider
period_1_start:
required: false
example: "00:00"
selector:
time:
period_1_end:
required: false
example: "00:00"
selector:
time:
period_1_enabled:
required: false
example: false
selector:
boolean:
period_2_start:
required: false
example: "00:00"
selector:
time:
period_2_end:
required: false
example: "00:00"
selector:
time:
period_2_enabled:
required: false
example: false
selector:
boolean:
period_3_start:
required: false
example: "00:00"
selector:
time:
period_3_end:
required: false
example: "00:00"
selector:
time:
period_3_enabled:
required: false
example: false
selector:
boolean:
read_ac_charge_times:
fields:
device_id:
required: true
selector:
device:
integration: growatt_server
read_ac_discharge_times:
fields:
device_id:
required: true
selector:
device:
integration: growatt_server

View File

@@ -58,14 +58,14 @@
"region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]",
"token": "The API token for your Growatt account. You can generate one via the Growatt web portal or ShinePhone app."
},
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"description": "Token authentication is only supported for MIN/SPH devices. For other device types, please use username/password authentication.",
"title": "Enter your API token"
},
"user": {
"description": "Note: Token authentication is currently only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"description": "Note: Token authentication is currently only supported for MIN/SPH devices. For other device types, please use username/password authentication.",
"menu_options": {
"password_auth": "Username/password",
"token_auth": "API token (MIN/TLX only)"
"token_auth": "API token (MIN/SPH only)"
},
"title": "Choose authentication method"
}
@@ -243,6 +243,24 @@
"mix_wattage_pv_all": {
"name": "All PV wattage"
},
"sph_grid_frequency": {
"name": "AC frequency"
},
"sph_temperature_1": {
"name": "Temperature 1"
},
"sph_temperature_2": {
"name": "Temperature 2"
},
"sph_temperature_3": {
"name": "Temperature 3"
},
"sph_temperature_4": {
"name": "Temperature 4"
},
"sph_temperature_5": {
"name": "Temperature 5"
},
"storage_ac_input_frequency_out": {
"name": "AC input frequency"
},
@@ -576,6 +594,26 @@
}
},
"services": {
"read_ac_charge_times": {
"description": "Read AC charge time periods from an SPH device.",
"fields": {
"device_id": {
"description": "The Growatt SPH device to read from.",
"name": "Device"
}
},
"name": "Read AC charge times"
},
"read_ac_discharge_times": {
"description": "Read AC discharge time periods from an SPH device.",
"fields": {
"device_id": {
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
}
},
"name": "Read AC discharge times"
},
"read_time_segments": {
"description": "Read all time segments from a supported inverter.",
"fields": {
@@ -615,6 +653,118 @@
}
},
"name": "Update time segment"
},
"write_ac_charge_times": {
"description": "Write AC charge time periods to an SPH device.",
"fields": {
"charge_power": {
"description": "Charge power limit (%).",
"name": "Charge power"
},
"charge_stop_soc": {
"description": "Stop charging at this state of charge (%).",
"name": "Charge stop SOC"
},
"device_id": {
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
},
"mains_enabled": {
"description": "Enable AC (mains) charging.",
"name": "Mains charging enabled"
},
"period_1_enabled": {
"description": "Enable time period 1.",
"name": "Period 1 enabled"
},
"period_1_end": {
"description": "End time for period 1 (HH:MM or HH:MM:SS).",
"name": "Period 1 end"
},
"period_1_start": {
"description": "Start time for period 1 (HH:MM or HH:MM:SS).",
"name": "Period 1 start"
},
"period_2_enabled": {
"description": "Enable time period 2.",
"name": "Period 2 enabled"
},
"period_2_end": {
"description": "End time for period 2 (HH:MM or HH:MM:SS).",
"name": "Period 2 end"
},
"period_2_start": {
"description": "Start time for period 2 (HH:MM or HH:MM:SS).",
"name": "Period 2 start"
},
"period_3_enabled": {
"description": "Enable time period 3.",
"name": "Period 3 enabled"
},
"period_3_end": {
"description": "End time for period 3 (HH:MM or HH:MM:SS).",
"name": "Period 3 end"
},
"period_3_start": {
"description": "Start time for period 3 (HH:MM or HH:MM:SS).",
"name": "Period 3 start"
}
},
"name": "Write AC charge times"
},
"write_ac_discharge_times": {
"description": "Write AC discharge time periods to an SPH device.",
"fields": {
"device_id": {
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
},
"discharge_power": {
"description": "Discharge power limit (%).",
"name": "Discharge power"
},
"discharge_stop_soc": {
"description": "Stop discharging at this state of charge (%).",
"name": "Discharge stop SOC"
},
"period_1_enabled": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_enabled::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_enabled::name%]"
},
"period_1_end": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_end::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_end::name%]"
},
"period_1_start": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_start::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_start::name%]"
},
"period_2_enabled": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_enabled::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_enabled::name%]"
},
"period_2_end": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_end::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_end::name%]"
},
"period_2_start": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_start::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_start::name%]"
},
"period_3_enabled": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_enabled::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_enabled::name%]"
},
"period_3_end": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_end::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_end::name%]"
},
"period_3_start": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_start::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_start::name%]"
}
},
"name": "Write AC discharge times"
}
},
"title": "Growatt Server"

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from apyhiveapi import Auth
@@ -26,6 +27,8 @@ from homeassistant.core import callback
from . import HiveConfigEntry
from .const import CONF_CODE, CONF_DEVICE_NAME, CONFIG_ENTRY_VERSION, DOMAIN
_LOGGER = logging.getLogger(__name__)
class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Hive config flow."""
@@ -36,7 +39,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the config flow."""
self.data: dict[str, Any] = {}
self.tokens: dict[str, str] = {}
self.tokens: dict[str, Any] = {}
self.device_registration: bool = False
self.device_name = "Home Assistant"
@@ -67,11 +70,22 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
except HiveApiError:
errors["base"] = "no_internet_available"
if (
auth_result := self.tokens.get("AuthenticationResult", {})
) and auth_result.get("NewDeviceMetadata"):
_LOGGER.debug("Login successful, New device detected")
self.device_registration = True
return await self.async_step_configuration()
if self.tokens.get("ChallengeName") == "SMS_MFA":
_LOGGER.debug("Login successful, SMS 2FA required")
# Complete SMS 2FA.
return await self.async_step_2fa()
if not errors:
_LOGGER.debug(
"Login successful, no new device detected, no 2FA required"
)
# Complete the entry.
try:
return await self.async_setup_hive_entry()
@@ -103,6 +117,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "no_internet_available"
if not errors:
_LOGGER.debug("2FA successful")
if self.source == SOURCE_REAUTH:
return await self.async_setup_hive_entry()
self.device_registration = True
@@ -119,10 +134,11 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input:
if self.device_registration:
_LOGGER.debug("Attempting to register device")
self.device_name = user_input["device_name"]
await self.hive_auth.device_registration(user_input["device_name"])
self.data["device_data"] = await self.hive_auth.get_device_data()
_LOGGER.debug("Device registration successful")
try:
return await self.async_setup_hive_entry()
except UnknownHiveError:
@@ -142,6 +158,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
raise UnknownHiveError
# Setup the config entry
_LOGGER.debug("Setting up Hive entry")
self.data["tokens"] = self.tokens
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
@@ -160,6 +177,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_USERNAME: entry_data[CONF_USERNAME],
CONF_PASSWORD: entry_data[CONF_PASSWORD],
}
_LOGGER.debug("Reauthenticating user")
return await self.async_step_user(data)
@staticmethod

View File

@@ -23,6 +23,6 @@
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.30.0"],
"requirements": ["aiohomeconnect==0.32.0"],
"zeroconf": ["_homeconnect._tcp.local."]
}

View File

@@ -127,6 +127,7 @@ set_program_and_options:
- cooking_oven_program_heating_mode_top_bottom_heating
- cooking_oven_program_heating_mode_top_bottom_heating_eco
- cooking_oven_program_heating_mode_bottom_heating
- cooking_oven_program_heating_mode_bread_baking
- cooking_oven_program_heating_mode_pizza_setting
- cooking_oven_program_heating_mode_slow_cook
- cooking_oven_program_heating_mode_intensive_heat
@@ -135,6 +136,7 @@ set_program_and_options:
- cooking_oven_program_heating_mode_frozen_heatup_special
- cooking_oven_program_heating_mode_desiccation
- cooking_oven_program_heating_mode_defrost
- cooking_oven_program_heating_mode_dough_proving
- cooking_oven_program_heating_mode_proof
- cooking_oven_program_heating_mode_hot_air_30_steam
- cooking_oven_program_heating_mode_hot_air_60_steam

View File

@@ -261,8 +261,10 @@
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
@@ -615,8 +617,10 @@
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
@@ -1618,8 +1622,10 @@
"cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
"cooking_common_program_hood_venting": "Venting",
"cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
"cooking_oven_program_heating_mode_bread_baking": "Bread baking",
"cooking_oven_program_heating_mode_defrost": "Defrost",
"cooking_oven_program_heating_mode_desiccation": "Desiccation",
"cooking_oven_program_heating_mode_dough_proving": "Dough proving",
"cooking_oven_program_heating_mode_frozen_heatup_special": "Special heat-up for frozen products",
"cooking_oven_program_heating_mode_hot_air": "Hot air",
"cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH",

View File

@@ -11,10 +11,14 @@ from homematicip.base.enums import (
OpticalSignalBehaviour,
RGBColorState,
)
from homematicip.base.functionalChannels import NotificationLightChannel
from homematicip.base.functionalChannels import (
NotificationLightChannel,
NotificationMp3SoundChannel,
)
from homematicip.device import (
BrandDimmer,
BrandSwitchNotificationLight,
CombinationSignallingDevice,
Device,
Dimmer,
DinRailDimmer3,
@@ -108,6 +112,8 @@ async def async_setup_entry(
entities.append(
HomematicipOpticalSignalLight(hap, device, ch.index, led_number)
)
elif isinstance(device, CombinationSignallingDevice):
entities.append(HomematicipCombinationSignallingLight(hap, device))
async_add_entities(entities)
@@ -586,3 +592,70 @@ class HomematicipOpticalSignalLight(HomematicipGenericEntity, LightEntity):
rgb=simple_rgb_color,
dimLevel=0.0,
)
class HomematicipCombinationSignallingLight(HomematicipGenericEntity, LightEntity):
"""Representation of the HomematicIP combination signalling device light (HmIP-MP3P)."""
_attr_color_mode = ColorMode.HS
_attr_supported_color_modes = {ColorMode.HS}
_color_switcher: dict[str, tuple[float, float]] = {
RGBColorState.WHITE: (0.0, 0.0),
RGBColorState.RED: (0.0, 100.0),
RGBColorState.YELLOW: (60.0, 100.0),
RGBColorState.GREEN: (120.0, 100.0),
RGBColorState.TURQUOISE: (180.0, 100.0),
RGBColorState.BLUE: (240.0, 100.0),
RGBColorState.PURPLE: (300.0, 100.0),
}
def __init__(
self, hap: HomematicipHAP, device: CombinationSignallingDevice
) -> None:
"""Initialize the combination signalling light entity."""
super().__init__(hap, device, channel=1, is_multi_channel=False)
@property
def _func_channel(self) -> NotificationMp3SoundChannel:
return self._device.functionalChannels[self._channel]
@property
def is_on(self) -> bool:
"""Return true if light is on."""
return self._func_channel.on
@property
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
return int((self._func_channel.dimLevel or 0.0) * 255)
@property
def hs_color(self) -> tuple[float, float]:
"""Return the hue and saturation color value [float, float]."""
simple_rgb_color = self._func_channel.simpleRGBColorState
return self._color_switcher.get(simple_rgb_color, (0.0, 0.0))
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color)
simple_rgb_color = _convert_color(hs_color)
brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness)
# Default to full brightness when no kwargs given
if not kwargs:
brightness = 255
# Minimum brightness is 10, otherwise the LED is disabled
brightness = max(10, brightness)
dim_level = brightness / 255.0
await self._func_channel.set_rgb_dim_level_async(
rgb_color_state=simple_rgb_color.name,
dim_level=dim_level,
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self._func_channel.turn_off_async()

View File

@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/huum",
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["huum==0.8.1"]
}

View File

@@ -0,0 +1,105 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage:
status: done
comment: |
PLANNED: Rename result2 -> result throughout test_config_flow.py.
config-flow:
status: done
comment: |
PLANNED: Move _async_abort_entries_match before the try block so duplicate
entries are rejected before credentials are validated. Remove _LOGGER.error
call from config_flow.py — the error message is redundant with the errors
dict entry.
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry:
status: done
comment: |
PLANNED: Move _async_abort_entries_match before the try block in
config_flow.py so duplicate entries are rejected before credentials are
validated.
# Silver
action-exceptions:
status: exempt
comment: Integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable:
status: done
comment: |
PLANNED: Remove _LOGGER.error from coordinator.py — the message is already
passed to UpdateFailed, so logging it separately is redundant.
parallel-updates: todo
reauthentication-flow: todo
test-coverage:
status: todo
comment: |
PLANNED: Remove unique_id from mock config entry in conftest.py. Use
freezer-based time advancement instead of directly calling async_refresh().
Rename result2 -> result in test files.
# Gold
devices: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Single device per account, no dynamic devices.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: All entities are core functionality.
entity-translations: done
exception-translations: todo
icon-translations:
status: done
comment: |
PLANNED: Remove the icon property from climate.py — entities should not set
custom icons. Use HA defaults or icon translations instead.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: Integration has no repair scenarios.
stale-devices:
status: exempt
comment: Single device per config entry.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -46,6 +46,16 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
self.account = Account(websession=async_get_clientsession(hass))
self.previous_members: set[str] = set()
# Initialize previous_members from the device registry so that
# stale devices can be detected on the first update after restart.
device_registry = dr.async_get(hass)
for device in dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
):
for domain, identifier in device.identifiers:
if domain == DOMAIN:
self.previous_members.add(identifier)
async def _async_update_data(self) -> None:
"""Update all device states from the Litter-Robot API."""
try:

View File

@@ -0,0 +1,78 @@
"""The LoJack integration for Home Assistant."""
from __future__ import annotations
from dataclasses import dataclass, field
from lojack_api import ApiError, AuthenticationError, LoJackClient, Vehicle
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import LoJackCoordinator
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
@dataclass
class LoJackData:
"""Runtime data for a LoJack config entry."""
client: LoJackClient
coordinators: list[LoJackCoordinator] = field(default_factory=list)
type LoJackConfigEntry = ConfigEntry[LoJackData]
async def async_setup_entry(hass: HomeAssistant, entry: LoJackConfigEntry) -> bool:
"""Set up LoJack from a config entry."""
session = async_get_clientsession(hass)
try:
client = await LoJackClient.create(
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
session=session,
)
except AuthenticationError as err:
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
except ApiError as err:
raise ConfigEntryNotReady(f"API error during setup: {err}") from err
try:
vehicles = await client.list_devices()
except AuthenticationError as err:
await client.close()
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
except ApiError as err:
await client.close()
raise ConfigEntryNotReady(f"API error during setup: {err}") from err
data = LoJackData(client=client)
entry.runtime_data = data
try:
for vehicle in vehicles or []:
if isinstance(vehicle, Vehicle):
coordinator = LoJackCoordinator(hass, client, entry, vehicle)
await coordinator.async_config_entry_first_refresh()
data.coordinators.append(coordinator)
except Exception:
await client.close()
raise
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: LoJackConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
await entry.runtime_data.client.close()
return unload_ok

View File

@@ -0,0 +1,111 @@
"""Config flow for LoJack integration."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from lojack_api import ApiError, AuthenticationError, LoJackClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class LoJackConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for LoJack."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
async with await LoJackClient.create(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
) as client:
user_id = client.user_id
except AuthenticationError:
errors["base"] = "invalid_auth"
except ApiError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if not user_id:
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"LoJack ({user_input[CONF_USERNAME]})",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthentication confirmation."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
try:
async with await LoJackClient.create(
reauth_entry.data[CONF_USERNAME],
user_input[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
):
pass
except AuthenticationError:
errors["base"] = "invalid_auth"
except ApiError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
errors=errors,
)

View File

@@ -0,0 +1,13 @@
"""Constants for the LoJack integration."""
from __future__ import annotations
import logging
from typing import Final
DOMAIN: Final = "lojack"
LOGGER = logging.getLogger(__package__)
# Default polling interval (in minutes)
DEFAULT_UPDATE_INTERVAL: Final = 5

View File

@@ -0,0 +1,68 @@
"""Data update coordinator for the LoJack integration."""
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from lojack_api import ApiError, AuthenticationError, LoJackClient
from lojack_api.device import Vehicle
from lojack_api.models import Location
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER
if TYPE_CHECKING:
from . import LoJackConfigEntry
def get_device_name(vehicle: Vehicle) -> str:
"""Get a human-readable name for a vehicle."""
parts = [
str(vehicle.year) if vehicle.year else None,
vehicle.make,
vehicle.model,
]
name = " ".join(p for p in parts if p)
return name or vehicle.name or "Vehicle"
class LoJackCoordinator(DataUpdateCoordinator[Location]):
"""Class to manage fetching LoJack data for a single vehicle."""
config_entry: LoJackConfigEntry
def __init__(
self,
hass: HomeAssistant,
client: LoJackClient,
entry: ConfigEntry,
vehicle: Vehicle,
) -> None:
"""Initialize the coordinator."""
self.client = client
self.vehicle = vehicle
super().__init__(
hass,
LOGGER,
name=f"{DOMAIN}_{vehicle.id}",
update_interval=timedelta(minutes=DEFAULT_UPDATE_INTERVAL),
config_entry=entry,
)
async def _async_update_data(self) -> Location:
"""Fetch location data for this vehicle."""
try:
location = await self.vehicle.get_location(force=True)
except AuthenticationError as err:
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
except ApiError as err:
raise UpdateFailed(f"Error fetching data: {err}") from err
if location is None:
raise UpdateFailed("No location data available")
return location

View File

@@ -0,0 +1,78 @@
"""Device tracker platform for LoJack integration."""
from __future__ import annotations
from homeassistant.components.device_tracker import SourceType, TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import LoJackConfigEntry
from .const import DOMAIN
from .coordinator import LoJackCoordinator, get_device_name
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: LoJackConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LoJack device tracker from a config entry."""
async_add_entities(
LoJackDeviceTracker(coordinator)
for coordinator in entry.runtime_data.coordinators
)
class LoJackDeviceTracker(CoordinatorEntity[LoJackCoordinator], TrackerEntity):
"""Representation of a LoJack device tracker."""
_attr_has_entity_name = True
_attr_name = None # Main entity of the device, uses device name directly
def __init__(self, coordinator: LoJackCoordinator) -> None:
"""Initialize the device tracker."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.vehicle.id
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
identifiers={(DOMAIN, self.coordinator.vehicle.id)},
name=get_device_name(self.coordinator.vehicle),
manufacturer="Spireon LoJack",
model=self.coordinator.vehicle.model,
serial_number=self.coordinator.vehicle.vin,
)
@property
def source_type(self) -> SourceType:
"""Return the source type of the device."""
return SourceType.GPS
@property
def latitude(self) -> float | None:
"""Return the latitude of the device."""
return self.coordinator.data.latitude
@property
def longitude(self) -> float | None:
"""Return the longitude of the device."""
return self.coordinator.data.longitude
@property
def location_accuracy(self) -> int:
"""Return the location accuracy of the device."""
if self.coordinator.data.accuracy is not None:
return int(self.coordinator.data.accuracy)
return 0
@property
def battery_level(self) -> int | None:
"""Return the battery level of the device (if applicable)."""
# LoJack devices report vehicle battery voltage, not percentage
return None

View File

@@ -0,0 +1,12 @@
{
"domain": "lojack",
"name": "LoJack",
"codeowners": ["@devinslick"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lojack",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["lojack_api"],
"quality_scale": "silver",
"requirements": ["lojack-api==0.7.1"]
}

View File

@@ -0,0 +1,81 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not provide actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration does not provide an options flow.
docs-installation-parameters:
status: done
comment: Documented in https://github.com/home-assistant/home-assistant.io/pull/43463
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery:
status: exempt
comment: This is a cloud polling integration with no local discovery mechanism since the devices are not on a local network.
discovery-update-info:
status: exempt
comment: This is a cloud polling integration with no local discovery mechanism.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Vehicles are tied to the user account. Changes require integration reload.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: The device tracker entity is the primary entity and should be enabled by default.
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No user-actionable repair scenarios identified for this integration.
stale-devices:
status: exempt
comment: Vehicles removed from the LoJack account stop appearing in API responses and become unavailable.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,38 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::lojack::config::step::user::data_description::password%]"
},
"description": "Re-enter the password for {username}."
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "Your LoJack/Spireon account password",
"username": "Your LoJack/Spireon account email address"
},
"description": "Enter your LoJack/Spireon account credentials."
}
}
}
}

View File

@@ -80,6 +80,7 @@ class MatterUpdate(MatterEntity, UpdateEntity):
# Matter server.
_attr_should_poll = True
_software_update: MatterSoftwareVersion | None = None
_installed_software_version: int | None = None
_cancel_update: CALLBACK_TYPE | None = None
_attr_supported_features = (
UpdateEntityFeature.INSTALL
@@ -92,6 +93,9 @@ class MatterUpdate(MatterEntity, UpdateEntity):
def _update_from_device(self) -> None:
"""Update from device."""
self._installed_software_version = self.get_matter_attribute_value(
clusters.BasicInformation.Attributes.SoftwareVersion
)
self._attr_installed_version = self.get_matter_attribute_value(
clusters.BasicInformation.Attributes.SoftwareVersionString
)
@@ -123,6 +127,22 @@ class MatterUpdate(MatterEntity, UpdateEntity):
else:
self._attr_update_percentage = None
def _format_latest_version(
self, update_information: MatterSoftwareVersion
) -> str | None:
"""Return the version string to expose in Home Assistant."""
latest_version = update_information.software_version_string
if self._installed_software_version is None:
return latest_version
if update_information.software_version == self._installed_software_version:
return self._attr_installed_version or latest_version
if latest_version == self._attr_installed_version:
return f"{latest_version} ({update_information.software_version})"
return latest_version
async def async_update(self) -> None:
"""Call when the entity needs to be updated."""
try:
@@ -130,11 +150,13 @@ class MatterUpdate(MatterEntity, UpdateEntity):
node_id=self._endpoint.node.node_id
)
if not update_information:
self._software_update = None
self._attr_latest_version = self._attr_installed_version
self._attr_release_url = None
return
self._software_update = update_information
self._attr_latest_version = update_information.software_version_string
self._attr_latest_version = self._format_latest_version(update_information)
self._attr_release_url = update_information.release_notes_url
except UpdateCheckError as err:
@@ -212,7 +234,12 @@ class MatterUpdate(MatterEntity, UpdateEntity):
software_version: str | int | None = version
if self._software_update is not None and (
version is None or version == self._software_update.software_version_string
version is None
or version
in {
self._software_update.software_version_string,
self._attr_latest_version,
}
):
# Update to the version previously fetched and shown.
# We can pass the integer version directly to speedup download.

View File

@@ -10,9 +10,13 @@ import httpx
import ollama
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_URL, Platform
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
@@ -62,10 +66,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bool:
"""Set up Ollama from a config entry."""
settings = {**entry.data, **entry.options}
client = ollama.AsyncClient(host=settings[CONF_URL], verify=get_default_context())
api_key = settings.get(CONF_API_KEY)
stripped_api_key = api_key.strip() if isinstance(api_key, str) else None
client = ollama.AsyncClient(
host=settings[CONF_URL],
headers=(
{"Authorization": f"Bearer {stripped_api_key}"}
if stripped_api_key
else None
),
verify=get_default_context(),
)
try:
async with asyncio.timeout(DEFAULT_TIMEOUT):
await client.list()
except ollama.ResponseError as err:
if err.status_code in (401, 403):
raise ConfigEntryAuthFailed from err
if err.status_code >= 500 or err.status_code == 429:
raise ConfigEntryNotReady(err) from err
# If the response is a 4xx error other than 401 or 403, it likely means the URL is valid but not an Ollama instance,
# so we raise ConfigEntryError to show an error in the UI, instead of ConfigEntryNotReady which would just keep retrying.
raise ConfigEntryError(err) from err
except (TimeoutError, httpx.ConnectError) as err:
raise ConfigEntryNotReady(err) from err

View File

@@ -20,7 +20,7 @@ from homeassistant.config_entries import (
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_LLM_HASS_API, CONF_NAME, CONF_URL
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, llm
from homeassistant.helpers.selector import (
@@ -68,6 +68,17 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
vol.Required(CONF_URL): TextSelector(
TextSelectorConfig(type=TextSelectorType.URL)
),
vol.Optional(CONF_API_KEY): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
},
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_API_KEY): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
)
@@ -78,9 +89,40 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 3
MINOR_VERSION = 3
def __init__(self) -> None:
"""Initialize config flow."""
self.url: str | None = None
async def _async_validate_connection(
self, url: str, api_key: str | None
) -> dict[str, str]:
"""Validate connection and credentials against the Ollama server."""
errors: dict[str, str] = {}
try:
client = ollama.AsyncClient(
host=url,
headers={"Authorization": f"Bearer {api_key}"} if api_key else None,
verify=get_default_context(),
)
async with asyncio.timeout(DEFAULT_TIMEOUT):
await client.list()
except ollama.ResponseError as err:
if err.status_code in (401, 403):
errors["base"] = "invalid_auth"
else:
_LOGGER.warning(
"Error response from Ollama server at %s: status %s, detail: %s",
url,
err.status_code,
str(err),
)
errors["base"] = "unknown"
except TimeoutError, httpx.ConnectError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return errors
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -92,9 +134,10 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
)
errors = {}
url = user_input[CONF_URL]
self._async_abort_entries_match({CONF_URL: url})
url = user_input[CONF_URL].strip()
api_key = user_input.get(CONF_API_KEY)
if api_key:
api_key = api_key.strip()
try:
url = cv.url(url)
@@ -108,15 +151,8 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
try:
client = ollama.AsyncClient(host=url, verify=get_default_context())
async with asyncio.timeout(DEFAULT_TIMEOUT):
await client.list()
except TimeoutError, httpx.ConnectError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
self._async_abort_entries_match({CONF_URL: url})
errors = await self._async_validate_connection(url, api_key)
if errors:
return self.async_show_form(
@@ -127,9 +163,65 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
return self.async_create_entry(
title=url,
data={CONF_URL: url},
entry_data: dict[str, str] = {CONF_URL: url}
if api_key:
entry_data[CONF_API_KEY] = api_key
return self.async_create_entry(title=url, data=entry_data)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication when existing credentials are invalid."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthentication confirmation."""
reauth_entry = self._get_reauth_entry()
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_DATA_SCHEMA,
)
api_key = user_input.get(CONF_API_KEY)
if api_key:
api_key = api_key.strip()
errors = await self._async_validate_connection(
reauth_entry.data[CONF_URL], api_key
)
if errors:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
STEP_REAUTH_DATA_SCHEMA, user_input
),
errors=errors,
)
updated_data = {
**reauth_entry.data,
CONF_URL: reauth_entry.data[CONF_URL],
}
if api_key:
updated_data[CONF_API_KEY] = api_key
else:
updated_data.pop(CONF_API_KEY, None)
updated_options = {
key: value
for key, value in reauth_entry.options.items()
if key != CONF_API_KEY
}
return self.async_update_reload_and_abort(
reauth_entry,
data=updated_data,
options=updated_options,
)
@classmethod

View File

@@ -1,16 +1,26 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_url": "[%key:common::config_flow::error::invalid_host%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"description": "The Ollama integration needs to re-authenticate with your Ollama API key.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"url": "[%key:common::config_flow::data::url%]"
}
}

View File

@@ -17,6 +17,7 @@ from onvif.client import (
from onvif.exceptions import ONVIFError
from onvif.util import stringify_onvif_error
import onvif_parsers
import onvif_parsers.util
from zeep.exceptions import Fault, TransportError, ValidationError, XMLParseError
from homeassistant.components import webhook
@@ -196,7 +197,7 @@ class EventManager:
topic = msg.Topic._value_1.rstrip("/.") # noqa: SLF001
try:
event = await onvif_parsers.parse(topic, unique_id, msg)
events = await onvif_parsers.parse(topic, unique_id, msg)
error = None
except onvif_parsers.errors.UnknownTopicError:
if topic not in UNHANDLED_TOPICS:
@@ -204,42 +205,43 @@ class EventManager:
"%s: No registered handler for event from %s: %s",
self.name,
unique_id,
msg,
onvif_parsers.util.event_to_debug_format(msg),
)
UNHANDLED_TOPICS.add(topic)
continue
except (AttributeError, KeyError) as e:
event = None
events = []
error = e
if not event:
if not events:
LOGGER.warning(
"%s: Unable to parse event from %s: %s: %s",
self.name,
unique_id,
error,
msg,
onvif_parsers.util.event_to_debug_format(msg),
)
continue
value = event.value
if event.device_class == "timestamp" and isinstance(value, str):
value = _local_datetime_or_none(value)
for event in events:
value = event.value
if event.device_class == "timestamp" and isinstance(value, str):
value = _local_datetime_or_none(value)
ha_event = Event(
uid=event.uid,
name=event.name,
platform=event.platform,
device_class=event.device_class,
unit_of_measurement=event.unit_of_measurement,
value=value,
entity_category=ENTITY_CATEGORY_MAPPING.get(
event.entity_category or ""
),
entity_enabled=event.entity_enabled,
)
self.get_uids_by_platform(ha_event.platform).add(ha_event.uid)
self._events[ha_event.uid] = ha_event
ha_event = Event(
uid=event.uid,
name=event.name,
platform=event.platform,
device_class=event.device_class,
unit_of_measurement=event.unit_of_measurement,
value=value,
entity_category=ENTITY_CATEGORY_MAPPING.get(
event.entity_category or ""
),
entity_enabled=event.entity_enabled,
)
self.get_uids_by_platform(ha_event.platform).add(ha_event.uid)
self._events[ha_event.uid] = ha_event
def get_uid(self, uid: str) -> Event | None:
"""Retrieve event for given id."""

View File

@@ -15,7 +15,7 @@
"loggers": ["onvif", "wsdiscovery", "zeep"],
"requirements": [
"onvif-zeep-async==4.0.4",
"onvif_parsers==1.2.2",
"onvif_parsers==2.3.0",
"WSDiscovery==2.1.2"
]
}

View File

@@ -20,7 +20,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import Event, HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import track_point_in_utc_time
from homeassistant.helpers.typing import ConfigType
@@ -37,6 +37,7 @@ DEFAULT_SEND_DELAY = 0.0
DOMAIN = "pilight"
EVENT = "pilight_received"
type EVENT_TYPE = Event[dict[str, Any]]
# The Pilight code schema depends on the protocol. Thus only require to have
# the protocol information. Ensure that protocol is in a list otherwise

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import datetime
from typing import Any
import voluptuous as vol
@@ -24,7 +25,7 @@ from homeassistant.helpers.event import track_point_in_time
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from . import EVENT
from . import EVENT, EVENT_TYPE
CONF_VARIABLE = "variable"
CONF_RESET_DELAY_SEC = "reset_delay_sec"
@@ -46,6 +47,8 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
}
)
type _PAYLOAD_SET_TYPE = str | int | float
def setup_platform(
hass: HomeAssistant,
@@ -59,12 +62,12 @@ def setup_platform(
[
PilightTriggerSensor(
hass=hass,
name=config.get(CONF_NAME),
variable=config.get(CONF_VARIABLE),
payload=config.get(CONF_PAYLOAD),
on_value=config.get(CONF_PAYLOAD_ON),
off_value=config.get(CONF_PAYLOAD_OFF),
rst_dly_sec=config.get(CONF_RESET_DELAY_SEC),
name=config[CONF_NAME],
variable=config[CONF_VARIABLE],
payload=config[CONF_PAYLOAD],
on_value=config[CONF_PAYLOAD_ON],
off_value=config[CONF_PAYLOAD_OFF],
rst_dly_sec=config[CONF_RESET_DELAY_SEC],
)
]
)
@@ -73,11 +76,11 @@ def setup_platform(
[
PilightBinarySensor(
hass=hass,
name=config.get(CONF_NAME),
variable=config.get(CONF_VARIABLE),
payload=config.get(CONF_PAYLOAD),
on_value=config.get(CONF_PAYLOAD_ON),
off_value=config.get(CONF_PAYLOAD_OFF),
name=config[CONF_NAME],
variable=config[CONF_VARIABLE],
payload=config[CONF_PAYLOAD],
on_value=config[CONF_PAYLOAD_ON],
off_value=config[CONF_PAYLOAD_OFF],
)
]
)
@@ -86,7 +89,15 @@ def setup_platform(
class PilightBinarySensor(BinarySensorEntity):
"""Representation of a binary sensor that can be updated using Pilight."""
def __init__(self, hass, name, variable, payload, on_value, off_value):
def __init__(
self,
hass: HomeAssistant,
name: str,
variable: str,
payload: dict[str, Any],
on_value: _PAYLOAD_SET_TYPE,
off_value: _PAYLOAD_SET_TYPE,
) -> None:
"""Initialize the sensor."""
self._attr_is_on = False
self._hass = hass
@@ -98,7 +109,7 @@ class PilightBinarySensor(BinarySensorEntity):
hass.bus.listen(EVENT, self._handle_code)
def _handle_code(self, call):
def _handle_code(self, call: EVENT_TYPE) -> None:
"""Handle received code by the pilight-daemon.
If the code matches the defined payload
@@ -126,8 +137,15 @@ class PilightTriggerSensor(BinarySensorEntity):
"""Representation of a binary sensor that can be updated using Pilight."""
def __init__(
self, hass, name, variable, payload, on_value, off_value, rst_dly_sec=30
):
self,
hass: HomeAssistant,
name: str,
variable: str,
payload: dict[str, Any],
on_value: _PAYLOAD_SET_TYPE,
off_value: _PAYLOAD_SET_TYPE,
rst_dly_sec: int,
) -> None:
"""Initialize the sensor."""
self._attr_is_on = False
self._hass = hass
@@ -137,17 +155,17 @@ class PilightTriggerSensor(BinarySensorEntity):
self._on_value = on_value
self._off_value = off_value
self._reset_delay_sec = rst_dly_sec
self._delay_after = None
self._delay_after: datetime.datetime | None = None
self._hass = hass
hass.bus.listen(EVENT, self._handle_code)
def _reset_state(self, call):
def _reset_state(self, _: datetime.datetime) -> None:
self._attr_is_on = False
self._delay_after = None
self.schedule_update_ha_state()
def _handle_code(self, call):
def _handle_code(self, call: EVENT_TYPE) -> None:
"""Handle received code by the pilight-daemon.
If the code matches the defined payload

View File

@@ -1,5 +1,7 @@
"""Base class for pilight."""
from typing import Any
import voluptuous as vol
from homeassistant.const import (
@@ -10,8 +12,10 @@ from homeassistant.const import (
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
from . import DOMAIN, EVENT, SERVICE_NAME
from .const import (
@@ -60,19 +64,19 @@ class PilightBaseDevice(RestoreEntity):
_attr_assumed_state = True
_attr_should_poll = False
def __init__(self, hass, name, config):
def __init__(self, hass: HomeAssistant, name: str, config: ConfigType) -> None:
"""Initialize a device."""
self._hass = hass
self._attr_name = config.get(CONF_NAME, name)
self._attr_is_on = False
self._attr_is_on: bool | None = False
self._code_on = config.get(CONF_ON_CODE)
self._code_off = config.get(CONF_OFF_CODE)
code_on_receive = config.get(CONF_ON_CODE_RECEIVE, [])
code_off_receive = config.get(CONF_OFF_CODE_RECEIVE, [])
self._code_on_receive = []
self._code_off_receive = []
self._code_on_receive: list[_ReceiveHandle] = []
self._code_off_receive: list[_ReceiveHandle] = []
for code_list, conf in (
(self._code_on_receive, code_on_receive),
@@ -85,7 +89,7 @@ class PilightBaseDevice(RestoreEntity):
if any(self._code_on_receive) or any(self._code_off_receive):
hass.bus.listen(EVENT, self._handle_code)
self._brightness = 255
self._brightness: int | None = 255
async def async_added_to_hass(self) -> None:
"""Call when entity about to be added to hass."""
@@ -147,18 +151,18 @@ class PilightBaseDevice(RestoreEntity):
class _ReceiveHandle:
def __init__(self, config, echo):
def __init__(self, config: dict[str, Any], echo: bool) -> None:
"""Initialize the handle."""
self.config_items = config.items()
self.echo = echo
def match(self, code):
def match(self, code: dict[str, Any]) -> bool:
"""Test if the received code matches the configured values.
The received values have to be a subset of the configured options.
"""
return self.config_items <= code.items()
def run(self, switch, turn_on):
def run(self, switch: PilightBaseDevice, turn_on: bool) -> None:
"""Change the state of the switch."""
switch.set_state(turn_on=turn_on, send_code=self.echo)

View File

@@ -55,11 +55,11 @@ class PilightLight(PilightBaseDevice, LightEntity):
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
def __init__(self, hass, name, config):
def __init__(self, hass: HomeAssistant, name: str, config: ConfigType) -> None:
"""Initialize a switch."""
super().__init__(hass, name, config)
self._dimlevel_min = config.get(CONF_DIMLEVEL_MIN)
self._dimlevel_max = config.get(CONF_DIMLEVEL_MAX)
self._dimlevel_min: int = config[CONF_DIMLEVEL_MIN]
self._dimlevel_max: int = config[CONF_DIMLEVEL_MAX]
@property
def brightness(self) -> int | None:

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
@@ -16,7 +17,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import EVENT
from . import EVENT, EVENT_TYPE
_LOGGER = logging.getLogger(__name__)
@@ -44,9 +45,9 @@ def setup_platform(
[
PilightSensor(
hass=hass,
name=config.get(CONF_NAME),
variable=config.get(CONF_VARIABLE),
payload=config.get(CONF_PAYLOAD),
name=config[CONF_NAME],
variable=config[CONF_VARIABLE],
payload=config[CONF_PAYLOAD],
unit_of_measurement=config.get(CONF_UNIT_OF_MEASUREMENT),
)
]
@@ -58,33 +59,24 @@ class PilightSensor(SensorEntity):
_attr_should_poll = False
def __init__(self, hass, name, variable, payload, unit_of_measurement):
def __init__(
self,
hass: HomeAssistant,
name: str,
variable: str,
payload: dict[str, Any],
unit_of_measurement: str | None,
) -> None:
"""Initialize the sensor."""
self._state = None
self._hass = hass
self._name = name
self._attr_name = name
self._variable = variable
self._payload = payload
self._unit_of_measurement = unit_of_measurement
self._attr_native_unit_of_measurement = unit_of_measurement
hass.bus.listen(EVENT, self._handle_code)
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def native_unit_of_measurement(self):
"""Return the unit this state is expressed in."""
return self._unit_of_measurement
@property
def native_value(self):
"""Return the state of the entity."""
return self._state
def _handle_code(self, call):
def _handle_code(self, call: EVENT_TYPE) -> None:
"""Handle received code by the pilight-daemon.
If the code matches the defined payload
@@ -96,7 +88,7 @@ class PilightSensor(SensorEntity):
if self._payload.items() <= call.data.items():
try:
value = call.data[self._variable]
self._state = value
self._attr_native_value = value
self.schedule_update_ha_state()
except KeyError:
_LOGGER.error(

View File

@@ -19,8 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartTubConfigEntry) ->
controller = SmartTubController(hass)
if not await controller.async_setup_entry(entry):
return False
await controller.async_setup_entry(entry)
entry.runtime_data = controller

View File

@@ -0,0 +1,74 @@
rules:
# Bronze
action-setup: todo
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions: todo
docs-high-level-description: todo
docs-installation-instructions: todo
docs-removal-instructions: todo
entity-event-setup:
status: exempt
comment: Entities use coordinator polling, no explicit event subscriptions.
entity-unique-id: done
has-entity-name: todo
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration does not have configuration parameters.
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: done
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery:
status: exempt
comment: This is a cloud-only service with no local discovery mechanism.
discovery-update-info:
status: exempt
comment: This is a cloud-only service with no local discovery mechanism.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Spa devices are fixed to the account and cannot be dynamically added or removed.
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: done
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: This integration does not raise any repairable issues.
stale-devices:
status: exempt
comment: Spa devices are fixed to the account and cannot be removed.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -8,7 +8,6 @@ from http import HTTPStatus
import logging
from xml.parsers.expat import ExpatError
from aiohttp import ClientSession
import voluptuous as vol
import xmltodict
@@ -151,7 +150,7 @@ async def async_setup_platform(
apikey = config[CONF_API_KEY]
bandwidthcap = config[CONF_TOTAL_BANDWIDTH]
ts_data = StartcaData(websession, apikey, bandwidthcap)
ts_data = StartcaData(hass.loop, websession, apikey, bandwidthcap)
ret = await ts_data.async_update()
if ret is False:
_LOGGER.error("Invalid Start.ca API key: %s", apikey)
@@ -177,9 +176,7 @@ async def async_setup_platform(
class StartcaSensor(SensorEntity):
"""Representation of Start.ca Bandwidth sensor."""
def __init__(
self, startcadata: StartcaData, name: str, description: SensorEntityDescription
) -> None:
def __init__(self, startcadata, name, description: SensorEntityDescription) -> None:
"""Initialize the sensor."""
self.entity_description = description
self.startcadata = startcadata
@@ -197,10 +194,9 @@ class StartcaSensor(SensorEntity):
class StartcaData:
"""Get data from Start.ca API."""
def __init__(
self, websession: ClientSession, api_key: str, bandwidth_cap: int
) -> None:
def __init__(self, loop, websession, api_key, bandwidth_cap):
"""Initialize the data object."""
self.loop = loop
self.websession = websession
self.api_key = api_key
self.bandwidth_cap = bandwidth_cap
@@ -219,7 +215,7 @@ class StartcaData:
return float(value) * 10**-9
@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self) -> bool:
async def async_update(self):
"""Get the Start.ca bandwidth data from the web service."""
_LOGGER.debug("Updating Start.ca usage data")
url = f"https://www.start.ca/support/usage/api?key={self.api_key}"

View File

@@ -22,7 +22,7 @@ from homeassistant.components.application_credentials import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -39,6 +39,7 @@ from .coordinator import (
TeslemetryEnergyHistoryCoordinator,
TeslemetryEnergySiteInfoCoordinator,
TeslemetryEnergySiteLiveCoordinator,
TeslemetryMetadataCoordinator,
TeslemetryVehicleDataCoordinator,
)
from .helpers import async_update_device_sw_version, flatten
@@ -109,6 +110,61 @@ async def _get_access_token(oauth_session: OAuth2Session) -> str:
return cast(str, oauth_session.token[CONF_ACCESS_TOKEN])
def _get_subscribed_ids_from_metadata(
data: dict[str, Any],
) -> tuple[set[str], set[str]]:
"""Return metadata device IDs that have an active subscription."""
subscribed_vins = {
vin for vin, info in data["vehicles"].items() if info.get("access")
}
subscribed_site_ids = {
site_id for site_id, info in data["energy_sites"].items() if info.get("access")
}
return subscribed_vins, subscribed_site_ids
def _setup_dynamic_discovery(
hass: HomeAssistant,
entry: TeslemetryConfigEntry,
metadata_coordinator: TeslemetryMetadataCoordinator,
known_vins: set[str],
known_site_ids: set[str],
) -> None:
"""Set up dynamic device discovery via reload when subscriptions change."""
@callback
def _handle_metadata_update() -> None:
"""Handle metadata coordinator update - detect subscription changes."""
data = metadata_coordinator.data
if not data:
return
current_vins, current_site_ids = _get_subscribed_ids_from_metadata(data)
added_vins = current_vins - known_vins
removed_vins = known_vins - current_vins
added_sites = current_site_ids - known_site_ids
removed_sites = known_site_ids - current_site_ids
if added_vins or removed_vins or added_sites or removed_sites:
LOGGER.info(
"Tesla subscription changes detected "
"(added vehicles: %s, removed vehicles: %s, "
"added energy sites: %s, removed energy sites: %s), "
"reloading integration",
added_vins or "none",
removed_vins or "none",
added_sites or "none",
removed_sites or "none",
)
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(
metadata_coordinator.async_add_listener(_handle_metadata_update)
)
async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool:
"""Set up Teslemetry config."""
@@ -159,6 +215,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
scopes = calls[0]["scopes"]
region = calls[0]["region"]
vehicle_metadata = calls[0]["vehicles"]
energy_site_metadata = calls[0]["energy_sites"]
products = calls[1]["response"]
device_registry = dr.async_get(hass)
@@ -167,21 +224,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
vehicles: list[TeslemetryVehicleData] = []
energysites: list[TeslemetryEnergyData] = []
# Create the stream
# Create the stream (created lazily when first vehicle is found)
stream: TeslemetryStream | None = None
# Remember each device identifier we create
current_devices: set[tuple[str, str]] = set()
# Track known devices for dynamic discovery (based on metadata access state)
known_vins, known_site_ids = _get_subscribed_ids_from_metadata(calls[0])
for product in products:
if (
"vin" in product
and vehicle_metadata.get(product["vin"], {}).get("access")
and Scope.VEHICLE_DEVICE_DATA in scopes
):
vin = product["vin"]
current_devices.add((DOMAIN, vin))
# Create stream if required (for first vehicle)
if not stream:
stream = TeslemetryStream(
session,
access_token,
server=f"{region.lower()}.teslemetry.com",
parse_timestamp=True,
manual=True,
)
# Remove the protobuff 'cached_data' that we do not use to save memory
product.pop("cached_data", None)
vin = product["vin"]
vehicle = teslemetry.vehicles.create(vin)
coordinator = TeslemetryVehicleDataCoordinator(
hass, entry, vehicle, product
@@ -197,17 +269,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
serial_number=vin,
sw_version=firmware,
)
current_devices.add((DOMAIN, vin))
# Create stream if required
if not stream:
stream = TeslemetryStream(
session,
access_token,
server=f"{region.lower()}.teslemetry.com",
parse_timestamp=True,
manual=True,
)
poll = vehicle_metadata[vin].get("polling", False)
entry.async_on_unload(
stream.async_add_listener(
@@ -216,7 +279,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
)
)
stream_vehicle = stream.get_vehicle(vin)
poll = vehicle_metadata[vin].get("polling", False)
vehicles.append(
TeslemetryVehicleData(
@@ -227,13 +289,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
stream=stream,
stream_vehicle=stream_vehicle,
vin=vin,
firmware=firmware or "",
firmware=firmware or "Unknown",
device=device,
)
)
elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes:
elif (
"energy_site_id" in product
and Scope.ENERGY_DEVICE_DATA in scopes
and energy_site_metadata.get(str(product["energy_site_id"]), {}).get(
"access"
)
):
site_id = product["energy_site_id"]
powerwall = (
product["components"]["battery"] or product["components"]["solar"]
)
@@ -245,6 +314,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
)
continue
current_devices.add((DOMAIN, str(site_id)))
if wall_connector:
current_devices |= {
(DOMAIN, c["din"]) for c in product["components"]["wall_connectors"]
}
energy_site = teslemetry.energySites.create(site_id)
device = DeviceInfo(
identifiers={(DOMAIN, str(site_id))},
@@ -253,13 +328,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
name=product.get("site_name", "Energy Site"),
serial_number=str(site_id),
)
current_devices.add((DOMAIN, str(site_id)))
if wall_connector:
for connector in product["components"]["wall_connectors"]:
current_devices.add((DOMAIN, connector["din"]))
# Check live status endpoint works before creating its coordinator
# For initial setup, raise auth errors properly
try:
live_status = (await energy_site.live_status())["response"]
except InvalidToken as e:
@@ -348,10 +418,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
remove_config_entry_id=entry.entry_id,
)
# Setup Platforms
entry.runtime_data = TeslemetryData(vehicles, energysites, scopes, stream)
metadata_coordinator = TeslemetryMetadataCoordinator(hass, entry, teslemetry)
entry.runtime_data = TeslemetryData(
vehicles=vehicles,
energysites=energysites,
scopes=scopes,
stream=stream,
metadata_coordinator=metadata_coordinator,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_setup_dynamic_discovery(
hass,
entry,
metadata_coordinator,
known_vins,
known_site_ids,
)
if stream:
entry.async_on_unload(stream.close)
entry.async_create_background_task(hass, stream.listen(), "Teslemetry Stream")
@@ -454,7 +539,6 @@ async def async_setup_stream(
hass: HomeAssistant, entry: TeslemetryConfigEntry, vehicle: TeslemetryVehicleData
) -> None:
"""Set up the stream for a vehicle."""
await vehicle.stream_vehicle.get_config()
entry.async_create_background_task(
hass,

View File

@@ -15,7 +15,7 @@ from tesla_fleet_api.exceptions import (
SubscriptionRequired,
TeslaFleetError,
)
from tesla_fleet_api.teslemetry import EnergySite, Vehicle
from tesla_fleet_api.teslemetry import EnergySite, Teslemetry, Vehicle
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -48,6 +48,7 @@ VEHICLE_WAIT = timedelta(minutes=15)
ENERGY_LIVE_INTERVAL = timedelta(seconds=30)
ENERGY_INFO_INTERVAL = timedelta(seconds=30)
ENERGY_HISTORY_INTERVAL = timedelta(seconds=60)
METADATA_INTERVAL = timedelta(hours=1)
ENDPOINTS = [
VehicleDataEndpoint.CHARGE_STATE,
@@ -59,6 +60,50 @@ ENDPOINTS = [
]
class TeslemetryMetadataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator to poll for subscription changes via metadata."""
config_entry: TeslemetryConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: TeslemetryConfigEntry,
teslemetry: Teslemetry,
) -> None:
"""Initialize Teslemetry Metadata coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name="Teslemetry Metadata",
update_interval=METADATA_INTERVAL,
)
self.teslemetry = teslemetry
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch latest metadata for subscription status."""
try:
data = await self.teslemetry.metadata()
except (InvalidToken, SubscriptionRequired) as e:
raise ConfigEntryAuthFailed from e
except RETRY_EXCEPTIONS as e:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"message": e.message},
retry_after=_get_retry_after(e),
) from e
except TeslaFleetError as e:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"message": e.message},
) from e
return data
class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching data from the Teslemetry API."""

View File

@@ -16,6 +16,7 @@ from .coordinator import (
TeslemetryEnergyHistoryCoordinator,
TeslemetryEnergySiteInfoCoordinator,
TeslemetryEnergySiteLiveCoordinator,
TeslemetryMetadataCoordinator,
TeslemetryVehicleDataCoordinator,
)
@@ -28,6 +29,7 @@ class TeslemetryData:
energysites: list[TeslemetryEnergyData]
scopes: list[Scope]
stream: TeslemetryStream | None
metadata_coordinator: TeslemetryMetadataCoordinator
@dataclass

View File

@@ -45,13 +45,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: todo
comment: |
New vehicles/energy sites added to user's Tesla account after initial setup
are not detected. Need to periodically poll teslemetry.products() and add
new TeslemetryVehicleData/TeslemetryEnergyData to runtime_data, then trigger
entity creation via coordinator listeners in each platform.
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done

View File

@@ -27,11 +27,28 @@ from .const import (
VICARE_TOKEN_FILENAME,
)
from .types import ViCareConfigEntry, ViCareData, ViCareDevice
from .utils import get_device, get_device_serial, login
from .utils import get_device_serial, login
_LOGGER = logging.getLogger(__name__)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: ViCareConfigEntry
) -> bool:
"""Migrate old entry."""
if config_entry.version > 1:
return False
if config_entry.version == 1 and config_entry.minor_version < 2:
_LOGGER.debug("Migrating ViCare config entry from version 1.1 to 1.2")
data = {**config_entry.data}
data.pop("heating_type", None)
hass.config_entries.async_update_entry(config_entry, data=data, minor_version=2)
_LOGGER.debug("Migration to version 1.2 successful")
return True
async def async_setup_entry(hass: HomeAssistant, entry: ViCareConfigEntry) -> bool:
"""Set up from config entry."""
_LOGGER.debug("Setting up ViCare component")
@@ -74,7 +91,7 @@ def setup_vicare_api(hass: HomeAssistant, entry: ViCareConfigEntry) -> PyViCare:
)
devices = [
ViCareDevice(config=device_config, api=get_device(entry, device_config))
ViCareDevice(config=device_config, api=device_config.asAutoDetectDevice())
for device_config in device_config_list
if bool(device_config.isOnline())
]

View File

@@ -18,14 +18,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import (
CONF_HEATING_TYPE,
DEFAULT_HEATING_TYPE,
DOMAIN,
VICARE_NAME,
VIESSMANN_DEVELOPER_PORTAL,
HeatingType,
)
from .const import DOMAIN, VICARE_NAME, VIESSMANN_DEVELOPER_PORTAL
from .utils import login
_LOGGER = logging.getLogger(__name__)
@@ -40,9 +33,6 @@ REAUTH_SCHEMA = vol.Schema(
USER_SCHEMA = REAUTH_SCHEMA.extend(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_HEATING_TYPE, default=DEFAULT_HEATING_TYPE.value): vol.In(
[e.value for e in HeatingType]
),
}
)
@@ -51,6 +41,7 @@ class ViCareConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for ViCare."""
VERSION = 1
MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None

View File

@@ -1,7 +1,5 @@
"""Constants for the ViCare integration."""
import enum
from homeassistant.const import Platform
DOMAIN = "vicare"
@@ -31,7 +29,6 @@ VICARE_TOKEN_FILENAME = "vicare_token.save"
VIESSMANN_DEVELOPER_PORTAL = "https://app.developer.viessmann-climatesolutions.com"
CONF_CIRCUIT = "circuit"
CONF_HEATING_TYPE = "heating_type"
DEFAULT_CACHE_DURATION = 60
@@ -43,28 +40,3 @@ VICARE_KWH = "kilowattHour"
VICARE_PERCENT = "percent"
VICARE_W = "watt"
VICARE_WH = "wattHour"
class HeatingType(enum.Enum):
"""Possible options for heating type."""
auto = "auto"
gas = "gas"
oil = "oil"
pellets = "pellets"
heatpump = "heatpump"
fuelcell = "fuelcell"
hybrid = "hybrid"
DEFAULT_HEATING_TYPE = HeatingType.auto
HEATING_TYPE_TO_CREATOR_METHOD = {
HeatingType.auto: "asAutoDetectDevice",
HeatingType.gas: "asGazBoiler",
HeatingType.fuelcell: "asFuelCell",
HeatingType.heatpump: "asHeatPump",
HeatingType.oil: "asOilBoiler",
HeatingType.pellets: "asPelletsBoiler",
HeatingType.hybrid: "asHybridDevice",
}

View File

@@ -24,13 +24,11 @@
"user": {
"data": {
"client_id": "Client ID",
"heating_type": "Heating type",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::email%]"
},
"data_description": {
"client_id": "The ID of the API client created in the [Viessmann developer portal]({viessmann_developer_portal}).",
"heating_type": "Allows to overrule the device auto detection.",
"password": "The password to log in to your ViCare account.",
"username": "The email address to log in to your ViCare account."
},

View File

@@ -8,7 +8,6 @@ from typing import Any
from PyViCare.PyViCare import PyViCare
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import (
HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent,
)
@@ -25,14 +24,7 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import STORAGE_DIR
from .const import (
CONF_HEATING_TYPE,
DEFAULT_CACHE_DURATION,
HEATING_TYPE_TO_CREATOR_METHOD,
VICARE_TOKEN_FILENAME,
HeatingType,
)
from .types import ViCareConfigEntry
from .const import DEFAULT_CACHE_DURATION, VICARE_TOKEN_FILENAME
_LOGGER = logging.getLogger(__name__)
@@ -54,16 +46,6 @@ def login(
return vicare_api
def get_device(
entry: ViCareConfigEntry, device_config: PyViCareDeviceConfig
) -> PyViCareDevice:
"""Get device for device config."""
return getattr(
device_config,
HEATING_TYPE_TO_CREATOR_METHOD[HeatingType(entry.data[CONF_HEATING_TYPE])],
)()
def get_device_serial(device: PyViCareDevice) -> str | None:
"""Get device serial for device if supported."""
try:

View File

@@ -408,9 +408,64 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
Keys.AC_CURRENT: VictronBLESensorEntityDescription(
key=Keys.AC_CURRENT,
translation_key=Keys.AC_CURRENT,
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.OUTPUT_VOLTAGE_1: VictronBLESensorEntityDescription(
key=Keys.OUTPUT_VOLTAGE_1,
translation_key="output_phase_voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
translation_placeholders={"phase": "1"},
),
Keys.OUTPUT_CURRENT_1: VictronBLESensorEntityDescription(
key=Keys.OUTPUT_CURRENT_1,
translation_key="output_phase_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
translation_placeholders={"phase": "1"},
),
Keys.OUTPUT_VOLTAGE_2: VictronBLESensorEntityDescription(
key=Keys.OUTPUT_VOLTAGE_2,
translation_key="output_phase_voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
translation_placeholders={"phase": "2"},
),
Keys.OUTPUT_CURRENT_2: VictronBLESensorEntityDescription(
key=Keys.OUTPUT_CURRENT_2,
translation_key="output_phase_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
translation_placeholders={"phase": "2"},
),
Keys.OUTPUT_VOLTAGE_3: VictronBLESensorEntityDescription(
key=Keys.OUTPUT_VOLTAGE_3,
translation_key="output_phase_voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
translation_placeholders={"phase": "3"},
),
Keys.OUTPUT_CURRENT_3: VictronBLESensorEntityDescription(
key=Keys.OUTPUT_CURRENT_3,
translation_key="output_phase_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
translation_placeholders={"phase": "3"},
),
}
for i in range(1, 8):
for i in range(1, 9):
cell_key = getattr(Keys, f"CELL_{i}_VOLTAGE")
SENSOR_DESCRIPTIONS[cell_key] = VictronBLESensorEntityDescription(
key=cell_key,
@@ -418,6 +473,7 @@ for i in range(1, 8):
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
translation_placeholders={"cell": str(i)},
)

View File

@@ -34,6 +34,9 @@
},
"entity": {
"sensor": {
"ac_current": {
"name": "AC current"
},
"ac_in_power": {
"name": "AC-in power"
},
@@ -235,6 +238,12 @@
"switched_off_switch": "Switched off by switch"
}
},
"output_phase_current": {
"name": "Output phase {phase} current"
},
"output_phase_voltage": {
"name": "Output phase {phase} voltage"
},
"output_voltage": {
"name": "Output voltage"
},

View File

@@ -2245,9 +2245,10 @@ class ConfigEntries:
self._entries = entries
self.async_update_issues()
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, self._async_scan_orphan_ignored_entries
)
if not self.hass.config.recovery_mode and not self.hass.config.safe_mode:
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, self._async_scan_orphan_ignored_entries
)
async def _async_scan_orphan_ignored_entries(
self, event: Event[NoEventData]

View File

@@ -961,7 +961,8 @@ class HomeAssistant:
async def async_block_till_done(self, wait_background_tasks: bool = False) -> None:
"""Block until all pending work is done."""
# To flush out any call_soon_threadsafe
# Sleep twice to flush out any call_soon_threadsafe
await asyncio.sleep(0)
await asyncio.sleep(0)
start_time: float | None = None
current_task = asyncio.current_task()

View File

@@ -399,6 +399,7 @@ FLOWS = {
"local_ip",
"local_todo",
"locative",
"lojack",
"london_underground",
"lookin",
"loqed",

View File

@@ -3828,6 +3828,12 @@
}
}
},
"lojack": {
"name": "LoJack",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"london_air": {
"name": "London Air",
"integration_type": "hub",

7
requirements_all.txt generated
View File

@@ -279,7 +279,7 @@ aioharmony==0.5.3
aiohasupervisor==0.4.1
# homeassistant.components.home_connect
aiohomeconnect==0.30.0
aiohomeconnect==0.32.0
# homeassistant.components.homekit_controller
aiohomekit==3.2.20
@@ -1448,6 +1448,9 @@ livisi==0.0.25
# homeassistant.components.google_maps
locationsharinglib==5.0.1
# homeassistant.components.lojack
lojack-api==0.7.1
# homeassistant.components.london_underground
london-tube-status==0.5
@@ -1688,7 +1691,7 @@ onedrive-personal-sdk==0.1.7
onvif-zeep-async==4.0.4
# homeassistant.components.onvif
onvif_parsers==1.2.2
onvif_parsers==2.3.0
# homeassistant.components.opengarage
open-garage==0.2.0

View File

@@ -267,7 +267,7 @@ aioharmony==0.5.3
aiohasupervisor==0.4.1
# homeassistant.components.home_connect
aiohomeconnect==0.30.0
aiohomeconnect==0.32.0
# homeassistant.components.homekit_controller
aiohomekit==3.2.20
@@ -1267,6 +1267,9 @@ libsoundtouch==0.8
# homeassistant.components.livisi
livisi==0.0.25
# homeassistant.components.lojack
lojack-api==0.7.1
# homeassistant.components.london_underground
london-tube-status==0.5
@@ -1474,7 +1477,7 @@ onedrive-personal-sdk==0.1.7
onvif-zeep-async==4.0.4
# homeassistant.components.onvif
onvif_parsers==1.2.2
onvif_parsers==2.3.0
# homeassistant.components.opengarage
open-garage==0.2.0

View File

@@ -469,7 +469,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"huisbaasje",
"hunterdouglas_powerview",
"husqvarna_automower_ble",
"huum",
"hvv_departures",
"hydrawise",
"hyperion",
@@ -869,7 +868,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"sma",
"smappee",
"smart_meter_texas",
"smarttub",
"smarty",
"smhi",
"sms",
@@ -1455,7 +1453,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"huisbaasje",
"hunterdouglas_powerview",
"husqvarna_automower_ble",
"huum",
"hvv_departures",
"hydrawise",
"hyperion",

View File

@@ -60,6 +60,7 @@ async def test_turn_on(hass: HomeAssistant, fan_entity_id) -> None:
await hass.services.async_call(
fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
@@ -77,6 +78,7 @@ async def test_turn_on_with_speed_and_percentage(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 100},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] == 100
@@ -87,6 +89,7 @@ async def test_turn_on_with_speed_and_percentage(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 66},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] == 66
@@ -97,6 +100,7 @@ async def test_turn_on_with_speed_and_percentage(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 33},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] == 33
@@ -107,6 +111,7 @@ async def test_turn_on_with_speed_and_percentage(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 100},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] == 100
@@ -117,6 +122,7 @@ async def test_turn_on_with_speed_and_percentage(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 66},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] == 66
@@ -127,6 +133,7 @@ async def test_turn_on_with_speed_and_percentage(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 33},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] == 33
@@ -137,6 +144,7 @@ async def test_turn_on_with_speed_and_percentage(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 0},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_OFF
assert state.attributes[fan.ATTR_PERCENTAGE] == 0
@@ -155,6 +163,7 @@ async def test_turn_on_with_preset_mode_only(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_AUTO},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_AUTO
@@ -171,6 +180,7 @@ async def test_turn_on_with_preset_mode_only(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_SMART},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_SMART
@@ -178,6 +188,7 @@ async def test_turn_on_with_preset_mode_only(
await hass.services.async_call(
fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_OFF
assert state.attributes[fan.ATTR_PRESET_MODE] is None
@@ -214,6 +225,7 @@ async def test_turn_on_with_preset_mode_and_speed(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_AUTO},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] is None
@@ -231,6 +243,7 @@ async def test_turn_on_with_preset_mode_and_speed(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 100},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] == 100
@@ -242,6 +255,7 @@ async def test_turn_on_with_preset_mode_and_speed(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_SMART},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] is None
@@ -250,6 +264,7 @@ async def test_turn_on_with_preset_mode_and_speed(
await hass.services.async_call(
fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_OFF
assert state.attributes[fan.ATTR_PERCENTAGE] == 0
@@ -284,12 +299,14 @@ async def test_turn_off(hass: HomeAssistant, fan_entity_id) -> None:
await hass.services.async_call(
fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
await hass.services.async_call(
fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_OFF
@@ -303,12 +320,14 @@ async def test_turn_off_without_entity_id(hass: HomeAssistant, fan_entity_id) ->
await hass.services.async_call(
fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
await hass.services.async_call(
fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_MATCH_ALL}, blocking=True
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_OFF
@@ -325,6 +344,7 @@ async def test_set_direction(hass: HomeAssistant, fan_entity_id) -> None:
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_DIRECTION: fan.DIRECTION_REVERSE},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_DIRECTION] == fan.DIRECTION_REVERSE
@@ -341,6 +361,7 @@ async def test_set_preset_mode(hass: HomeAssistant, fan_entity_id) -> None:
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_AUTO},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] is None
@@ -386,6 +407,7 @@ async def test_set_percentage(hass: HomeAssistant, fan_entity_id) -> None:
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 33},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_PERCENTAGE] == 33
@@ -403,6 +425,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
{ATTR_ENTITY_ID: fan_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_PERCENTAGE] == 33
@@ -412,6 +435,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
{ATTR_ENTITY_ID: fan_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_PERCENTAGE] == 66
@@ -421,6 +445,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
{ATTR_ENTITY_ID: fan_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_PERCENTAGE] == 100
@@ -430,6 +455,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
{ATTR_ENTITY_ID: fan_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_PERCENTAGE] == 100
@@ -439,6 +465,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
{ATTR_ENTITY_ID: fan_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_PERCENTAGE] == 66
@@ -448,6 +475,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
{ATTR_ENTITY_ID: fan_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_PERCENTAGE] == 33
@@ -457,6 +485,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
{ATTR_ENTITY_ID: fan_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_PERCENTAGE] == 0
@@ -466,6 +495,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
{ATTR_ENTITY_ID: fan_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_PERCENTAGE] == 0
@@ -481,6 +511,7 @@ async def test_increase_decrease_speed_with_percentage_step(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE_STEP: 25},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_PERCENTAGE] == 25
@@ -490,6 +521,7 @@ async def test_increase_decrease_speed_with_percentage_step(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE_STEP: 25},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_PERCENTAGE] == 50
@@ -499,6 +531,7 @@ async def test_increase_decrease_speed_with_percentage_step(
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE_STEP: 25},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_PERCENTAGE] == 75
@@ -516,6 +549,7 @@ async def test_oscillate(hass: HomeAssistant, fan_entity_id) -> None:
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_OSCILLATING: True},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_OSCILLATING] is True
@@ -525,6 +559,7 @@ async def test_oscillate(hass: HomeAssistant, fan_entity_id) -> None:
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_OSCILLATING: False},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(fan_entity_id)
assert state.attributes[fan.ATTR_OSCILLATING] is False
@@ -537,4 +572,5 @@ async def test_is_on(hass: HomeAssistant, fan_entity_id) -> None:
await hass.services.async_call(
fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True
)
await hass.async_block_till_done()
assert fan.is_on(hass, fan_entity_id)

View File

@@ -107,6 +107,7 @@ async def test_source_select(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: entity_id, ATTR_INPUT_SOURCE: "xbox"},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.attributes.get(ATTR_INPUT_SOURCE) == "xbox"
@@ -128,6 +129,7 @@ async def test_repeat_set(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_REPEAT: RepeatMode.ALL},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.attributes.get(ATTR_MEDIA_REPEAT) == RepeatMode.ALL
@@ -148,6 +150,7 @@ async def test_clear_playlist(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == STATE_OFF
@@ -179,6 +182,7 @@ async def test_volume_services(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0.5
@@ -188,6 +192,7 @@ async def test_volume_services(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0.4
@@ -197,6 +202,7 @@ async def test_volume_services(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0.5
@@ -219,6 +225,7 @@ async def test_volume_services(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True
@@ -240,6 +247,7 @@ async def test_turning_off_and_on(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == STATE_OFF
assert not is_on(hass, TEST_ENTITY_ID)
@@ -250,6 +258,7 @@ async def test_turning_off_and_on(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == STATE_PLAYING
assert is_on(hass, TEST_ENTITY_ID)
@@ -260,6 +269,7 @@ async def test_turning_off_and_on(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == STATE_OFF
assert not is_on(hass, TEST_ENTITY_ID)
@@ -281,6 +291,7 @@ async def test_playing_pausing(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == STATE_PAUSED
@@ -290,6 +301,7 @@ async def test_playing_pausing(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == STATE_PLAYING
@@ -299,6 +311,7 @@ async def test_playing_pausing(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == STATE_PAUSED
@@ -308,6 +321,7 @@ async def test_playing_pausing(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == STATE_PLAYING
@@ -328,6 +342,7 @@ async def test_prev_next_track(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.attributes.get(ATTR_MEDIA_TRACK) == 2
@@ -337,6 +352,7 @@ async def test_prev_next_track(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.attributes.get(ATTR_MEDIA_TRACK) == 3
@@ -346,6 +362,7 @@ async def test_prev_next_track(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.attributes.get(ATTR_MEDIA_TRACK) == 2
@@ -364,6 +381,7 @@ async def test_prev_next_track(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: ent_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(ent_id)
assert state.attributes.get(ATTR_MEDIA_EPISODE) == "2"
@@ -373,6 +391,7 @@ async def test_prev_next_track(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: ent_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(ent_id)
assert state.attributes.get(ATTR_MEDIA_EPISODE) == "1"
@@ -418,6 +437,7 @@ async def test_play_media(hass: HomeAssistant) -> None:
},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(ent_id)
assert (
MediaPlayerEntityFeature.PLAY_MEDIA
@@ -479,6 +499,7 @@ async def test_stop(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == STATE_OFF
@@ -552,6 +573,7 @@ async def test_grouping(hass: HomeAssistant) -> None:
},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(walkman)
assert state.attributes.get(ATTR_GROUP_MEMBERS) == [walkman, kitchen]
@@ -561,6 +583,7 @@ async def test_grouping(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: walkman},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(walkman)
assert state.attributes.get(ATTR_GROUP_MEMBERS) == []

View File

@@ -46,6 +46,7 @@ async def test_turn_on(hass: HomeAssistant, switch_entity_id: str) -> None:
{ATTR_ENTITY_ID: switch_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(switch_entity_id)
assert state.state == STATE_OFF
@@ -56,6 +57,7 @@ async def test_turn_on(hass: HomeAssistant, switch_entity_id: str) -> None:
{ATTR_ENTITY_ID: switch_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(switch_entity_id)
assert state.state == STATE_ON
@@ -70,6 +72,7 @@ async def test_turn_off(hass: HomeAssistant, switch_entity_id: str) -> None:
{ATTR_ENTITY_ID: switch_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(switch_entity_id)
assert state.state == STATE_ON
@@ -80,6 +83,7 @@ async def test_turn_off(hass: HomeAssistant, switch_entity_id: str) -> None:
{ATTR_ENTITY_ID: switch_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(switch_entity_id)
assert state.state == STATE_OFF
@@ -93,6 +97,7 @@ async def test_turn_off_without_entity_id(
await hass.services.async_call(
SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "all"}, blocking=True
)
await hass.async_block_till_done()
state = hass.states.get(switch_entity_id)
assert state.state == STATE_ON
@@ -100,6 +105,7 @@ async def test_turn_off_without_entity_id(
await hass.services.async_call(
SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True
)
await hass.async_block_till_done()
state = hass.states.get(switch_entity_id)
assert state.state == STATE_OFF

View File

@@ -38,8 +38,18 @@ def mock_growatt_v1_api():
Methods mocked for switch and number operations:
- min_write_parameter: Called by switch/number entities to change settings
Methods mocked for service operations:
Methods mocked for MIN service operations:
- min_write_time_segment: Called by time segment management services
Methods mocked for SPH device coordinator refresh:
- sph_detail: Provides device state and charge/discharge settings
- sph_energy: Provides energy data and last-update timestamp
Methods mocked for SPH service operations:
- sph_write_ac_charge_times: Called by write_ac_charge_times service
- sph_write_ac_discharge_times: Called by write_ac_discharge_times service
- sph_read_ac_charge_times: Called by read_ac_charge_times service
- sph_read_ac_discharge_times: Called by read_ac_discharge_times service
"""
with patch(
"homeassistant.components.growatt_server.config_flow.growattServer.OpenApiV1",
@@ -136,7 +146,7 @@ def mock_growatt_v1_api():
# Called by switch/number entities during turn_on/turn_off/set_value
mock_v1_api.min_write_parameter.return_value = None
# Called by time segment management services
# Called by MIN time segment management services
# Note: Don't use autospec for this method as it needs to accept variable arguments
mock_v1_api.min_write_time_segment = Mock(
return_value={
@@ -145,6 +155,131 @@ def mock_growatt_v1_api():
}
)
# Called by SPH device coordinator during refresh
mock_v1_api.sph_detail.return_value = {
# Real-time data read by sensor entities
"bmsSOC": 75,
"vbat": 52.4,
"vpv1": 380.0,
"vpv2": 370.0,
"vac1": 230.0,
"pcharge1": 1200,
"pdischarge1": 0,
"pacToGridTotal": 0.5,
"pacToUserR": 0.2,
"fac": 50.0,
"temp1": 35.0,
"temp2": 36.0,
"temp3": 34.0,
"temp4": 33.0,
"temp5": 32.0,
# Charge settings (also used by sph_read_ac_charge_times)
"chargePowerCommand": 100,
"wchargeSOCLowLimit": 90,
"acChargeEnable": 1,
"forcedChargeTimeStart1": "01:00",
"forcedChargeTimeStop1": "05:00",
"forcedChargeStopSwitch1": 1,
"forcedChargeTimeStart2": "00:00",
"forcedChargeTimeStop2": "00:00",
"forcedChargeStopSwitch2": 0,
"forcedChargeTimeStart3": "00:00",
"forcedChargeTimeStop3": "00:00",
"forcedChargeStopSwitch3": 0,
# Discharge settings (also used by sph_read_ac_discharge_times)
"disChargePowerCommand": 100,
"wdisChargeSOCLowLimit": 10,
"forcedDischargeTimeStart1": "10:00",
"forcedDischargeTimeStop1": "16:00",
"forcedDischargeStopSwitch1": 1,
"forcedDischargeTimeStart2": "00:00",
"forcedDischargeTimeStop2": "00:00",
"forcedDischargeStopSwitch2": 0,
"forcedDischargeTimeStart3": "00:00",
"forcedDischargeTimeStop3": "00:00",
"forcedDischargeStopSwitch3": 0,
}
# Called by SPH device coordinator during refresh
mock_v1_api.sph_energy.return_value = {
"ppv1": 800,
"ppv2": 700,
"ppv": 1500,
"echarge1Today": 5.0,
"echarge1Total": 120.0,
"edischarge1Today": 3.0,
"edischarge1Total": 90.0,
"epvtoday": 8.0,
"epvTotal": 2000.0,
"esystemtoday": 10.0,
"eselfToday": 7.5,
"etoUserToday": 1.5,
"etoGridToday": 2.0,
"etogridTotal": 500.0,
"elocalLoadToday": 9.0,
"elocalLoadTotal": 1800.0,
"echarge1": 3.5,
"eChargeToday": 4.5,
"time": "2024-01-15 12:30:00",
}
# Called by read_ac_charge_times service (returns parsed data from cache)
mock_v1_api.sph_read_ac_charge_times.return_value = {
"charge_power": 100,
"charge_stop_soc": 90,
"mains_enabled": True,
"periods": [
{
"period_id": 1,
"start_time": "01:00",
"end_time": "05:00",
"enabled": True,
},
{
"period_id": 2,
"start_time": "00:00",
"end_time": "00:00",
"enabled": False,
},
{
"period_id": 3,
"start_time": "00:00",
"end_time": "00:00",
"enabled": False,
},
],
}
# Called by read_ac_discharge_times service (returns parsed data from cache)
mock_v1_api.sph_read_ac_discharge_times.return_value = {
"discharge_power": 100,
"discharge_stop_soc": 10,
"periods": [
{
"period_id": 1,
"start_time": "10:00",
"end_time": "16:00",
"enabled": True,
},
{
"period_id": 2,
"start_time": "00:00",
"end_time": "00:00",
"enabled": False,
},
{
"period_id": 3,
"start_time": "00:00",
"end_time": "00:00",
"enabled": False,
},
],
}
# Called by write_ac_charge_times / write_ac_discharge_times services
mock_v1_api.sph_write_ac_charge_times = Mock(return_value=None)
mock_v1_api.sph_write_ac_discharge_times = Mock(return_value=None)
yield mock_v1_api

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,57 @@
# serializer version: 1
# name: test_read_ac_charge_times
dict({
'charge_power': 100,
'charge_stop_soc': 90,
'mains_enabled': True,
'periods': list([
dict({
'enabled': True,
'end_time': '05:00',
'period_id': 1,
'start_time': '01:00',
}),
dict({
'enabled': False,
'end_time': '00:00',
'period_id': 2,
'start_time': '00:00',
}),
dict({
'enabled': False,
'end_time': '00:00',
'period_id': 3,
'start_time': '00:00',
}),
]),
})
# ---
# name: test_read_ac_discharge_times
dict({
'discharge_power': 100,
'discharge_stop_soc': 10,
'periods': list([
dict({
'enabled': True,
'end_time': '16:00',
'period_id': 1,
'start_time': '10:00',
}),
dict({
'enabled': False,
'end_time': '00:00',
'period_id': 2,
'start_time': '00:00',
}),
dict({
'enabled': False,
'end_time': '00:00',
'period_id': 3,
'start_time': '00:00',
}),
]),
})
# ---
# name: test_read_time_segments_single_device
dict({
'time_segments': list([

View File

@@ -563,8 +563,8 @@ async def test_v1_api_unsupported_device_type(
# Return mix of MIN (type 7) and other device types
mock_growatt_v1_api.device_list.return_value = {
"devices": [
{"device_sn": "MIN123456", "type": 7}, # Supported
{"device_sn": "TLX789012", "type": 5}, # Unsupported
{"device_sn": "MIN123456", "type": 7}, # Supported (MIN)
{"device_sn": "UNK999999", "type": 3}, # Unsupported
]
}
@@ -572,7 +572,7 @@ async def test_v1_api_unsupported_device_type(
assert mock_config_entry.state is ConfigEntryState.LOADED
# Verify warning was logged for unsupported device
assert "Device TLX789012 with type 5 not supported in Open API V1" in caplog.text
assert "Device UNK999999 with type 3 not supported in Open API V1" in caplog.text
async def test_migrate_version_bump(

View File

@@ -17,6 +17,57 @@ from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.freeze_time("2024-01-15 12:30:00")
async def test_sph_sensors_v1_api(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test SPH device sensor entities with V1 API."""
mock_growatt_v1_api.device_list.return_value = {
"devices": [{"device_sn": "SPH123456", "type": 5}]
}
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_sph_sensor_unavailable_on_coordinator_error(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test SPH sensors become unavailable when coordinator fails."""
mock_growatt_v1_api.device_list.return_value = {
"devices": [{"device_sn": "SPH123456", "type": 5}]
}
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
state = hass.states.get("sensor.sph123456_state_of_charge")
assert state is not None
assert state.state != STATE_UNAVAILABLE
mock_growatt_v1_api.sph_detail.side_effect = growattServer.GrowattV1ApiError(
"Connection timeout"
)
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get("sensor.sph123456_state_of_charge")
assert state is not None
assert state.state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_min_sensors_v1_api(
hass: HomeAssistant,

View File

@@ -1,5 +1,6 @@
"""Test Growatt Server services."""
import datetime as dt
from unittest.mock import patch
import growattServer
@@ -675,3 +676,487 @@ async def test_service_with_unloaded_config_entry(
},
blocking=True,
)
# ---------------------------------------------------------------------------
# SPH device service tests
# ---------------------------------------------------------------------------
async def _setup_sph_integration(
hass: HomeAssistant,
mock_config_entry,
mock_growatt_v1_api,
) -> None:
"""Set up the integration with a single SPH device."""
mock_growatt_v1_api.device_list.return_value = {
"devices": [{"device_sn": "SPH123456", "type": 5}]
}
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
@pytest.mark.usefixtures("mock_growatt_v1_api")
async def test_read_ac_charge_times(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_growatt_v1_api,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test reading AC charge times from SPH device."""
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
assert device_entry is not None
response = await hass.services.async_call(
DOMAIN,
"read_ac_charge_times",
{"device_id": device_entry.id},
blocking=True,
return_response=True,
)
assert response == snapshot
@pytest.mark.usefixtures("mock_growatt_v1_api")
async def test_read_ac_discharge_times(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_growatt_v1_api,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test reading AC discharge times from SPH device."""
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
assert device_entry is not None
response = await hass.services.async_call(
DOMAIN,
"read_ac_discharge_times",
{"device_id": device_entry.id},
blocking=True,
return_response=True,
)
assert response == snapshot
async def test_write_ac_charge_times(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_growatt_v1_api,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test writing AC charge times to SPH device."""
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
assert device_entry is not None
await hass.services.async_call(
DOMAIN,
"write_ac_charge_times",
{
"device_id": device_entry.id,
"charge_power": 80,
"charge_stop_soc": 95,
"mains_enabled": True,
"period_1_start": "02:00",
"period_1_end": "06:00",
"period_1_enabled": True,
},
blocking=True,
)
mock_growatt_v1_api.sph_write_ac_charge_times.assert_called_once()
async def test_write_ac_charge_times_with_seconds_format(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_growatt_v1_api,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test writing AC charge times with HH:MM:SS format from UI time selector."""
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
assert device_entry is not None
await hass.services.async_call(
DOMAIN,
"write_ac_charge_times",
{
"device_id": device_entry.id,
"charge_power": 100,
"charge_stop_soc": 90,
"mains_enabled": False,
"period_1_start": "02:00:00",
"period_1_end": "06:00:00",
"period_1_enabled": True,
},
blocking=True,
)
mock_growatt_v1_api.sph_write_ac_charge_times.assert_called_once()
async def test_write_ac_discharge_times(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_growatt_v1_api,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test writing AC discharge times to SPH device."""
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
assert device_entry is not None
await hass.services.async_call(
DOMAIN,
"write_ac_discharge_times",
{
"device_id": device_entry.id,
"discharge_power": 75,
"discharge_stop_soc": 20,
"period_1_start": "11:00",
"period_1_end": "15:00",
"period_1_enabled": True,
},
blocking=True,
)
mock_growatt_v1_api.sph_write_ac_discharge_times.assert_called_once()
async def test_write_ac_charge_times_api_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_growatt_v1_api,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test handling API error when writing AC charge times."""
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
assert device_entry is not None
mock_growatt_v1_api.sph_write_ac_charge_times.side_effect = (
growattServer.GrowattV1ApiError("Write failed", error_code=1, error_msg="Error")
)
with pytest.raises(HomeAssistantError, match="API error updating AC charge times"):
await hass.services.async_call(
DOMAIN,
"write_ac_charge_times",
{
"device_id": device_entry.id,
"charge_power": 100,
"charge_stop_soc": 90,
"mains_enabled": True,
},
blocking=True,
)
async def test_write_ac_discharge_times_api_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_growatt_v1_api,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test handling API error when writing AC discharge times."""
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
assert device_entry is not None
mock_growatt_v1_api.sph_write_ac_discharge_times.side_effect = (
growattServer.GrowattV1ApiError("Write failed", error_code=1, error_msg="Error")
)
with pytest.raises(
HomeAssistantError, match="API error updating AC discharge times"
):
await hass.services.async_call(
DOMAIN,
"write_ac_discharge_times",
{
"device_id": device_entry.id,
"discharge_power": 100,
"discharge_stop_soc": 10,
},
blocking=True,
)
async def test_write_ac_charge_times_invalid_charge_power(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_growatt_v1_api,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test validation of charge_power range."""
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
assert device_entry is not None
with pytest.raises(
ServiceValidationError, match="charge_power must be between 0 and 100"
):
await hass.services.async_call(
DOMAIN,
"write_ac_charge_times",
{
"device_id": device_entry.id,
"charge_power": 150,
"charge_stop_soc": 90,
"mains_enabled": True,
},
blocking=True,
)
async def test_write_ac_charge_times_invalid_charge_stop_soc(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_growatt_v1_api,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test validation of charge_stop_soc range."""
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
assert device_entry is not None
with pytest.raises(
ServiceValidationError, match="charge_stop_soc must be between 0 and 100"
):
await hass.services.async_call(
DOMAIN,
"write_ac_charge_times",
{
"device_id": device_entry.id,
"charge_power": 100,
"charge_stop_soc": 110,
"mains_enabled": True,
},
blocking=True,
)
async def test_write_ac_discharge_times_invalid_discharge_power(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_growatt_v1_api,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test validation of discharge_power range."""
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
assert device_entry is not None
with pytest.raises(
ServiceValidationError, match="discharge_power must be between 0 and 100"
):
await hass.services.async_call(
DOMAIN,
"write_ac_discharge_times",
{
"device_id": device_entry.id,
"discharge_power": 200,
"discharge_stop_soc": 10,
},
blocking=True,
)
async def test_write_ac_discharge_times_invalid_discharge_stop_soc(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_growatt_v1_api,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test validation of discharge_stop_soc range."""
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
assert device_entry is not None
with pytest.raises(
ServiceValidationError, match="discharge_stop_soc must be between 0 and 100"
):
await hass.services.async_call(
DOMAIN,
"write_ac_discharge_times",
{
"device_id": device_entry.id,
"discharge_power": 100,
"discharge_stop_soc": -5,
},
blocking=True,
)
async def test_write_ac_charge_times_invalid_period_time(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_growatt_v1_api,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test validation of invalid period time format."""
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
assert device_entry is not None
with pytest.raises(
ServiceValidationError,
match="period_1_start must be in HH:MM or HH:MM:SS format",
):
await hass.services.async_call(
DOMAIN,
"write_ac_charge_times",
{
"device_id": device_entry.id,
"charge_power": 100,
"charge_stop_soc": 90,
"mains_enabled": True,
"period_1_start": "invalid",
"period_1_end": "06:00",
},
blocking=True,
)
async def test_no_sph_devices_fails_gracefully(
hass: HomeAssistant,
mock_config_entry_classic: MockConfigEntry,
mock_growatt_classic_api,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test that SPH services fail gracefully when no SPH devices exist."""
mock_growatt_classic_api.device_list.return_value = [
{"deviceSn": "TLX123456", "deviceType": "tlx"}
]
mock_config_entry_classic.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry_classic.entry_id)
await hass.async_block_till_done()
assert hass.services.has_service(DOMAIN, "write_ac_charge_times")
assert hass.services.has_service(DOMAIN, "read_ac_charge_times")
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "TLX123456")})
assert device_entry is not None
with pytest.raises(
ServiceValidationError, match="No SPH devices with token authentication"
):
await hass.services.async_call(
DOMAIN,
"write_ac_charge_times",
{
"device_id": device_entry.id,
"charge_power": 100,
"charge_stop_soc": 90,
"mains_enabled": True,
},
blocking=True,
)
async def test_sph_service_with_non_sph_growatt_device(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_growatt_v1_api,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test SPH service called with a non-SPH Growatt device."""
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
# Manually register a MIN device (not SPH)
min_device = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={(DOMAIN, "MIN999999")},
name="MIN Device",
)
with pytest.raises(
ServiceValidationError,
match="SPH device 'MIN999999' not found or not configured for services",
):
await hass.services.async_call(
DOMAIN,
"write_ac_charge_times",
{
"device_id": min_device.id,
"charge_power": 100,
"charge_stop_soc": 90,
"mains_enabled": True,
},
blocking=True,
)
async def test_write_ac_charge_times_uses_cached_periods_for_unspecified(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_growatt_v1_api,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test that unspecified periods are filled from cached settings."""
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
assert device_entry is not None
# Only override period 1; periods 2 and 3 should come from cache (all 00:00)
await hass.services.async_call(
DOMAIN,
"write_ac_charge_times",
{
"device_id": device_entry.id,
"charge_power": 100,
"charge_stop_soc": 90,
"mains_enabled": True,
"period_1_start": "03:00",
"period_1_end": "07:00",
"period_1_enabled": True,
},
blocking=True,
)
# Verify the API was called with datetime.time objects (not strings)
mock_growatt_v1_api.sph_write_ac_charge_times.assert_called_once_with(
"SPH123456",
100,
90,
True,
[
{
"start_time": dt.time(3, 0),
"end_time": dt.time(7, 0),
"enabled": True,
},
{
"start_time": dt.time(0, 0),
"end_time": dt.time(0, 0),
"enabled": False,
},
{
"start_time": dt.time(0, 0),
"end_time": dt.time(0, 0),
"enabled": False,
},
],
)

View File

@@ -74,6 +74,94 @@ async def test_user_flow(hass: HomeAssistant) -> None:
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
async def test_user_flow_with_no_2fa(hass: HomeAssistant) -> None:
"""Test user flow with no 2FA required and device registration."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
with patch(
"homeassistant.components.hive.config_flow.Auth.login",
return_value={
"ChallengeName": "SUCCESS",
"AuthenticationResult": {
"RefreshToken": "mock-refresh-token",
"AccessToken": "mock-access-token",
"NewDeviceMetadata": {
"DeviceGroupKey": "mock-device-group-key",
"DeviceKey": "mock-device-key",
},
},
},
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "configuration"
assert result2["errors"] == {}
with (
patch(
"homeassistant.components.hive.config_flow.Auth.device_registration",
return_value=True,
),
patch(
"homeassistant.components.hive.config_flow.Auth.get_device_data",
return_value=[
"mock-device-group-key",
"mock-device-key",
"mock-device-password",
],
),
patch(
"homeassistant.components.hive.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_DEVICE_NAME: DEVICE_NAME,
},
)
await hass.async_block_till_done()
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == USERNAME
assert result3["data"] == {
CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD,
"tokens": {
"AuthenticationResult": {
"AccessToken": "mock-access-token",
"RefreshToken": "mock-refresh-token",
"NewDeviceMetadata": {
"DeviceGroupKey": "mock-device-group-key",
"DeviceKey": "mock-device-key",
},
},
"ChallengeName": "SUCCESS",
},
"device_data": [
"mock-device-group-key",
"mock-device-key",
"mock-device-password",
],
}
assert len(mock_setup_entry.mock_calls) == 1
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
async def test_user_flow_2fa(hass: HomeAssistant) -> None:
"""Test user flow with 2FA."""
result = await hass.config_entries.flow.async_init(

View File

@@ -22,7 +22,7 @@ async def test_hmip_load_all_supported_devices(
test_devices=None, test_groups=None
)
assert len(mock_hap.hmip_device_by_entity_id) == 350
assert len(mock_hap.hmip_device_by_entity_id) == 351
async def test_hmip_remove_device(

View File

@@ -854,3 +854,59 @@ async def test_hmip_wired_push_button_led_2(
assert hmip_device.mock_calls[-1][0] == "set_optical_signal_async"
assert hmip_device.mock_calls[-1][2]["channelIndex"] == 8
assert len(hmip_device.mock_calls) == service_call_counter + 1
async def test_hmip_combination_signalling_light(
hass: HomeAssistant, default_mock_hap_factory: HomeFactory
) -> None:
"""Test HomematicipCombinationSignallingLight (HmIP-MP3P)."""
entity_id = "light.kombisignalmelder"
entity_name = "Kombisignalmelder"
device_model = "HmIP-MP3P"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
test_devices=["Kombisignalmelder"]
)
ha_state, hmip_device = get_and_check_entity_basics(
hass, mock_hap, entity_id, entity_name, device_model
)
# Fixture has dimLevel=0.5, simpleRGBColorState=RED, on=true
assert ha_state.state == STATE_ON
assert ha_state.attributes[ATTR_COLOR_MODE] == ColorMode.HS
assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS]
assert ha_state.attributes[ATTR_SUPPORTED_FEATURES] == 0
assert ha_state.attributes[ATTR_BRIGHTNESS] == 127 # 0.5 * 255
assert ha_state.attributes[ATTR_HS_COLOR] == (0.0, 100.0) # RED
functional_channel = hmip_device.functionalChannels[1]
service_call_counter = len(functional_channel.mock_calls)
# Test turn_on with color and brightness
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": entity_id, ATTR_HS_COLOR: [240.0, 100.0], ATTR_BRIGHTNESS: 255},
blocking=True,
)
assert functional_channel.mock_calls[-1][0] == "set_rgb_dim_level_async"
assert functional_channel.mock_calls[-1][2] == {
"rgb_color_state": "BLUE",
"dim_level": 1.0,
}
assert len(functional_channel.mock_calls) == service_call_counter + 1
# Test turn_off
await hass.services.async_call(
"light",
"turn_off",
{"entity_id": entity_id},
blocking=True,
)
assert functional_channel.mock_calls[-1][0] == "turn_off_async"
assert len(functional_channel.mock_calls) == service_call_counter + 2
# Test state update when turned off
await async_manipulate_test_data(hass, hmip_device, "on", False, channel=1)
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_OFF

View File

@@ -64,6 +64,7 @@ async def test_turn_on(hass: HomeAssistant, switch_entity_id: str) -> None:
{ATTR_ENTITY_ID: switch_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(switch_entity_id)
assert state.state == STATE_OFF
@@ -74,6 +75,7 @@ async def test_turn_on(hass: HomeAssistant, switch_entity_id: str) -> None:
{ATTR_ENTITY_ID: switch_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(switch_entity_id)
assert state.state == STATE_ON
@@ -88,6 +90,7 @@ async def test_turn_off(hass: HomeAssistant, switch_entity_id: str) -> None:
{ATTR_ENTITY_ID: switch_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(switch_entity_id)
assert state.state == STATE_ON
@@ -98,6 +101,7 @@ async def test_turn_off(hass: HomeAssistant, switch_entity_id: str) -> None:
{ATTR_ENTITY_ID: switch_entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(switch_entity_id)
assert state.state == STATE_OFF

View File

@@ -281,3 +281,15 @@ async def test_dynamic_devices(
# Third check -> removed 1 device
assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 1
mock_account.robots.pop(0)
with patch(
"homeassistant.components.litterrobot.coordinator.Account",
return_value=mock_account,
):
await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
# Fourth check -> removed 1 device after reload
assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 0

View File

@@ -188,6 +188,8 @@ async def test_update_file_path(
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get("camera.local_file")
assert state.attributes.get("file_path") == "new/path.jpg"

View File

@@ -0,0 +1,12 @@
"""Tests for the LoJack integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Set up the LoJack integration for testing."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -0,0 +1,107 @@
"""Test fixtures for the LoJack integration."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, create_autospec, patch
from lojack_api import LoJackClient
from lojack_api.device import Vehicle
from lojack_api.models import Location
import pytest
from homeassistant.components.lojack.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import (
TEST_ACCURACY,
TEST_ADDRESS,
TEST_DEVICE_ID,
TEST_DEVICE_NAME,
TEST_HEADING,
TEST_LATITUDE,
TEST_LONGITUDE,
TEST_MAKE,
TEST_MODEL,
TEST_PASSWORD,
TEST_TIMESTAMP,
TEST_USER_ID,
TEST_USERNAME,
TEST_VIN,
TEST_YEAR,
)
from tests.common import MockConfigEntry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_USER_ID,
data={
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
title=f"LoJack ({TEST_USERNAME})",
)
@pytest.fixture
def mock_location() -> Location:
"""Return a mock LoJack location."""
return Location(
latitude=TEST_LATITUDE,
longitude=TEST_LONGITUDE,
accuracy=TEST_ACCURACY,
heading=TEST_HEADING,
address=TEST_ADDRESS,
timestamp=TEST_TIMESTAMP,
)
@pytest.fixture
def mock_device(mock_location: Location) -> MagicMock:
"""Return a mock LoJack device."""
device = create_autospec(Vehicle, instance=True)
device.id = TEST_DEVICE_ID
device.name = TEST_DEVICE_NAME
device.vin = TEST_VIN
device.make = TEST_MAKE
device.model = TEST_MODEL
device.year = TEST_YEAR
device.get_location = AsyncMock(return_value=mock_location)
return device
@pytest.fixture
def mock_lojack_client(
mock_device: MagicMock,
) -> Generator[MagicMock]:
"""Return a mock LoJack client."""
client = create_autospec(LoJackClient, instance=True)
client.user_id = TEST_USER_ID
client.list_devices = AsyncMock(return_value=[mock_device])
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
with (
patch(
"homeassistant.components.lojack.LoJackClient.create",
return_value=client,
),
patch(
"homeassistant.components.lojack.config_flow.LoJackClient.create",
return_value=client,
),
):
yield client
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Mock async_setup_entry."""
with patch(
"homeassistant.components.lojack.async_setup_entry",
return_value=True,
) as mock:
yield mock

View File

@@ -0,0 +1,20 @@
"""Constants for the LoJack integration tests."""
from datetime import datetime
TEST_USERNAME = "test@example.com"
TEST_PASSWORD = "testpassword123"
TEST_DEVICE_ID = "12345"
TEST_DEVICE_NAME = "My Car"
TEST_VIN = "1HGBH41JXMN109186"
TEST_MAKE = "Honda"
TEST_MODEL = "Accord"
TEST_YEAR = 2021
TEST_USER_ID = "user_abc123"
TEST_LATITUDE = 37.7749
TEST_LONGITUDE = -122.4194
TEST_ACCURACY = 10.5
TEST_HEADING = 180.0
TEST_ADDRESS = "123 Main St, San Francisco, CA 94102"
TEST_TIMESTAMP = datetime.fromisoformat("2020-02-02T14:00:00Z")

View File

@@ -0,0 +1,54 @@
# serializer version: 1
# name: test_all_entities[device_tracker.2021_honda_accord-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'device_tracker',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'device_tracker.2021_honda_accord',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'lojack',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '12345',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[device_tracker.2021_honda_accord-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '2021 Honda Accord',
'gps_accuracy': 10,
'latitude': 37.7749,
'longitude': -122.4194,
'source_type': <SourceType.GPS: 'gps'>,
}),
'context': <ANY>,
'entity_id': 'device_tracker.2021_honda_accord',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'not_home',
})
# ---

View File

@@ -0,0 +1,32 @@
# serializer version: 1
# name: test_device_info
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'lojack',
'12345',
),
}),
'labels': set({
}),
'manufacturer': 'Spireon LoJack',
'model': 'Accord',
'model_id': None,
'name': '2021 Honda Accord',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '1HGBH41JXMN109186',
'sw_version': None,
'via_device_id': None,
})
# ---

View File

@@ -0,0 +1,119 @@
"""Tests for the LoJack config flow."""
from unittest.mock import AsyncMock, MagicMock, patch
from lojack_api import ApiError, AuthenticationError
import pytest
from homeassistant.components.lojack.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import TEST_PASSWORD, TEST_USER_ID, TEST_USERNAME
from tests.common import MockConfigEntry
async def test_full_user_flow(
hass: HomeAssistant,
mock_lojack_client: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test the full user configuration flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"LoJack ({TEST_USERNAME})"
assert result["data"] == {
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
}
assert result["result"].unique_id == TEST_USER_ID
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(AuthenticationError("Invalid credentials"), "invalid_auth"),
(ApiError("Connection failed"), "cannot_connect"),
(Exception("Unknown error"), "unknown"),
],
)
async def test_user_flow_errors(
hass: HomeAssistant,
mock_lojack_client: MagicMock,
mock_setup_entry: AsyncMock,
side_effect: Exception,
expected_error: str,
) -> None:
"""Test error handling and recovery in the user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
with patch(
"homeassistant.components.lojack.config_flow.LoJackClient.create",
side_effect=side_effect,
):
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["step_id"] == "user"
assert result["errors"] == {"base": expected_error}
# Verify flow recovers after error
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_user_flow_already_configured(
hass: HomeAssistant,
mock_lojack_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that duplicate accounts are rejected."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@@ -0,0 +1,56 @@
"""Tests for the LoJack device tracker platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock
from freezegun.api import FrozenDateTimeFactory
from lojack_api import ApiError
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.lojack.const import DEFAULT_UPDATE_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
ENTITY_ID = "device_tracker.2021_honda_accord"
async def test_all_entities(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lojack_client: MagicMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test all device tracker entities are created."""
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_device_tracker_becomes_unavailable_on_api_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lojack_client: MagicMock,
mock_device: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test device tracker becomes unavailable when coordinator update fails."""
await setup_integration(hass, mock_config_entry)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state != "unavailable"
mock_device.get_location = AsyncMock(side_effect=ApiError("API unavailable"))
freezer.tick(timedelta(minutes=DEFAULT_UPDATE_INTERVAL))
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 == "unavailable"

View File

@@ -0,0 +1,156 @@
"""Tests for the LoJack integration setup."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from lojack_api import ApiError, AuthenticationError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.lojack.const import DEFAULT_UPDATE_INTERVAL, DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import setup_integration
from .const import TEST_DEVICE_ID
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_setup_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lojack_client: MagicMock,
) -> None:
"""Test successful setup of the integration."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert hass.states.get("device_tracker.2021_honda_accord") is not None
@pytest.mark.parametrize(
("side_effect", "expected_state"),
[
(AuthenticationError("Invalid credentials"), ConfigEntryState.SETUP_ERROR),
(ApiError("Connection failed"), ConfigEntryState.SETUP_RETRY),
],
)
async def test_setup_entry_create_errors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
side_effect: Exception,
expected_state: ConfigEntryState,
) -> None:
"""Test setup failure when LoJackClient.create raises an error."""
with patch(
"homeassistant.components.lojack.LoJackClient.create",
side_effect=side_effect,
):
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is expected_state
@pytest.mark.parametrize(
("side_effect", "expected_state"),
[
(AuthenticationError("Invalid credentials"), ConfigEntryState.SETUP_ERROR),
(ApiError("Connection failed"), ConfigEntryState.SETUP_RETRY),
],
)
async def test_setup_entry_list_devices_errors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lojack_client: MagicMock,
side_effect: Exception,
expected_state: ConfigEntryState,
) -> None:
"""Test setup failure when list_devices raises an error."""
mock_lojack_client.list_devices = AsyncMock(side_effect=side_effect)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is expected_state
async def test_setup_entry_no_vehicles(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lojack_client: MagicMock,
) -> None:
"""Test integration loads successfully with no vehicles."""
mock_lojack_client.list_devices = AsyncMock(return_value=[])
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert len(hass.states.async_entity_ids("device_tracker")) == 0
async def test_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lojack_client: MagicMock,
) -> None:
"""Test successful unload of the integration."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_coordinator_update_auth_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lojack_client: MagicMock,
mock_device: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test entry stays loaded and reauth is triggered on auth error during polling."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
mock_device.get_location = AsyncMock(
side_effect=AuthenticationError("Token expired")
)
freezer.tick(timedelta(minutes=DEFAULT_UPDATE_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Entry stays loaded; HA initiates a reauth flow
assert mock_config_entry.state is ConfigEntryState.LOADED
assert len(hass.config_entries.flow.async_progress()) == 1
flow = hass.config_entries.flow.async_progress()[0]
assert flow["flow_id"] is not None
assert flow["handler"] == DOMAIN
assert flow["context"]["source"] == "reauth"
async def test_device_info(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lojack_client: MagicMock,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test device registry entry is created."""
await setup_integration(hass, mock_config_entry)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, TEST_DEVICE_ID)}
)
assert device_entry is not None
assert device_entry == snapshot

View File

@@ -141,6 +141,127 @@ async def test_update_check_service(
)
@pytest.mark.parametrize("node_fixture", ["mock_dimmable_light"])
async def test_update_check_with_same_version_string(
hass: HomeAssistant,
matter_client: MagicMock,
check_node_update: AsyncMock,
matter_node: MatterNode,
) -> None:
"""Test update detection when versions share the same display string."""
set_node_attribute_typed(
matter_node,
0,
clusters.BasicInformation.Attributes.SoftwareVersion,
115,
)
set_node_attribute_typed(
matter_node,
0,
clusters.BasicInformation.Attributes.SoftwareVersionString,
"1.1.5",
)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_OFF
assert state.attributes.get("installed_version") == "1.1.5"
await async_setup_component(hass, HA_DOMAIN, {})
check_node_update.return_value = MatterSoftwareVersion(
vid=65521,
pid=32768,
software_version=1150,
software_version_string="1.1.5",
firmware_information="",
min_applicable_software_version=0,
max_applicable_software_version=115,
release_notes_url="http://home-assistant.io/non-existing-product",
update_source=UpdateSource.LOCAL,
)
await hass.services.async_call(
HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
{
ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware",
},
blocking=True,
)
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_ON
assert state.attributes.get("installed_version") == "1.1.5"
assert state.attributes.get("latest_version") == "1.1.5 (1150)"
assert (
state.attributes.get("release_url")
== "http://home-assistant.io/non-existing-product"
)
check_node_update.return_value = None
await hass.services.async_call(
HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
{
ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware",
},
blocking=True,
)
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_OFF
assert state.attributes.get("latest_version") == "1.1.5"
assert state.attributes.get("release_url") is None
@pytest.mark.parametrize("node_fixture", ["mock_dimmable_light"])
async def test_update_check_with_same_numeric_version(
hass: HomeAssistant,
matter_client: MagicMock,
check_node_update: AsyncMock,
matter_node: MatterNode,
) -> None:
"""Test update detection when numeric version is unchanged."""
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_OFF
assert state.attributes.get("installed_version") == "v1.0"
await async_setup_component(hass, HA_DOMAIN, {})
check_node_update.return_value = MatterSoftwareVersion(
vid=65521,
pid=32768,
software_version=1,
software_version_string="1.0.0",
firmware_information="",
min_applicable_software_version=0,
max_applicable_software_version=1,
release_notes_url="http://home-assistant.io/non-existing-product",
update_source=UpdateSource.LOCAL,
)
await hass.services.async_call(
HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
{
ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware",
},
blocking=True,
)
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_OFF
assert state.attributes.get("installed_version") == "v1.0"
assert state.attributes.get("latest_version") == "v1.0"
@pytest.mark.parametrize("node_fixture", ["mock_dimmable_light"])
async def test_update_install(
hass: HomeAssistant,

View File

@@ -475,6 +475,7 @@ async def test_group_members_available_when_off(hass: HomeAssistant) -> None:
{ATTR_ENTITY_ID: "media_player.group"},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get("media_player.group")
assert state.state == STATE_OFF

View File

@@ -1,12 +1,13 @@
"""Tests Ollama integration."""
from copy import deepcopy
from typing import Any
from unittest.mock import patch
import pytest
from homeassistant.components import ollama
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API
from homeassistant.core import HomeAssistant
from homeassistant.helpers import llm
from homeassistant.setup import async_setup_component
@@ -22,14 +23,31 @@ def mock_config_entry_options() -> dict[str, Any]:
return TEST_OPTIONS
@pytest.fixture
def has_token() -> bool:
"""Fixture to indicate if the config entry has a token."""
return False
@pytest.fixture
def mock_config_entry_data(has_token: bool) -> dict[str, Any]:
"""Fixture for configuration entry data."""
res = deepcopy(TEST_USER_DATA)
if has_token:
res[CONF_API_KEY] = "test_token"
return res
@pytest.fixture
def mock_config_entry(
hass: HomeAssistant, mock_config_entry_options: dict[str, Any]
hass: HomeAssistant,
mock_config_entry_options: dict[str, Any],
mock_config_entry_data: dict[str, Any],
) -> MockConfigEntry:
"""Mock a config entry."""
entry = MockConfigEntry(
domain=ollama.DOMAIN,
data=TEST_USER_DATA,
data=mock_config_entry_data,
version=3,
minor_version=2,
subentries_data=[

View File

@@ -1,14 +1,17 @@
"""Test the Ollama config flow."""
import asyncio
from unittest.mock import patch
from unittest.mock import ANY, AsyncMock, patch
from httpx import ConnectError
from ollama import ResponseError
import pytest
from homeassistant import config_entries
from homeassistant.components import ollama
from homeassistant.const import CONF_LLM_HASS_API, CONF_NAME
from homeassistant.components.ollama.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -20,14 +23,14 @@ TEST_MODEL = "test_model:latest"
async def test_form(hass: HomeAssistant) -> None:
"""Test flow when configuring URL only."""
# Pretend we already set up a config entry.
hass.config.components.add(ollama.DOMAIN)
hass.config.components.add(DOMAIN)
MockConfigEntry(
domain=ollama.DOMAIN,
domain=DOMAIN,
state=config_entries.ConfigEntryState.LOADED,
).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
ollama.DOMAIN, context={"source": config_entries.SOURCE_USER}
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
@@ -48,18 +51,18 @@ async def test_form(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["data"] == {
ollama.CONF_URL: "http://localhost:11434",
}
assert result2["data"] == {ollama.CONF_URL: "http://localhost:11434"}
# No subentries created by default
assert len(result2.get("subentries", [])) == 0
assert len(mock_setup_entry.mock_calls) == 1
assert CONF_API_KEY not in result2["data"]
async def test_duplicate_entry(hass: HomeAssistant) -> None:
"""Test we abort on duplicate config entry."""
MockConfigEntry(
domain=ollama.DOMAIN,
domain=DOMAIN,
data={
ollama.CONF_URL: "http://localhost:11434",
ollama.CONF_MODEL: "test_model",
@@ -67,7 +70,7 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None:
).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
ollama.DOMAIN, context={"source": config_entries.SOURCE_USER}
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
@@ -141,7 +144,7 @@ async def test_creating_new_conversation_subentry(
):
new_flow = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "conversation"),
context={"source": config_entries.SOURCE_USER},
context={"source": SOURCE_USER},
)
assert new_flow["type"] is FlowResultType.FORM
@@ -181,7 +184,7 @@ async def test_creating_conversation_subentry_not_loaded(
await hass.config_entries.async_unload(mock_config_entry.entry_id)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "conversation"),
context={"source": config_entries.SOURCE_USER},
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.ABORT
@@ -209,7 +212,7 @@ async def test_subentry_need_download(
):
new_flow = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "conversation"),
context={"source": config_entries.SOURCE_USER},
context={"source": SOURCE_USER},
)
assert new_flow["type"] is FlowResultType.FORM, new_flow
@@ -271,7 +274,7 @@ async def test_subentry_download_error(
):
new_flow = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "conversation"),
context={"source": config_entries.SOURCE_USER},
context={"source": SOURCE_USER},
)
assert new_flow["type"] is FlowResultType.FORM
@@ -307,6 +310,130 @@ async def test_subentry_download_error(
assert result["reason"] == "download_failed"
@pytest.mark.parametrize(
("init_data", "input_data", "expected_data"),
[
(
{
CONF_URL: "http://localhost:11434",
CONF_API_KEY: "old-api-key",
},
{
CONF_API_KEY: "new-api-key",
},
{
CONF_URL: "http://localhost:11434",
CONF_API_KEY: "new-api-key",
},
),
(
{
CONF_URL: "http://localhost:11434",
CONF_API_KEY: "old-api-key",
},
{
# Reconfigure without api_key to test that it gets removed from data
},
{
CONF_URL: "http://localhost:11434",
},
),
],
)
async def test_reauth_flow_success(
hass: HomeAssistant, init_data, input_data, expected_data
) -> None:
"""Test successful reauthentication flow."""
entry = MockConfigEntry(
domain=DOMAIN,
data=init_data,
options={CONF_API_KEY: "stale-options-api-key"},
version=3,
minor_version=3,
)
entry.add_to_hass(hass)
result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
with patch(
"homeassistant.components.ollama.config_flow.ollama.AsyncClient.list",
return_value={"models": [{"model": TEST_MODEL}]},
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
input_data,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert entry.data == expected_data
assert entry.options == {}
@pytest.mark.parametrize(
("side_effect", "error"),
[
(ResponseError(error="Unauthorized", status_code=401), "invalid_auth"),
(ConnectError(message="Connection failed"), "cannot_connect"),
],
)
async def test_reauth_flow_errors(hass: HomeAssistant, side_effect, error) -> None:
"""Test reauthentication flow when authentication fails."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_URL: "http://localhost:11434",
CONF_API_KEY: "old-api-key",
},
version=3,
minor_version=3,
)
entry.add_to_hass(hass)
result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
with patch(
"homeassistant.components.ollama.config_flow.ollama.AsyncClient.list",
side_effect=side_effect,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: "other-api-key",
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {"base": error}
with patch(
"homeassistant.components.ollama.config_flow.ollama.AsyncClient.list",
return_value={"models": [{"model": TEST_MODEL}]},
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: "new-api-key",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert entry.data == {
CONF_URL: "http://localhost:11434",
CONF_API_KEY: "new-api-key",
}
@pytest.mark.parametrize(
("side_effect", "error"),
[
@@ -317,7 +444,7 @@ async def test_subentry_download_error(
async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None:
"""Test we handle errors."""
result = await hass.config_entries.flow.async_init(
ollama.DOMAIN, context={"source": config_entries.SOURCE_USER}
DOMAIN, context={"source": SOURCE_USER}
)
with patch(
@@ -332,10 +459,50 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None:
assert result2["errors"] == {"base": error}
@pytest.mark.parametrize(
("side_effect", "error"),
[
(ConnectError(message=""), "cannot_connect"),
(RuntimeError(), "unknown"),
],
)
async def test_form_errors_recovery(hass: HomeAssistant, side_effect, error) -> None:
"""Test that the user flow recovers after an error and completes successfully."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
# First attempt fails
with patch(
"homeassistant.components.ollama.config_flow.ollama.AsyncClient.list",
side_effect=side_effect,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {ollama.CONF_URL: "http://localhost:11434"}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
# Second attempt succeeds
with patch(
"homeassistant.components.ollama.config_flow.ollama.AsyncClient.list",
return_value={"models": [{"model": TEST_MODEL}]},
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{ollama.CONF_URL: "http://localhost:11434"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {ollama.CONF_URL: "http://localhost:11434"}
async def test_form_invalid_url(hass: HomeAssistant) -> None:
"""Test we handle invalid URL."""
result = await hass.config_entries.flow.async_init(
ollama.DOMAIN, context={"source": config_entries.SOURCE_USER}
DOMAIN, context={"source": SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
@@ -358,7 +525,7 @@ async def test_subentry_connection_error(
):
new_flow = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "conversation"),
context={"source": config_entries.SOURCE_USER},
context={"source": SOURCE_USER},
)
assert new_flow["type"] is FlowResultType.ABORT
@@ -380,7 +547,7 @@ async def test_subentry_model_check_exception(
):
new_flow = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "conversation"),
context={"source": config_entries.SOURCE_USER},
context={"source": SOURCE_USER},
)
assert new_flow["type"] is FlowResultType.FORM
@@ -500,7 +667,7 @@ async def test_creating_ai_task_subentry(
):
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "ai_task_data"),
context={"source": config_entries.SOURCE_USER},
context={"source": SOURCE_USER},
)
assert result.get("type") is FlowResultType.FORM
@@ -552,8 +719,167 @@ async def test_ai_task_subentry_not_loaded(
# Don't call mock_init_component to simulate not loaded state
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "ai_task_data"),
context={"source": config_entries.SOURCE_USER},
context={"source": SOURCE_USER},
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "entry_not_loaded"
@pytest.mark.parametrize(
("user_input", "expected_headers", "expected_data"),
[
(
{CONF_URL: "http://localhost:11434", CONF_API_KEY: "my-secret-token"},
{"Authorization": "Bearer my-secret-token"},
{CONF_URL: "http://localhost:11434", CONF_API_KEY: "my-secret-token"},
),
(
{CONF_URL: "http://localhost:11434", CONF_API_KEY: ""},
None,
{CONF_URL: "http://localhost:11434"},
),
(
{CONF_URL: "http://localhost:11434", CONF_API_KEY: " "},
None,
{CONF_URL: "http://localhost:11434"},
),
(
{CONF_URL: "http://localhost:11434"},
None,
{CONF_URL: "http://localhost:11434"},
),
],
)
async def test_user_step_async_client_headers(
hass: HomeAssistant,
user_input: dict[str, str],
expected_headers: dict[str, str] | None,
expected_data: dict[str, str],
) -> None:
"""Test Authorization header passed to AsyncClient with/without api_key."""
with patch(
"homeassistant.components.ollama.config_flow.ollama.AsyncClient",
) as mock_async_client:
mock_async_client.return_value.list = AsyncMock(return_value={"models": []})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=user_input,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == expected_data
mock_async_client.assert_called_with(
host="http://localhost:11434",
headers=expected_headers,
verify=ANY,
)
@pytest.mark.parametrize(
("status_code", "error", "error_message", "user_input"),
[
(
400,
"unknown",
"Bad Request",
{
CONF_URL: "http://localhost:11434",
CONF_API_KEY: "my-secret-token",
},
),
(
401,
"invalid_auth",
"Unauthorized",
{
CONF_URL: "http://localhost:11434",
CONF_API_KEY: "my-secret-token",
},
),
(
403,
"invalid_auth",
"Unauthorized",
{
CONF_URL: "http://localhost:11434",
CONF_API_KEY: "my-secret-token",
},
),
(
403,
"invalid_auth",
"Forbidden",
{
CONF_URL: "http://localhost:11434",
},
),
],
)
async def test_user_step_errors(
hass: HomeAssistant,
status_code: int,
error: str,
error_message: str,
user_input: dict[str, str],
) -> None:
"""Test error handling when ollama returns HTTP 4xx."""
with patch(
"homeassistant.components.ollama.config_flow.ollama.AsyncClient"
) as mock_async_client:
mock_client_instance = AsyncMock()
mock_async_client.return_value = mock_client_instance
mock_client_instance.list.side_effect = ResponseError(
error=error_message, status_code=status_code
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=user_input,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result.get("errors") == {"base": error}
async def test_user_step_trim_url(hass: HomeAssistant) -> None:
"""Test URL is trimmed before validation and persistence."""
with patch(
"homeassistant.components.ollama.config_flow.ollama.AsyncClient",
) as mock_async_client:
mock_async_client.return_value.list = AsyncMock(return_value={"models": []})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_URL: " http://localhost:11434 ",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {CONF_URL: "http://localhost:11434"}
mock_async_client.assert_called_with(
host="http://localhost:11434",
headers=None,
verify=ANY,
)

View File

@@ -1,14 +1,19 @@
"""Tests for the Ollama integration."""
from typing import Any
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from httpx import ConnectError
from ollama import ResponseError
import pytest
from homeassistant.components import ollama
from homeassistant.components.ollama.const import DOMAIN
from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData
from homeassistant.config_entries import (
ConfigEntryDisabler,
ConfigEntryState,
ConfigSubentryData,
)
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er, llm
@@ -21,7 +26,7 @@ from . import TEST_OPTIONS
from tests.common import MockConfigEntry
V1_TEST_USER_DATA = {
ollama.CONF_URL: "http://localhost:11434",
CONF_URL: "http://localhost:11434",
ollama.CONF_MODEL: "test_model:latest",
}
@@ -58,6 +63,74 @@ async def test_init_error(
assert error in caplog.text
@pytest.mark.parametrize("has_token", [True])
async def test_init_with_api_key(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test initialization with API key - Authorization header should be set."""
# Create entry with API key in data (version 3.0 after migration)
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.ollama.ollama.AsyncClient") as mock_client:
mock_client.return_value.list = AsyncMock(return_value={"models": []})
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert any(
call.kwargs["headers"] == {"Authorization": "Bearer test_token"}
for call in mock_client.call_args_list
)
async def test_init_without_api_key(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test initialization without API key - Authorization header should not be set."""
# Create entry without API key in data (version 3.0 after migration)
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.ollama.ollama.AsyncClient") as mock_client:
mock_client.return_value.list = AsyncMock(return_value={"models": []})
assert await async_setup_component(hass, ollama.DOMAIN, {})
await hass.async_block_till_done()
assert all(
call.kwargs["headers"] is None for call in mock_client.call_args_list
)
@pytest.mark.parametrize(
("status_code", "entry_state"),
[
(401, ConfigEntryState.SETUP_ERROR),
(403, ConfigEntryState.SETUP_ERROR),
(500, ConfigEntryState.SETUP_RETRY),
(429, ConfigEntryState.SETUP_RETRY),
(400, ConfigEntryState.SETUP_ERROR),
],
)
async def test_async_setup_entry_auth_failed_on_response_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
status_code: int,
entry_state: ConfigEntryState,
) -> None:
"""Test async_setup_entry raises auth failed on 401/403 response."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.ollama.ollama.AsyncClient") as mock_client:
mock_client.return_value.list = AsyncMock(
side_effect=ResponseError(error="Unauthorized", status_code=status_code)
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is entry_state
async def test_migration_from_v1(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
@@ -102,7 +175,7 @@ async def test_migration_from_v1(
assert mock_config_entry.version == 3
assert mock_config_entry.minor_version == 3
# After migration, parent entry should only have URL
assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"}
assert mock_config_entry.data == {CONF_URL: "http://localhost:11434"}
assert mock_config_entry.options == {}
assert len(mock_config_entry.subentries) == 2
@@ -748,7 +821,7 @@ async def test_migration_from_v2_2(hass: HomeAssistant) -> None:
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={
ollama.CONF_URL: "http://localhost:11434",
CONF_URL: "http://localhost:11434",
ollama.CONF_MODEL: "test_model:latest", # Model still in main data
},
version=2,
@@ -768,7 +841,7 @@ async def test_migration_from_v2_2(hass: HomeAssistant) -> None:
assert mock_config_entry.minor_version == 3
# Check that model was moved from main data to subentry
assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"}
assert mock_config_entry.data == {CONF_URL: "http://localhost:11434"}
assert len(mock_config_entry.subentries) == 2
subentry = next(iter(mock_config_entry.subentries.values()))

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import collections
from collections import defaultdict
from unittest.mock import AsyncMock, MagicMock, patch
@@ -196,7 +197,7 @@ async def setup_onvif_integration(
source=config_entries.SOURCE_USER,
capabilities=None,
events=None,
raw_events: list[tuple[str, EventEntity]] | None = None,
raw_events: list[tuple[str, list[EventEntity]]] | None = None,
) -> tuple[MockConfigEntry, MagicMock, MagicMock]:
"""Create an ONVIF config entry."""
if not config:
@@ -239,12 +240,14 @@ async def setup_onvif_integration(
# to test the full parsing pipeline including conversions
event_manager = EventManager(hass, mock_onvif_camera, config_entry, NAME)
mock_messages = []
event_by_topic: dict[str, EventEntity] = {}
for topic, raw_event in raw_events:
event_by_topic: collections.defaultdict[str, list[EventEntity]] = (
collections.defaultdict(list)
)
for topic, topic_events in raw_events:
mock_msg = MagicMock()
mock_msg.Topic._value_1 = topic
mock_messages.append(mock_msg)
event_by_topic[topic] = raw_event
event_by_topic[topic].extend(topic_events)
async def mock_parse(topic, unique_id, msg):
return event_by_topic.get(topic)

View File

@@ -95,13 +95,15 @@ async def test_timestamp_event_conversion(hass: HomeAssistant) -> None:
raw_events=[
(
"tns1:Monitoring/LastReset",
EventEntity(
uid=LAST_RESET_UID,
name="Last Reset",
platform="sensor",
device_class="timestamp",
value="2023-10-01T12:00:00Z",
),
[
EventEntity(
uid=LAST_RESET_UID,
name="Last Reset",
platform="sensor",
device_class="timestamp",
value="2023-10-01T12:00:00Z",
),
],
),
],
)
@@ -121,13 +123,15 @@ async def test_timestamp_event_invalid_value(hass: HomeAssistant) -> None:
raw_events=[
(
"tns1:Monitoring/LastReset",
EventEntity(
uid=LAST_RESET_UID,
name="Last Reset",
platform="sensor",
device_class="timestamp",
value="0000-00-00T00:00:00Z",
),
[
EventEntity(
uid=LAST_RESET_UID,
name="Last Reset",
platform="sensor",
device_class="timestamp",
value="0000-00-00T00:00:00Z",
),
],
),
],
)
@@ -135,3 +139,40 @@ async def test_timestamp_event_invalid_value(hass: HomeAssistant) -> None:
state = hass.states.get("sensor.testcamera_last_reset")
assert state is not None
assert state.state == "unknown"
async def test_multiple_events_same_topic(hass: HomeAssistant) -> None:
"""Test that multiple events with the same topic are all processed."""
await setup_onvif_integration(
hass,
capabilities=Capabilities(events=True, imaging=True, ptz=True),
raw_events=[
(
"tns1:VideoSource/MotionAlarm",
[
EventEntity(
uid=f"{MOTION_ALARM_UID}_1",
name="Motion Alarm 1",
platform="binary_sensor",
device_class="motion",
value=True,
),
EventEntity(
uid=f"{MOTION_ALARM_UID}_2",
name="Motion Alarm 2",
platform="binary_sensor",
device_class="motion",
value=False,
),
],
),
],
)
state1 = hass.states.get("binary_sensor.testcamera_motion_alarm_1")
assert state1 is not None
assert state1.state == STATE_ON
state2 = hass.states.get("binary_sensor.testcamera_motion_alarm_2")
assert state2 is not None
assert state2.state == STATE_OFF

View File

@@ -21,12 +21,13 @@ def config_data() -> dict[str, Any]:
@pytest.fixture
def config_entry(config_data: dict[str, Any]) -> MockConfigEntry:
def config_entry(config_data: dict[str, Any], account) -> MockConfigEntry:
"""Create a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
data=config_data,
options={},
unique_id=account.id,
)

View File

@@ -5,7 +5,6 @@ import smarttub
from homeassistant.components.climate import (
ATTR_CURRENT_TEMPERATURE,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
ATTR_HVAC_MODES,
ATTR_MAX_TEMP,
ATTR_MIN_TEMP,
@@ -14,7 +13,6 @@ from homeassistant.components.climate import (
DOMAIN as CLIMATE_DOMAIN,
PRESET_ECO,
PRESET_NONE,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
SERVICE_SET_TEMPERATURE,
ClimateEntityFeature,
@@ -32,25 +30,16 @@ from homeassistant.core import HomeAssistant
from . import trigger_update
async def test_thermostat_update(
async def test_thermostat_state(
spa, spa_state, setup_entry, hass: HomeAssistant
) -> None:
"""Test the thermostat entity."""
"""Test the thermostat entity initial state and attributes."""
entity_id = f"climate.{spa.brand}_{spa.model}_thermostat"
state = hass.states.get(entity_id)
assert state
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING
spa_state.heater = "OFF"
await trigger_update(hass)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE
assert set(state.attributes[ATTR_HVAC_MODES]) == {HVACMode.HEAT}
assert state.state == HVACMode.HEAT
assert set(state.attributes[ATTR_HVAC_MODES]) == {HVACMode.HEAT}
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING
assert (
state.attributes[ATTR_SUPPORTED_FEATURES]
== ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
@@ -60,7 +49,28 @@ async def test_thermostat_update(
assert state.attributes[ATTR_MAX_TEMP] == DEFAULT_MAX_TEMP
assert state.attributes[ATTR_MIN_TEMP] == DEFAULT_MIN_TEMP
assert state.attributes[ATTR_PRESET_MODES] == ["none", "eco", "day", "ready"]
assert state.attributes.get(ATTR_PRESET_MODE) == PRESET_NONE
async def test_thermostat_hvac_action_update(
spa, spa_state, setup_entry, hass: HomeAssistant
) -> None:
"""Test the thermostat HVAC action transitions from heating to idle."""
entity_id = f"climate.{spa.brand}_{spa.model}_thermostat"
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING
spa_state.heater = "OFF"
await trigger_update(hass)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE
async def test_thermostat_set_temperature(
spa, setup_entry, hass: HomeAssistant
) -> None:
"""Test setting the target temperature."""
entity_id = f"climate.{spa.brand}_{spa.model}_thermostat"
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
@@ -69,15 +79,12 @@ async def test_thermostat_update(
)
spa.set_temperature.assert_called_with(37)
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT},
blocking=True,
)
# does nothing
assert state.attributes.get(ATTR_PRESET_MODE) == PRESET_NONE
async def test_thermostat_set_preset_mode(
spa, spa_state, setup_entry, hass: HomeAssistant
) -> None:
"""Test setting a preset mode updates state correctly."""
entity_id = f"climate.{spa.brand}_{spa.model}_thermostat"
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
@@ -91,6 +98,9 @@ async def test_thermostat_update(
state = hass.states.get(entity_id)
assert state.attributes.get(ATTR_PRESET_MODE) == PRESET_ECO
async def test_thermostat_api_error(spa, setup_entry, hass: HomeAssistant) -> None:
"""Test that an API error during update does not raise."""
spa.get_status_full.side_effect = smarttub.APIError
await trigger_update(hass)
# should not fail

View File

@@ -2,6 +2,7 @@
from unittest.mock import patch
import pytest
from smarttub import LoginFailed
from homeassistant import config_entries
@@ -13,35 +14,43 @@ from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_form(hass: HomeAssistant) -> None:
"""Test we get the form."""
@pytest.fixture
def mock_setup_entry():
"""Mock the integration setup."""
with patch(
"homeassistant.components.smarttub.async_setup_entry",
return_value=True,
) as mock:
yield mock
async def test_user_flow(hass: HomeAssistant, mock_setup_entry, account) -> None:
"""Test the user config flow creates an entry with correct data."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
with patch(
"homeassistant.components.smarttub.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_EMAIL: "test-email", CONF_PASSWORD: "test-password"},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_EMAIL: "test-email", CONF_PASSWORD: "test-password"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test-email"
assert result["data"] == {
CONF_EMAIL: "test-email",
CONF_PASSWORD: "test-password",
}
await hass.async_block_till_done()
mock_setup_entry.assert_called_once()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test-email"
assert result["data"] == {
CONF_EMAIL: "test-email",
CONF_PASSWORD: "test-password",
}
assert result["result"].unique_id == account.id
mock_setup_entry.assert_called_once()
async def test_form_invalid_auth(hass: HomeAssistant, smarttub_api) -> None:
"""Test we handle invalid auth."""
async def test_form_invalid_auth(
hass: HomeAssistant, smarttub_api, mock_setup_entry
) -> None:
"""Test we handle invalid auth and can recover."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@@ -56,17 +65,21 @@ async def test_form_invalid_auth(hass: HomeAssistant, smarttub_api) -> None:
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
smarttub_api.login.side_effect = None
async def test_reauth_success(hass: HomeAssistant, smarttub_api, account) -> None:
"""Test reauthentication flow."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_EMAIL: "test-email", CONF_PASSWORD: "test-password"},
unique_id=account.id,
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_EMAIL: "test-email", CONF_PASSWORD: "test-password"},
)
mock_entry.add_to_hass(hass)
result = await mock_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_reauth_success(hass: HomeAssistant, smarttub_api, config_entry) -> None:
"""Test reauthentication flow."""
config_entry.add_to_hass(hass)
result = await config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
@@ -77,18 +90,15 @@ async def test_reauth_success(hass: HomeAssistant, smarttub_api, account) -> Non
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_entry.data[CONF_EMAIL] == "test-email3"
assert mock_entry.data[CONF_PASSWORD] == "test-password3"
assert config_entry.data[CONF_EMAIL] == "test-email3"
assert config_entry.data[CONF_PASSWORD] == "test-password3"
async def test_reauth_wrong_account(hass: HomeAssistant, smarttub_api, account) -> None:
async def test_reauth_wrong_account(
hass: HomeAssistant, smarttub_api, account, config_entry
) -> None:
"""Test reauthentication flow if the user enters credentials for a different already-configured account."""
mock_entry1 = MockConfigEntry(
domain=DOMAIN,
data={CONF_EMAIL: "test-email1", CONF_PASSWORD: "test-password1"},
unique_id=account.id,
)
mock_entry1.add_to_hass(hass)
config_entry.add_to_hass(hass)
mock_entry2 = MockConfigEntry(
domain=DOMAIN,
@@ -98,7 +108,7 @@ async def test_reauth_wrong_account(hass: HomeAssistant, smarttub_api, account)
mock_entry2.add_to_hass(hass)
# we try to reauth account #2, and the user successfully authenticates to account #1
account.id = mock_entry1.unique_id
account.id = config_entry.unique_id
result = await mock_entry2.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM

View File

@@ -1,13 +1,10 @@
"""Test smarttub setup process."""
from unittest.mock import patch
from smarttub import LoginFailed
from homeassistant.components.smarttub.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
async def test_setup_with_no_config(
@@ -39,34 +36,23 @@ async def test_setup_auth_failed(
smarttub_api.login.side_effect = LoginFailed
config_entry.add_to_hass(hass)
with patch.object(hass.config_entries.flow, "async_init") as mock_flow_init:
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_ERROR
mock_flow_init.assert_called_with(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": config_entry.entry_id,
"unique_id": config_entry.unique_id,
"title_placeholders": {"name": config_entry.title},
},
data=config_entry.data,
)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_ERROR
async def test_config_passed_to_config_entry(
hass: HomeAssistant, config_entry, config_data
) -> None:
"""Test that configured options are loaded via config entry."""
config_entry.add_to_hass(hass)
assert await async_setup_component(hass, DOMAIN, config_data)
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert len(flows) == 1
assert flows[0]["context"]["source"] == SOURCE_REAUTH
async def test_unload_entry(hass: HomeAssistant, config_entry) -> None:
"""Test being able to unload an entry."""
config_entry.add_to_hass(hass)
assert await async_setup_component(hass, DOMAIN, {}) is True
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(config_entry.entry_id)
assert config_entry.state is ConfigEntryState.NOT_LOADED

View File

@@ -7,49 +7,73 @@ from homeassistant.core import HomeAssistant
@pytest.mark.parametrize(
("pump_id", "entity_suffix", "pump_state"),
("pump_id", "entity_suffix", "expected_state"),
[
("CP", "circulation_pump", "off"),
("P1", "jet_p1", "off"),
("P2", "jet_p2", "on"),
("CP", "circulation_pump", STATE_OFF),
("P1", "jet_p1", STATE_OFF),
("P2", "jet_p2", STATE_ON),
],
)
async def test_pumps(
spa, setup_entry, hass: HomeAssistant, pump_id, pump_state, entity_suffix
async def test_pump_state(
spa, setup_entry, hass: HomeAssistant, pump_id, entity_suffix, expected_state
) -> None:
"""Test pump entities."""
status = await spa.get_status_full()
pump = next(pump for pump in status.pumps if pump.id == pump_id)
"""Test pump entity initial state."""
entity_id = f"switch.{spa.brand}_{spa.model}_{entity_suffix}"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == pump_state
assert state.state == expected_state
@pytest.mark.parametrize(
("pump_id", "entity_suffix"),
[
("CP", "circulation_pump"),
("P1", "jet_p1"),
("P2", "jet_p2"),
],
)
async def test_pump_toggle(
spa, setup_entry, hass: HomeAssistant, pump_id, entity_suffix
) -> None:
"""Test toggling a pump."""
status = await spa.get_status_full()
pump = next(pump for pump in status.pumps if pump.id == pump_id)
entity_id = f"switch.{spa.brand}_{spa.model}_{entity_suffix}"
await hass.services.async_call(
"switch",
"toggle",
{"entity_id": entity_id},
blocking=True,
"switch", "toggle", {"entity_id": entity_id}, blocking=True
)
pump.toggle.assert_called()
if state.state == STATE_OFF:
await hass.services.async_call(
"switch",
"turn_on",
{"entity_id": entity_id},
blocking=True,
)
pump.toggle.assert_called()
else:
assert state.state == STATE_ON
await hass.services.async_call(
"switch",
"turn_off",
{"entity_id": entity_id},
blocking=True,
)
pump.toggle.assert_called()
@pytest.mark.parametrize(
("pump_id", "entity_suffix"),
[
("CP", "circulation_pump"),
("P1", "jet_p1"),
],
)
async def test_pump_turn_on(
spa, setup_entry, hass: HomeAssistant, pump_id, entity_suffix
) -> None:
"""Test turning on an off pump toggles it."""
status = await spa.get_status_full()
pump = next(pump for pump in status.pumps if pump.id == pump_id)
entity_id = f"switch.{spa.brand}_{spa.model}_{entity_suffix}"
await hass.services.async_call(
"switch", "turn_on", {"entity_id": entity_id}, blocking=True
)
pump.toggle.assert_called()
async def test_pump_turn_off(spa, setup_entry, hass: HomeAssistant) -> None:
"""Test turning off an on pump toggles it."""
status = await spa.get_status_full()
pump = next(pump for pump in status.pumps if pump.id == "P2")
entity_id = f"switch.{spa.brand}_{spa.model}_jet_p2"
await hass.services.async_call(
"switch", "turn_off", {"entity_id": entity_id}, blocking=True
)
pump.toggle.assert_called()

View File

@@ -66,6 +66,12 @@ METADATA = {
"name": "Home Assistant",
}
},
"energy_sites": {
"123456": {
"access": True,
"name": "Energy Site",
}
},
}
METADATA_LEGACY = {
"uid": UNIQUE_ID,
@@ -92,6 +98,12 @@ METADATA_LEGACY = {
"name": "Home Assistant",
}
},
"energy_sites": {
"123456": {
"access": True,
"name": "Energy Site",
}
},
}
METADATA_NOSCOPE = {
"uid": UNIQUE_ID,
@@ -108,4 +120,10 @@ METADATA_NOSCOPE = {
"name": "Home Assistant",
}
},
"energy_sites": {
"123456": {
"access": True,
"name": "Energy Site",
}
},
}

View File

@@ -24,6 +24,7 @@ from homeassistant.components.teslemetry.coordinator import (
ENERGY_HISTORY_INTERVAL,
ENERGY_INFO_INTERVAL,
ENERGY_LIVE_INTERVAL,
METADATA_INTERVAL,
VEHICLE_INTERVAL,
)
from homeassistant.components.teslemetry.models import TeslemetryData
@@ -43,6 +44,8 @@ from .const import (
CONFIG_V1,
ENERGY_HISTORY,
LIVE_STATUS,
METADATA,
METADATA_NOSCOPE,
PRODUCTS_MODERN,
SITE_INFO,
UNIQUE_ID,
@@ -271,6 +274,37 @@ async def test_stale_device_removal(
assert updated_device is None
async def test_skipped_energy_site_is_removed_as_stale_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test skipped energy sites do not block stale device removal."""
entry = await setup_platform(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, "98765")},
manufacturer="Tesla",
name="Skipped Energy Site",
)
refreshed_metadata = deepcopy(METADATA)
refreshed_metadata["energy_sites"]["98765"] = {
"access": True,
"name": "Skipped Energy Site",
}
with patch(
"tesla_fleet_api.teslemetry.Teslemetry.metadata",
return_value=refreshed_metadata,
):
await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
updated_device = device_registry.async_get_device(identifiers={(DOMAIN, "98765")})
assert updated_device is None
async def test_device_retention_during_reload(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
@@ -864,3 +898,83 @@ async def test_energy_history_coordinator_refresh_errors(
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
async def test_dynamic_device_discovery_triggers_reload(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that metadata coordinator triggers reload when new vehicle is added."""
entry = await setup_platform(hass)
assert entry.state is ConfigEntryState.LOADED
# Update metadata to include a new vehicle with access
new_metadata = deepcopy(METADATA)
new_metadata["vehicles"]["5YJ3E1EA1NF000001"] = {
"proxy": True,
"access": True,
"polling": False,
"firmware": "2026.0.0",
}
with (
patch(
"tesla_fleet_api.teslemetry.Teslemetry.metadata",
return_value=new_metadata,
),
patch.object(hass.config_entries, "async_schedule_reload") as mock_reload,
):
# Advance time to trigger metadata coordinator refresh
freezer.tick(METADATA_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Verify reload was triggered due to new vehicle
mock_reload.assert_called_once_with(entry.entry_id)
async def test_dynamic_device_discovery_no_reload_for_scope_only_change(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test metadata refresh does not reload when only scopes change."""
entry = await setup_platform(hass)
assert entry.state is ConfigEntryState.LOADED
with (
patch(
"tesla_fleet_api.teslemetry.Teslemetry.metadata",
return_value=deepcopy(METADATA_NOSCOPE),
),
patch.object(hass.config_entries, "async_schedule_reload") as mock_reload,
):
freezer.tick(METADATA_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_reload.assert_not_called()
async def test_dynamic_device_discovery_no_reload_without_changes(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that metadata coordinator refresh without changes does not reload."""
entry = await setup_platform(hass)
assert entry.state is ConfigEntryState.LOADED
# Patch to use the same metadata (no changes)
with (
patch(
"tesla_fleet_api.teslemetry.Teslemetry.metadata",
return_value=deepcopy(METADATA),
),
patch.object(hass.config_entries, "async_schedule_reload") as mock_reload,
):
# Advance time to trigger metadata coordinator refresh
freezer.tick(METADATA_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Verify reload was NOT triggered since no subscription changes
mock_reload.assert_not_called()

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from typing import Final
from homeassistant.components.vicare.const import CONF_HEATING_TYPE
from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
@@ -16,7 +15,6 @@ ENTRY_CONFIG: Final[dict[str, str]] = {
CONF_USERNAME: "foo@bar.com",
CONF_PASSWORD: "1234",
CONF_CLIENT_ID: "5678",
CONF_HEATING_TYPE: "auto",
}
MOCK_MAC = "B874241B7B9"

View File

@@ -76,6 +76,7 @@ def mock_config_entry() -> MockConfigEntry:
unique_id="ViCare",
entry_id="1234",
data=ENTRY_CONFIG,
minor_version=2,
)

View File

@@ -2,7 +2,6 @@
# name: test_form_dhcp
dict({
'client_id': '5678',
'heating_type': 'auto',
'password': '1234',
'username': 'foo@bar.com',
})
@@ -10,7 +9,6 @@
# name: test_user_create_entry
dict({
'client_id': '5678',
'heating_type': 'auto',
'password': '1234',
'username': 'foo@bar.com',
})

View File

@@ -4724,7 +4724,6 @@
'entry': dict({
'data': dict({
'client_id': '**REDACTED**',
'heating_type': 'auto',
'password': '**REDACTED**',
'username': '**REDACTED**',
}),
@@ -4733,7 +4732,7 @@
}),
'domain': 'vicare',
'entry_id': '1234',
'minor_version': 1,
'minor_version': 2,
'options': dict({
}),
'pref_disable_new_entities': False,

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