mirror of
https://github.com/home-assistant/core.git
synced 2026-03-17 00:12:02 +01:00
Compare commits
1 Commits
python-3.1
...
edenhaus-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abf2a93cc0 |
2
.github/workflows/builder.yml
vendored
2
.github/workflows/builder.yml
vendored
@@ -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.02.0"
|
||||
BASE_IMAGE_VERSION: "2026.01.0"
|
||||
ARCHITECTURES: '["amd64", "aarch64"]'
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.14.3
|
||||
3.14.2
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -974,8 +974,6 @@ 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
|
||||
|
||||
@@ -239,9 +239,6 @@ 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]:
|
||||
@@ -263,17 +260,18 @@ 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": V1_DEVICE_TYPES[device.get("type")],
|
||||
"deviceType": "min",
|
||||
}
|
||||
for device in devices
|
||||
if device.get("type") in V1_DEVICE_TYPES
|
||||
if device.get("type") == 7
|
||||
]
|
||||
|
||||
for device in devices:
|
||||
if device.get("type") not in V1_DEVICE_TYPES:
|
||||
if device.get("type") != 7:
|
||||
_LOGGER.warning(
|
||||
"Device %s with type %s not supported in Open API V1, skipping",
|
||||
device.get("device_sn", ""),
|
||||
@@ -350,7 +348,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", "sph"]
|
||||
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min"]
|
||||
}
|
||||
|
||||
# Perform the first refresh for the total coordinator
|
||||
|
||||
@@ -167,36 +167,6 @@ 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)
|
||||
@@ -478,123 +448,3 @@ 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)
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ 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
|
||||
@@ -58,8 +57,6 @@ 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",
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
"""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,
|
||||
),
|
||||
)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@@ -21,77 +21,67 @@ 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"])
|
||||
@@ -101,11 +91,13 @@ 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,
|
||||
@@ -117,121 +109,50 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
)
|
||||
batt_mode: int = valid_modes[batt_mode_str]
|
||||
|
||||
start_time = _parse_time_str(start_time_str, "start_time")
|
||||
end_time = _parse_time_str(end_time_str, "end_time")
|
||||
# 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)
|
||||
|
||||
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."""
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "min"
|
||||
)
|
||||
device_id: str = call.data["device_id"]
|
||||
|
||||
# Get the appropriate MIN coordinator
|
||||
coordinator: GrowattCoordinator = get_coordinator(device_id)
|
||||
|
||||
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(
|
||||
@@ -247,31 +168,3 @@ 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,
|
||||
)
|
||||
|
||||
@@ -48,162 +48,3 @@ 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
|
||||
|
||||
@@ -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/SPH devices. For other device types, please use username/password authentication.",
|
||||
"description": "Token authentication is only supported for MIN/TLX 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/SPH devices. For other device types, please use username/password authentication.",
|
||||
"description": "Note: Token authentication is currently only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
|
||||
"menu_options": {
|
||||
"password_auth": "Username/password",
|
||||
"token_auth": "API token (MIN/SPH only)"
|
||||
"token_auth": "API token (MIN/TLX only)"
|
||||
},
|
||||
"title": "Choose authentication method"
|
||||
}
|
||||
@@ -243,24 +243,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -594,26 +576,6 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
@@ -653,118 +615,6 @@
|
||||
}
|
||||
},
|
||||
"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"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from apyhiveapi import Auth
|
||||
@@ -27,8 +26,6 @@ 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."""
|
||||
@@ -39,7 +36,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.tokens: dict[str, Any] = {}
|
||||
self.tokens: dict[str, str] = {}
|
||||
self.device_registration: bool = False
|
||||
self.device_name = "Home Assistant"
|
||||
|
||||
@@ -70,22 +67,11 @@ 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()
|
||||
@@ -117,7 +103,6 @@ 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
|
||||
@@ -134,11 +119,10 @@ 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:
|
||||
@@ -158,7 +142,6 @@ 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(
|
||||
@@ -177,7 +160,6 @@ 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
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.32.0"],
|
||||
"requirements": ["aiohomeconnect==0.30.0"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -127,7 +127,6 @@ 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
|
||||
@@ -136,7 +135,6 @@ 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
|
||||
|
||||
@@ -261,10 +261,8 @@
|
||||
"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%]",
|
||||
@@ -617,10 +615,8 @@
|
||||
"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%]",
|
||||
@@ -1622,10 +1618,8 @@
|
||||
"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",
|
||||
|
||||
@@ -11,14 +11,10 @@ from homematicip.base.enums import (
|
||||
OpticalSignalBehaviour,
|
||||
RGBColorState,
|
||||
)
|
||||
from homematicip.base.functionalChannels import (
|
||||
NotificationLightChannel,
|
||||
NotificationMp3SoundChannel,
|
||||
)
|
||||
from homematicip.base.functionalChannels import NotificationLightChannel
|
||||
from homematicip.device import (
|
||||
BrandDimmer,
|
||||
BrandSwitchNotificationLight,
|
||||
CombinationSignallingDevice,
|
||||
Device,
|
||||
Dimmer,
|
||||
DinRailDimmer3,
|
||||
@@ -112,8 +108,6 @@ 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)
|
||||
|
||||
@@ -592,70 +586,3 @@ 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()
|
||||
|
||||
@@ -6,6 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/huum",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["huum==0.8.1"]
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
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
|
||||
@@ -46,16 +46,6 @@ 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:
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"""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
|
||||
@@ -1,111 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
"""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
|
||||
@@ -1,68 +0,0 @@
|
||||
"""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
|
||||
@@ -1,78 +0,0 @@
|
||||
"""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
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,6 @@ 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
|
||||
@@ -93,9 +92,6 @@ 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
|
||||
)
|
||||
@@ -127,22 +123,6 @@ 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:
|
||||
@@ -150,13 +130,11 @@ 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 = self._format_latest_version(update_information)
|
||||
self._attr_latest_version = update_information.software_version_string
|
||||
self._attr_release_url = update_information.release_notes_url
|
||||
|
||||
except UpdateCheckError as err:
|
||||
@@ -234,12 +212,7 @@ class MatterUpdate(MatterEntity, UpdateEntity):
|
||||
|
||||
software_version: str | int | None = version
|
||||
if self._software_update is not None and (
|
||||
version is None
|
||||
or version
|
||||
in {
|
||||
self._software_update.software_version_string,
|
||||
self._attr_latest_version,
|
||||
}
|
||||
version is None or version == self._software_update.software_version_string
|
||||
):
|
||||
# Update to the version previously fetched and shown.
|
||||
# We can pass the integer version directly to speedup download.
|
||||
|
||||
@@ -10,13 +10,9 @@ import httpx
|
||||
import ollama
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform
|
||||
from homeassistant.const import CONF_URL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
@@ -66,28 +62,10 @@ 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}
|
||||
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(),
|
||||
)
|
||||
client = ollama.AsyncClient(host=settings[CONF_URL], 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
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.config_entries import (
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_URL
|
||||
from homeassistant.const import 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,17 +68,6 @@ 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)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -89,40 +78,9 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 3
|
||||
MINOR_VERSION = 3
|
||||
|
||||
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
|
||||
def __init__(self) -> None:
|
||||
"""Initialize config flow."""
|
||||
self.url: str | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -134,10 +92,9 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
errors = {}
|
||||
url = user_input[CONF_URL].strip()
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
if api_key:
|
||||
api_key = api_key.strip()
|
||||
url = user_input[CONF_URL]
|
||||
|
||||
self._async_abort_entries_match({CONF_URL: url})
|
||||
|
||||
try:
|
||||
url = cv.url(url)
|
||||
@@ -151,8 +108,15 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
self._async_abort_entries_match({CONF_URL: url})
|
||||
errors = await self._async_validate_connection(url, api_key)
|
||||
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"
|
||||
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
@@ -163,65 +127,9 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
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,
|
||||
return self.async_create_entry(
|
||||
title=url,
|
||||
data={CONF_URL: url},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
},
|
||||
"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%]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ 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
|
||||
@@ -197,7 +196,7 @@ class EventManager:
|
||||
topic = msg.Topic._value_1.rstrip("/.") # noqa: SLF001
|
||||
|
||||
try:
|
||||
events = await onvif_parsers.parse(topic, unique_id, msg)
|
||||
event = await onvif_parsers.parse(topic, unique_id, msg)
|
||||
error = None
|
||||
except onvif_parsers.errors.UnknownTopicError:
|
||||
if topic not in UNHANDLED_TOPICS:
|
||||
@@ -205,43 +204,42 @@ class EventManager:
|
||||
"%s: No registered handler for event from %s: %s",
|
||||
self.name,
|
||||
unique_id,
|
||||
onvif_parsers.util.event_to_debug_format(msg),
|
||||
msg,
|
||||
)
|
||||
UNHANDLED_TOPICS.add(topic)
|
||||
continue
|
||||
except (AttributeError, KeyError) as e:
|
||||
events = []
|
||||
event = None
|
||||
error = e
|
||||
|
||||
if not events:
|
||||
if not event:
|
||||
LOGGER.warning(
|
||||
"%s: Unable to parse event from %s: %s: %s",
|
||||
self.name,
|
||||
unique_id,
|
||||
error,
|
||||
onvif_parsers.util.event_to_debug_format(msg),
|
||||
msg,
|
||||
)
|
||||
continue
|
||||
|
||||
for event in events:
|
||||
value = event.value
|
||||
if event.device_class == "timestamp" and isinstance(value, str):
|
||||
value = _local_datetime_or_none(value)
|
||||
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."""
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"loggers": ["onvif", "wsdiscovery", "zeep"],
|
||||
"requirements": [
|
||||
"onvif-zeep-async==4.0.4",
|
||||
"onvif_parsers==2.3.0",
|
||||
"onvif_parsers==1.2.2",
|
||||
"WSDiscovery==2.1.2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, ServiceCall
|
||||
from homeassistant.core import 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,7 +37,6 @@ 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
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -25,7 +24,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, EVENT_TYPE
|
||||
from . import EVENT
|
||||
|
||||
CONF_VARIABLE = "variable"
|
||||
CONF_RESET_DELAY_SEC = "reset_delay_sec"
|
||||
@@ -47,8 +46,6 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
|
||||
}
|
||||
)
|
||||
|
||||
type _PAYLOAD_SET_TYPE = str | int | float
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass: HomeAssistant,
|
||||
@@ -62,12 +59,12 @@ def setup_platform(
|
||||
[
|
||||
PilightTriggerSensor(
|
||||
hass=hass,
|
||||
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],
|
||||
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),
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -76,11 +73,11 @@ def setup_platform(
|
||||
[
|
||||
PilightBinarySensor(
|
||||
hass=hass,
|
||||
name=config[CONF_NAME],
|
||||
variable=config[CONF_VARIABLE],
|
||||
payload=config[CONF_PAYLOAD],
|
||||
on_value=config[CONF_PAYLOAD_ON],
|
||||
off_value=config[CONF_PAYLOAD_OFF],
|
||||
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),
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -89,15 +86,7 @@ def setup_platform(
|
||||
class PilightBinarySensor(BinarySensorEntity):
|
||||
"""Representation of a binary sensor that can be updated using Pilight."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
name: str,
|
||||
variable: str,
|
||||
payload: dict[str, Any],
|
||||
on_value: _PAYLOAD_SET_TYPE,
|
||||
off_value: _PAYLOAD_SET_TYPE,
|
||||
) -> None:
|
||||
def __init__(self, hass, name, variable, payload, on_value, off_value):
|
||||
"""Initialize the sensor."""
|
||||
self._attr_is_on = False
|
||||
self._hass = hass
|
||||
@@ -109,7 +98,7 @@ class PilightBinarySensor(BinarySensorEntity):
|
||||
|
||||
hass.bus.listen(EVENT, self._handle_code)
|
||||
|
||||
def _handle_code(self, call: EVENT_TYPE) -> None:
|
||||
def _handle_code(self, call):
|
||||
"""Handle received code by the pilight-daemon.
|
||||
|
||||
If the code matches the defined payload
|
||||
@@ -137,15 +126,8 @@ class PilightTriggerSensor(BinarySensorEntity):
|
||||
"""Representation of a binary sensor that can be updated using Pilight."""
|
||||
|
||||
def __init__(
|
||||
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:
|
||||
self, hass, name, variable, payload, on_value, off_value, rst_dly_sec=30
|
||||
):
|
||||
"""Initialize the sensor."""
|
||||
self._attr_is_on = False
|
||||
self._hass = hass
|
||||
@@ -155,17 +137,17 @@ class PilightTriggerSensor(BinarySensorEntity):
|
||||
self._on_value = on_value
|
||||
self._off_value = off_value
|
||||
self._reset_delay_sec = rst_dly_sec
|
||||
self._delay_after: datetime.datetime | None = None
|
||||
self._delay_after = None
|
||||
self._hass = hass
|
||||
|
||||
hass.bus.listen(EVENT, self._handle_code)
|
||||
|
||||
def _reset_state(self, _: datetime.datetime) -> None:
|
||||
def _reset_state(self, call):
|
||||
self._attr_is_on = False
|
||||
self._delay_after = None
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def _handle_code(self, call: EVENT_TYPE) -> None:
|
||||
def _handle_code(self, call):
|
||||
"""Handle received code by the pilight-daemon.
|
||||
|
||||
If the code matches the defined payload
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Base class for pilight."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
@@ -12,10 +10,8 @@ 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 (
|
||||
@@ -64,19 +60,19 @@ class PilightBaseDevice(RestoreEntity):
|
||||
_attr_assumed_state = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, hass: HomeAssistant, name: str, config: ConfigType) -> None:
|
||||
def __init__(self, hass, name, config):
|
||||
"""Initialize a device."""
|
||||
self._hass = hass
|
||||
self._attr_name = config.get(CONF_NAME, name)
|
||||
self._attr_is_on: bool | None = False
|
||||
self._attr_is_on = 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: list[_ReceiveHandle] = []
|
||||
self._code_off_receive: list[_ReceiveHandle] = []
|
||||
self._code_on_receive = []
|
||||
self._code_off_receive = []
|
||||
|
||||
for code_list, conf in (
|
||||
(self._code_on_receive, code_on_receive),
|
||||
@@ -89,7 +85,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: int | None = 255
|
||||
self._brightness = 255
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity about to be added to hass."""
|
||||
@@ -151,18 +147,18 @@ class PilightBaseDevice(RestoreEntity):
|
||||
|
||||
|
||||
class _ReceiveHandle:
|
||||
def __init__(self, config: dict[str, Any], echo: bool) -> None:
|
||||
def __init__(self, config, echo):
|
||||
"""Initialize the handle."""
|
||||
self.config_items = config.items()
|
||||
self.echo = echo
|
||||
|
||||
def match(self, code: dict[str, Any]) -> bool:
|
||||
def match(self, code):
|
||||
"""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: PilightBaseDevice, turn_on: bool) -> None:
|
||||
def run(self, switch, turn_on):
|
||||
"""Change the state of the switch."""
|
||||
switch.set_state(turn_on=turn_on, send_code=self.echo)
|
||||
|
||||
@@ -55,11 +55,11 @@ class PilightLight(PilightBaseDevice, LightEntity):
|
||||
_attr_color_mode = ColorMode.BRIGHTNESS
|
||||
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
|
||||
def __init__(self, hass: HomeAssistant, name: str, config: ConfigType) -> None:
|
||||
def __init__(self, hass, name, config):
|
||||
"""Initialize a switch."""
|
||||
super().__init__(hass, name, config)
|
||||
self._dimlevel_min: int = config[CONF_DIMLEVEL_MIN]
|
||||
self._dimlevel_max: int = config[CONF_DIMLEVEL_MAX]
|
||||
self._dimlevel_min = config.get(CONF_DIMLEVEL_MIN)
|
||||
self._dimlevel_max = config.get(CONF_DIMLEVEL_MAX)
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -17,7 +16,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, EVENT_TYPE
|
||||
from . import EVENT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,9 +44,9 @@ def setup_platform(
|
||||
[
|
||||
PilightSensor(
|
||||
hass=hass,
|
||||
name=config[CONF_NAME],
|
||||
variable=config[CONF_VARIABLE],
|
||||
payload=config[CONF_PAYLOAD],
|
||||
name=config.get(CONF_NAME),
|
||||
variable=config.get(CONF_VARIABLE),
|
||||
payload=config.get(CONF_PAYLOAD),
|
||||
unit_of_measurement=config.get(CONF_UNIT_OF_MEASUREMENT),
|
||||
)
|
||||
]
|
||||
@@ -59,24 +58,33 @@ class PilightSensor(SensorEntity):
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
name: str,
|
||||
variable: str,
|
||||
payload: dict[str, Any],
|
||||
unit_of_measurement: str | None,
|
||||
) -> None:
|
||||
def __init__(self, hass, name, variable, payload, unit_of_measurement):
|
||||
"""Initialize the sensor."""
|
||||
self._state = None
|
||||
self._hass = hass
|
||||
self._attr_name = name
|
||||
self._name = name
|
||||
self._variable = variable
|
||||
self._payload = payload
|
||||
self._attr_native_unit_of_measurement = unit_of_measurement
|
||||
self._unit_of_measurement = unit_of_measurement
|
||||
|
||||
hass.bus.listen(EVENT, self._handle_code)
|
||||
|
||||
def _handle_code(self, call: EVENT_TYPE) -> None:
|
||||
@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):
|
||||
"""Handle received code by the pilight-daemon.
|
||||
|
||||
If the code matches the defined payload
|
||||
@@ -88,7 +96,7 @@ class PilightSensor(SensorEntity):
|
||||
if self._payload.items() <= call.data.items():
|
||||
try:
|
||||
value = call.data[self._variable]
|
||||
self._attr_native_value = value
|
||||
self._state = value
|
||||
self.schedule_update_ha_state()
|
||||
except KeyError:
|
||||
_LOGGER.error(
|
||||
|
||||
@@ -19,7 +19,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartTubConfigEntry) ->
|
||||
|
||||
controller = SmartTubController(hass)
|
||||
|
||||
await controller.async_setup_entry(entry)
|
||||
if not await controller.async_setup_entry(entry):
|
||||
return False
|
||||
|
||||
entry.runtime_data = controller
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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
|
||||
@@ -8,6 +8,7 @@ from http import HTTPStatus
|
||||
import logging
|
||||
from xml.parsers.expat import ExpatError
|
||||
|
||||
from aiohttp import ClientSession
|
||||
import voluptuous as vol
|
||||
import xmltodict
|
||||
|
||||
@@ -150,7 +151,7 @@ async def async_setup_platform(
|
||||
apikey = config[CONF_API_KEY]
|
||||
bandwidthcap = config[CONF_TOTAL_BANDWIDTH]
|
||||
|
||||
ts_data = StartcaData(hass.loop, websession, apikey, bandwidthcap)
|
||||
ts_data = StartcaData(websession, apikey, bandwidthcap)
|
||||
ret = await ts_data.async_update()
|
||||
if ret is False:
|
||||
_LOGGER.error("Invalid Start.ca API key: %s", apikey)
|
||||
@@ -176,7 +177,9 @@ async def async_setup_platform(
|
||||
class StartcaSensor(SensorEntity):
|
||||
"""Representation of Start.ca Bandwidth sensor."""
|
||||
|
||||
def __init__(self, startcadata, name, description: SensorEntityDescription) -> None:
|
||||
def __init__(
|
||||
self, startcadata: StartcaData, name: str, description: SensorEntityDescription
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.entity_description = description
|
||||
self.startcadata = startcadata
|
||||
@@ -194,9 +197,10 @@ class StartcaSensor(SensorEntity):
|
||||
class StartcaData:
|
||||
"""Get data from Start.ca API."""
|
||||
|
||||
def __init__(self, loop, websession, api_key, bandwidth_cap):
|
||||
def __init__(
|
||||
self, websession: ClientSession, api_key: str, bandwidth_cap: int
|
||||
) -> None:
|
||||
"""Initialize the data object."""
|
||||
self.loop = loop
|
||||
self.websession = websession
|
||||
self.api_key = api_key
|
||||
self.bandwidth_cap = bandwidth_cap
|
||||
@@ -215,7 +219,7 @@ class StartcaData:
|
||||
return float(value) * 10**-9
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
async def async_update(self):
|
||||
async def async_update(self) -> bool:
|
||||
"""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}"
|
||||
|
||||
@@ -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, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
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,7 +39,6 @@ from .coordinator import (
|
||||
TeslemetryEnergyHistoryCoordinator,
|
||||
TeslemetryEnergySiteInfoCoordinator,
|
||||
TeslemetryEnergySiteLiveCoordinator,
|
||||
TeslemetryMetadataCoordinator,
|
||||
TeslemetryVehicleDataCoordinator,
|
||||
)
|
||||
from .helpers import async_update_device_sw_version, flatten
|
||||
@@ -110,61 +109,6 @@ 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."""
|
||||
|
||||
@@ -215,7 +159,6 @@ 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)
|
||||
@@ -224,36 +167,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
vehicles: list[TeslemetryVehicleData] = []
|
||||
energysites: list[TeslemetryEnergyData] = []
|
||||
|
||||
# Create the stream (created lazily when first vehicle is found)
|
||||
# Create the stream
|
||||
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
|
||||
@@ -269,8 +197,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
serial_number=vin,
|
||||
sw_version=firmware,
|
||||
)
|
||||
current_devices.add((DOMAIN, vin))
|
||||
|
||||
poll = vehicle_metadata[vin].get("polling", False)
|
||||
# Create stream if required
|
||||
if not stream:
|
||||
stream = TeslemetryStream(
|
||||
session,
|
||||
access_token,
|
||||
server=f"{region.lower()}.teslemetry.com",
|
||||
parse_timestamp=True,
|
||||
manual=True,
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
stream.async_add_listener(
|
||||
@@ -279,6 +216,7 @@ 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(
|
||||
@@ -289,20 +227,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
stream=stream,
|
||||
stream_vehicle=stream_vehicle,
|
||||
vin=vin,
|
||||
firmware=firmware or "Unknown",
|
||||
firmware=firmware or "",
|
||||
device=device,
|
||||
)
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
):
|
||||
elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes:
|
||||
site_id = product["energy_site_id"]
|
||||
|
||||
powerwall = (
|
||||
product["components"]["battery"] or product["components"]["solar"]
|
||||
)
|
||||
@@ -314,12 +245,6 @@ 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))},
|
||||
@@ -328,8 +253,13 @@ 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)))
|
||||
|
||||
# For initial setup, raise auth errors properly
|
||||
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
|
||||
try:
|
||||
live_status = (await energy_site.live_status())["response"]
|
||||
except InvalidToken as e:
|
||||
@@ -418,25 +348,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
)
|
||||
|
||||
metadata_coordinator = TeslemetryMetadataCoordinator(hass, entry, teslemetry)
|
||||
|
||||
entry.runtime_data = TeslemetryData(
|
||||
vehicles=vehicles,
|
||||
energysites=energysites,
|
||||
scopes=scopes,
|
||||
stream=stream,
|
||||
metadata_coordinator=metadata_coordinator,
|
||||
)
|
||||
# Setup Platforms
|
||||
entry.runtime_data = TeslemetryData(vehicles, energysites, scopes, stream)
|
||||
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")
|
||||
@@ -539,6 +454,7 @@ 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,
|
||||
|
||||
@@ -15,7 +15,7 @@ from tesla_fleet_api.exceptions import (
|
||||
SubscriptionRequired,
|
||||
TeslaFleetError,
|
||||
)
|
||||
from tesla_fleet_api.teslemetry import EnergySite, Teslemetry, Vehicle
|
||||
from tesla_fleet_api.teslemetry import EnergySite, Vehicle
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
@@ -48,7 +48,6 @@ 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,
|
||||
@@ -60,50 +59,6 @@ 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."""
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ from .coordinator import (
|
||||
TeslemetryEnergyHistoryCoordinator,
|
||||
TeslemetryEnergySiteInfoCoordinator,
|
||||
TeslemetryEnergySiteLiveCoordinator,
|
||||
TeslemetryMetadataCoordinator,
|
||||
TeslemetryVehicleDataCoordinator,
|
||||
)
|
||||
|
||||
@@ -29,7 +28,6 @@ class TeslemetryData:
|
||||
energysites: list[TeslemetryEnergyData]
|
||||
scopes: list[Scope]
|
||||
stream: TeslemetryStream | None
|
||||
metadata_coordinator: TeslemetryMetadataCoordinator
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -45,7 +45,13 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: 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.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
|
||||
@@ -27,28 +27,11 @@ from .const import (
|
||||
VICARE_TOKEN_FILENAME,
|
||||
)
|
||||
from .types import ViCareConfigEntry, ViCareData, ViCareDevice
|
||||
from .utils import get_device_serial, login
|
||||
from .utils import get_device, 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")
|
||||
@@ -91,7 +74,7 @@ def setup_vicare_api(hass: HomeAssistant, entry: ViCareConfigEntry) -> PyViCare:
|
||||
)
|
||||
|
||||
devices = [
|
||||
ViCareDevice(config=device_config, api=device_config.asAutoDetectDevice())
|
||||
ViCareDevice(config=device_config, api=get_device(entry, device_config))
|
||||
for device_config in device_config_list
|
||||
if bool(device_config.isOnline())
|
||||
]
|
||||
|
||||
@@ -18,7 +18,14 @@ 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 DOMAIN, VICARE_NAME, VIESSMANN_DEVELOPER_PORTAL
|
||||
from .const import (
|
||||
CONF_HEATING_TYPE,
|
||||
DEFAULT_HEATING_TYPE,
|
||||
DOMAIN,
|
||||
VICARE_NAME,
|
||||
VIESSMANN_DEVELOPER_PORTAL,
|
||||
HeatingType,
|
||||
)
|
||||
from .utils import login
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -33,6 +40,9 @@ 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]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -41,7 +51,6 @@ 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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Constants for the ViCare integration."""
|
||||
|
||||
import enum
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "vicare"
|
||||
@@ -29,6 +31,7 @@ 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
|
||||
|
||||
@@ -40,3 +43,28 @@ 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",
|
||||
}
|
||||
|
||||
@@ -24,11 +24,13 @@
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ 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,
|
||||
)
|
||||
@@ -24,7 +25,14 @@ 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 DEFAULT_CACHE_DURATION, VICARE_TOKEN_FILENAME
|
||||
from .const import (
|
||||
CONF_HEATING_TYPE,
|
||||
DEFAULT_CACHE_DURATION,
|
||||
HEATING_TYPE_TO_CREATOR_METHOD,
|
||||
VICARE_TOKEN_FILENAME,
|
||||
HeatingType,
|
||||
)
|
||||
from .types import ViCareConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,6 +54,16 @@ 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:
|
||||
|
||||
@@ -408,64 +408,9 @@ 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, 9):
|
||||
for i in range(1, 8):
|
||||
cell_key = getattr(Keys, f"CELL_{i}_VOLTAGE")
|
||||
SENSOR_DESCRIPTIONS[cell_key] = VictronBLESensorEntityDescription(
|
||||
key=cell_key,
|
||||
@@ -473,7 +418,6 @@ for i in range(1, 9):
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_placeholders={"cell": str(i)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -34,9 +34,6 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"ac_current": {
|
||||
"name": "AC current"
|
||||
},
|
||||
"ac_in_power": {
|
||||
"name": "AC-in power"
|
||||
},
|
||||
@@ -238,12 +235,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -2245,10 +2245,9 @@ class ConfigEntries:
|
||||
self._entries = entries
|
||||
self.async_update_issues()
|
||||
|
||||
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
|
||||
)
|
||||
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]
|
||||
|
||||
@@ -961,8 +961,7 @@ class HomeAssistant:
|
||||
|
||||
async def async_block_till_done(self, wait_background_tasks: bool = False) -> None:
|
||||
"""Block until all pending work is done."""
|
||||
# Sleep twice to flush out any call_soon_threadsafe
|
||||
await asyncio.sleep(0)
|
||||
# To flush out any call_soon_threadsafe
|
||||
await asyncio.sleep(0)
|
||||
start_time: float | None = None
|
||||
current_task = asyncio.current_task()
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -399,7 +399,6 @@ FLOWS = {
|
||||
"local_ip",
|
||||
"local_todo",
|
||||
"locative",
|
||||
"lojack",
|
||||
"london_underground",
|
||||
"lookin",
|
||||
"loqed",
|
||||
|
||||
@@ -3828,12 +3828,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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
7
requirements_all.txt
generated
@@ -279,7 +279,7 @@ aioharmony==0.5.3
|
||||
aiohasupervisor==0.4.1
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
aiohomeconnect==0.32.0
|
||||
aiohomeconnect==0.30.0
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==3.2.20
|
||||
@@ -1448,9 +1448,6 @@ 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
|
||||
|
||||
@@ -1691,7 +1688,7 @@ onedrive-personal-sdk==0.1.7
|
||||
onvif-zeep-async==4.0.4
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif_parsers==2.3.0
|
||||
onvif_parsers==1.2.2
|
||||
|
||||
# homeassistant.components.opengarage
|
||||
open-garage==0.2.0
|
||||
|
||||
7
requirements_test_all.txt
generated
7
requirements_test_all.txt
generated
@@ -267,7 +267,7 @@ aioharmony==0.5.3
|
||||
aiohasupervisor==0.4.1
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
aiohomeconnect==0.32.0
|
||||
aiohomeconnect==0.30.0
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==3.2.20
|
||||
@@ -1267,9 +1267,6 @@ 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
|
||||
|
||||
@@ -1477,7 +1474,7 @@ onedrive-personal-sdk==0.1.7
|
||||
onvif-zeep-async==4.0.4
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif_parsers==2.3.0
|
||||
onvif_parsers==1.2.2
|
||||
|
||||
# homeassistant.components.opengarage
|
||||
open-garage==0.2.0
|
||||
|
||||
@@ -469,6 +469,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
||||
"huisbaasje",
|
||||
"hunterdouglas_powerview",
|
||||
"husqvarna_automower_ble",
|
||||
"huum",
|
||||
"hvv_departures",
|
||||
"hydrawise",
|
||||
"hyperion",
|
||||
@@ -868,6 +869,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
||||
"sma",
|
||||
"smappee",
|
||||
"smart_meter_texas",
|
||||
"smarttub",
|
||||
"smarty",
|
||||
"smhi",
|
||||
"sms",
|
||||
@@ -1453,6 +1455,7 @@ INTEGRATIONS_WITHOUT_SCALE = [
|
||||
"huisbaasje",
|
||||
"hunterdouglas_powerview",
|
||||
"husqvarna_automower_ble",
|
||||
"huum",
|
||||
"hvv_departures",
|
||||
"hydrawise",
|
||||
"hyperion",
|
||||
|
||||
@@ -60,7 +60,6 @@ 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
|
||||
|
||||
@@ -78,7 +77,6 @@ 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
|
||||
@@ -89,7 +87,6 @@ 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
|
||||
@@ -100,7 +97,6 @@ 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
|
||||
@@ -111,7 +107,6 @@ 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
|
||||
@@ -122,7 +117,6 @@ 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
|
||||
@@ -133,7 +127,6 @@ 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
|
||||
@@ -144,7 +137,6 @@ 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
|
||||
@@ -163,7 +155,6 @@ 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
|
||||
@@ -180,7 +171,6 @@ 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
|
||||
@@ -188,7 +178,6 @@ 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
|
||||
@@ -225,7 +214,6 @@ 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
|
||||
@@ -243,7 +231,6 @@ 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
|
||||
@@ -255,7 +242,6 @@ 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
|
||||
@@ -264,7 +250,6 @@ 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
|
||||
@@ -299,14 +284,12 @@ 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
|
||||
|
||||
@@ -320,14 +303,12 @@ 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
|
||||
|
||||
@@ -344,7 +325,6 @@ 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
|
||||
|
||||
@@ -361,7 +341,6 @@ 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
|
||||
@@ -407,7 +386,6 @@ 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
|
||||
|
||||
@@ -425,7 +403,6 @@ 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
|
||||
|
||||
@@ -435,7 +412,6 @@ 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
|
||||
|
||||
@@ -445,7 +421,6 @@ 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
|
||||
|
||||
@@ -455,7 +430,6 @@ 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
|
||||
|
||||
@@ -465,7 +439,6 @@ 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
|
||||
|
||||
@@ -475,7 +448,6 @@ 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
|
||||
|
||||
@@ -485,7 +457,6 @@ 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
|
||||
|
||||
@@ -495,7 +466,6 @@ 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
|
||||
|
||||
@@ -511,7 +481,6 @@ 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
|
||||
|
||||
@@ -521,7 +490,6 @@ 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
|
||||
|
||||
@@ -531,7 +499,6 @@ 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
|
||||
|
||||
@@ -549,7 +516,6 @@ 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
|
||||
|
||||
@@ -559,7 +525,6 @@ 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
|
||||
|
||||
@@ -572,5 +537,4 @@ 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)
|
||||
|
||||
@@ -107,7 +107,6 @@ 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"
|
||||
|
||||
@@ -129,7 +128,6 @@ 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
|
||||
|
||||
@@ -150,7 +148,6 @@ 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
|
||||
|
||||
@@ -182,7 +179,6 @@ 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
|
||||
|
||||
@@ -192,7 +188,6 @@ 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
|
||||
|
||||
@@ -202,7 +197,6 @@ 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
|
||||
|
||||
@@ -225,7 +219,6 @@ 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
|
||||
@@ -247,7 +240,6 @@ 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)
|
||||
@@ -258,7 +250,6 @@ 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)
|
||||
@@ -269,7 +260,6 @@ 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)
|
||||
@@ -291,7 +281,6 @@ 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
|
||||
|
||||
@@ -301,7 +290,6 @@ 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
|
||||
|
||||
@@ -311,7 +299,6 @@ 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
|
||||
|
||||
@@ -321,7 +308,6 @@ 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
|
||||
|
||||
@@ -342,7 +328,6 @@ 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
|
||||
|
||||
@@ -352,7 +337,6 @@ 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
|
||||
|
||||
@@ -362,7 +346,6 @@ 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
|
||||
|
||||
@@ -381,7 +364,6 @@ 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"
|
||||
|
||||
@@ -391,7 +373,6 @@ 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"
|
||||
|
||||
@@ -437,7 +418,6 @@ 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
|
||||
@@ -499,7 +479,6 @@ 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
|
||||
|
||||
@@ -573,7 +552,6 @@ 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]
|
||||
|
||||
@@ -583,7 +561,6 @@ 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) == []
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ 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
|
||||
@@ -57,7 +56,6 @@ 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
|
||||
@@ -72,7 +70,6 @@ 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
|
||||
@@ -83,7 +80,6 @@ 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
|
||||
@@ -97,7 +93,6 @@ 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
|
||||
@@ -105,7 +100,6 @@ 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
|
||||
|
||||
@@ -38,18 +38,8 @@ 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 MIN service operations:
|
||||
Methods mocked for 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",
|
||||
@@ -146,7 +136,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 MIN time segment management services
|
||||
# Called by 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={
|
||||
@@ -155,131 +145,6 @@ 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
@@ -1,57 +1,4 @@
|
||||
# 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([
|
||||
|
||||
@@ -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 (MIN)
|
||||
{"device_sn": "UNK999999", "type": 3}, # Unsupported
|
||||
{"device_sn": "MIN123456", "type": 7}, # Supported
|
||||
{"device_sn": "TLX789012", "type": 5}, # 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 UNK999999 with type 3 not supported in Open API V1" in caplog.text
|
||||
assert "Device TLX789012 with type 5 not supported in Open API V1" in caplog.text
|
||||
|
||||
|
||||
async def test_migrate_version_bump(
|
||||
|
||||
@@ -17,57 +17,6 @@ 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,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Test Growatt Server services."""
|
||||
|
||||
import datetime as dt
|
||||
from unittest.mock import patch
|
||||
|
||||
import growattServer
|
||||
@@ -676,487 +675,3 @@ 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,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@@ -74,94 +74,6 @@ 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(
|
||||
|
||||
@@ -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) == 351
|
||||
assert len(mock_hap.hmip_device_by_entity_id) == 350
|
||||
|
||||
|
||||
async def test_hmip_remove_device(
|
||||
|
||||
@@ -854,59 +854,3 @@ 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
|
||||
|
||||
@@ -64,7 +64,6 @@ 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
|
||||
@@ -75,7 +74,6 @@ 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
|
||||
@@ -90,7 +88,6 @@ 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
|
||||
@@ -101,7 +98,6 @@ 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
|
||||
|
||||
@@ -281,15 +281,3 @@ 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
|
||||
|
||||
@@ -188,8 +188,6 @@ 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"
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,107 +0,0 @@
|
||||
"""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
|
||||
@@ -1,20 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,54 +0,0 @@
|
||||
# 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',
|
||||
})
|
||||
# ---
|
||||
@@ -1,32 +0,0 @@
|
||||
# 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,
|
||||
})
|
||||
# ---
|
||||
@@ -1,119 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,56 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,156 +0,0 @@
|
||||
"""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
|
||||
@@ -141,127 +141,6 @@ 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,
|
||||
|
||||
@@ -475,7 +475,6 @@ 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
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"""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_API_KEY, CONF_LLM_HASS_API
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -23,31 +22,14 @@ 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],
|
||||
mock_config_entry_data: dict[str, Any],
|
||||
hass: HomeAssistant, mock_config_entry_options: dict[str, Any]
|
||||
) -> MockConfigEntry:
|
||||
"""Mock a config entry."""
|
||||
entry = MockConfigEntry(
|
||||
domain=ollama.DOMAIN,
|
||||
data=mock_config_entry_data,
|
||||
data=TEST_USER_DATA,
|
||||
version=3,
|
||||
minor_version=2,
|
||||
subentries_data=[
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
"""Test the Ollama config flow."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import ANY, AsyncMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
from httpx import ConnectError
|
||||
from ollama import ResponseError
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import ollama
|
||||
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.const import CONF_LLM_HASS_API, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
@@ -23,14 +20,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(DOMAIN)
|
||||
hass.config.components.add(ollama.DOMAIN)
|
||||
MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
domain=ollama.DOMAIN,
|
||||
state=config_entries.ConfigEntryState.LOADED,
|
||||
).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
ollama.DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] is None
|
||||
@@ -51,18 +48,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=DOMAIN,
|
||||
domain=ollama.DOMAIN,
|
||||
data={
|
||||
ollama.CONF_URL: "http://localhost:11434",
|
||||
ollama.CONF_MODEL: "test_model",
|
||||
@@ -70,7 +67,7 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None:
|
||||
).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
ollama.DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert not result["errors"]
|
||||
@@ -144,7 +141,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": SOURCE_USER},
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert new_flow["type"] is FlowResultType.FORM
|
||||
@@ -184,7 +181,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": SOURCE_USER},
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
@@ -212,7 +209,7 @@ async def test_subentry_need_download(
|
||||
):
|
||||
new_flow = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "conversation"),
|
||||
context={"source": SOURCE_USER},
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert new_flow["type"] is FlowResultType.FORM, new_flow
|
||||
@@ -274,7 +271,7 @@ async def test_subentry_download_error(
|
||||
):
|
||||
new_flow = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "conversation"),
|
||||
context={"source": SOURCE_USER},
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert new_flow["type"] is FlowResultType.FORM
|
||||
@@ -310,130 +307,6 @@ 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"),
|
||||
[
|
||||
@@ -444,7 +317,7 @@ async def test_reauth_flow_errors(hass: HomeAssistant, side_effect, error) -> No
|
||||
async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None:
|
||||
"""Test we handle errors."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
ollama.DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
@@ -459,50 +332,10 @@ 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(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
ollama.DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
@@ -525,7 +358,7 @@ async def test_subentry_connection_error(
|
||||
):
|
||||
new_flow = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "conversation"),
|
||||
context={"source": SOURCE_USER},
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert new_flow["type"] is FlowResultType.ABORT
|
||||
@@ -547,7 +380,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": SOURCE_USER},
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert new_flow["type"] is FlowResultType.FORM
|
||||
@@ -667,7 +500,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": SOURCE_USER},
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result.get("type") is FlowResultType.FORM
|
||||
@@ -719,167 +552,8 @@ 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": SOURCE_USER},
|
||||
context={"source": config_entries.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,
|
||||
)
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
"""Tests for the Ollama integration."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import 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,
|
||||
ConfigEntryState,
|
||||
ConfigSubentryData,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryDisabler, 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
|
||||
@@ -26,7 +21,7 @@ from . import TEST_OPTIONS
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
V1_TEST_USER_DATA = {
|
||||
CONF_URL: "http://localhost:11434",
|
||||
ollama.CONF_URL: "http://localhost:11434",
|
||||
ollama.CONF_MODEL: "test_model:latest",
|
||||
}
|
||||
|
||||
@@ -63,74 +58,6 @@ 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,
|
||||
@@ -175,7 +102,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 == {CONF_URL: "http://localhost:11434"}
|
||||
assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"}
|
||||
assert mock_config_entry.options == {}
|
||||
|
||||
assert len(mock_config_entry.subentries) == 2
|
||||
@@ -821,7 +748,7 @@ async def test_migration_from_v2_2(hass: HomeAssistant) -> None:
|
||||
mock_config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_URL: "http://localhost:11434",
|
||||
ollama.CONF_URL: "http://localhost:11434",
|
||||
ollama.CONF_MODEL: "test_model:latest", # Model still in main data
|
||||
},
|
||||
version=2,
|
||||
@@ -841,7 +768,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 == {CONF_URL: "http://localhost:11434"}
|
||||
assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"}
|
||||
assert len(mock_config_entry.subentries) == 2
|
||||
|
||||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
from collections import defaultdict
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -197,7 +196,7 @@ async def setup_onvif_integration(
|
||||
source=config_entries.SOURCE_USER,
|
||||
capabilities=None,
|
||||
events=None,
|
||||
raw_events: list[tuple[str, list[EventEntity]]] | None = None,
|
||||
raw_events: list[tuple[str, EventEntity]] | None = None,
|
||||
) -> tuple[MockConfigEntry, MagicMock, MagicMock]:
|
||||
"""Create an ONVIF config entry."""
|
||||
if not config:
|
||||
@@ -240,14 +239,12 @@ 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: collections.defaultdict[str, list[EventEntity]] = (
|
||||
collections.defaultdict(list)
|
||||
)
|
||||
for topic, topic_events in raw_events:
|
||||
event_by_topic: dict[str, EventEntity] = {}
|
||||
for topic, raw_event in raw_events:
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.Topic._value_1 = topic
|
||||
mock_messages.append(mock_msg)
|
||||
event_by_topic[topic].extend(topic_events)
|
||||
event_by_topic[topic] = raw_event
|
||||
|
||||
async def mock_parse(topic, unique_id, msg):
|
||||
return event_by_topic.get(topic)
|
||||
|
||||
@@ -95,15 +95,13 @@ 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",
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -123,15 +121,13 @@ 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",
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -139,40 +135,3 @@ 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
|
||||
|
||||
@@ -21,13 +21,12 @@ def config_data() -> dict[str, Any]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_entry(config_data: dict[str, Any], account) -> MockConfigEntry:
|
||||
def config_entry(config_data: dict[str, Any]) -> MockConfigEntry:
|
||||
"""Create a mock config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=config_data,
|
||||
options={},
|
||||
unique_id=account.id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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,
|
||||
@@ -13,6 +14,7 @@ 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,
|
||||
@@ -30,16 +32,25 @@ from homeassistant.core import HomeAssistant
|
||||
from . import trigger_update
|
||||
|
||||
|
||||
async def test_thermostat_state(
|
||||
async def test_thermostat_update(
|
||||
spa, spa_state, setup_entry, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""Test the thermostat entity initial state and attributes."""
|
||||
"""Test the thermostat entity."""
|
||||
|
||||
entity_id = f"climate.{spa.brand}_{spa.model}_thermostat"
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == HVACMode.HEAT
|
||||
assert set(state.attributes[ATTR_HVAC_MODES]) == {HVACMode.HEAT}
|
||||
|
||||
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 (
|
||||
state.attributes[ATTR_SUPPORTED_FEATURES]
|
||||
== ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
@@ -49,28 +60,7 @@ async def test_thermostat_state(
|
||||
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,
|
||||
@@ -79,12 +69,15 @@ async def test_thermostat_set_temperature(
|
||||
)
|
||||
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
|
||||
|
||||
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"
|
||||
assert state.attributes.get(ATTR_PRESET_MODE) == PRESET_NONE
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
@@ -98,9 +91,6 @@ async def test_thermostat_set_preset_mode(
|
||||
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
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from smarttub import LoginFailed
|
||||
|
||||
from homeassistant import config_entries
|
||||
@@ -14,43 +13,35 @@ from homeassistant.data_entry_flow import FlowResultType
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@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."""
|
||||
async def test_form(hass: HomeAssistant) -> None:
|
||||
"""Test we get the form."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_EMAIL: "test-email", CONF_PASSWORD: "test-password"},
|
||||
)
|
||||
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"},
|
||||
)
|
||||
|
||||
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()
|
||||
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()
|
||||
|
||||
|
||||
async def test_form_invalid_auth(
|
||||
hass: HomeAssistant, smarttub_api, mock_setup_entry
|
||||
) -> None:
|
||||
"""Test we handle invalid auth and can recover."""
|
||||
async def test_form_invalid_auth(hass: HomeAssistant, smarttub_api) -> None:
|
||||
"""Test we handle invalid auth."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
@@ -65,21 +56,17 @@ async def test_form_invalid_auth(
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
smarttub_api.login.side_effect = None
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def test_reauth_success(hass: HomeAssistant, smarttub_api, config_entry) -> None:
|
||||
async def test_reauth_success(hass: HomeAssistant, smarttub_api, account) -> None:
|
||||
"""Test reauthentication flow."""
|
||||
config_entry.add_to_hass(hass)
|
||||
mock_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_EMAIL: "test-email", CONF_PASSWORD: "test-password"},
|
||||
unique_id=account.id,
|
||||
)
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
result = await config_entry.start_reauth_flow(hass)
|
||||
result = await mock_entry.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
@@ -90,15 +77,18 @@ async def test_reauth_success(hass: HomeAssistant, smarttub_api, config_entry) -
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert config_entry.data[CONF_EMAIL] == "test-email3"
|
||||
assert config_entry.data[CONF_PASSWORD] == "test-password3"
|
||||
assert mock_entry.data[CONF_EMAIL] == "test-email3"
|
||||
assert mock_entry.data[CONF_PASSWORD] == "test-password3"
|
||||
|
||||
|
||||
async def test_reauth_wrong_account(
|
||||
hass: HomeAssistant, smarttub_api, account, config_entry
|
||||
) -> None:
|
||||
async def test_reauth_wrong_account(hass: HomeAssistant, smarttub_api, account) -> None:
|
||||
"""Test reauthentication flow if the user enters credentials for a different already-configured account."""
|
||||
config_entry.add_to_hass(hass)
|
||||
mock_entry1 = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_EMAIL: "test-email1", CONF_PASSWORD: "test-password1"},
|
||||
unique_id=account.id,
|
||||
)
|
||||
mock_entry1.add_to_hass(hass)
|
||||
|
||||
mock_entry2 = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
@@ -108,7 +98,7 @@ async def test_reauth_wrong_account(
|
||||
mock_entry2.add_to_hass(hass)
|
||||
|
||||
# we try to reauth account #2, and the user successfully authenticates to account #1
|
||||
account.id = config_entry.unique_id
|
||||
account.id = mock_entry1.unique_id
|
||||
result = await mock_entry2.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""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(
|
||||
@@ -36,23 +39,34 @@ async def test_setup_auth_failed(
|
||||
smarttub_api.login.side_effect = LoginFailed
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
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,
|
||||
)
|
||||
|
||||
assert config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
|
||||
assert len(flows) == 1
|
||||
assert flows[0]["context"]["source"] == SOURCE_REAUTH
|
||||
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)
|
||||
|
||||
|
||||
async def test_unload_entry(hass: HomeAssistant, config_entry) -> None:
|
||||
"""Test being able to unload an entry."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
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 async_setup_component(hass, DOMAIN, {}) is True
|
||||
|
||||
assert await hass.config_entries.async_unload(config_entry.entry_id)
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
@@ -7,73 +7,49 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pump_id", "entity_suffix", "expected_state"),
|
||||
("pump_id", "entity_suffix", "pump_state"),
|
||||
[
|
||||
("CP", "circulation_pump", STATE_OFF),
|
||||
("P1", "jet_p1", STATE_OFF),
|
||||
("P2", "jet_p2", STATE_ON),
|
||||
("CP", "circulation_pump", "off"),
|
||||
("P1", "jet_p1", "off"),
|
||||
("P2", "jet_p2", "on"),
|
||||
],
|
||||
)
|
||||
async def test_pump_state(
|
||||
spa, setup_entry, hass: HomeAssistant, pump_id, entity_suffix, expected_state
|
||||
async def test_pumps(
|
||||
spa, setup_entry, hass: HomeAssistant, pump_id, pump_state, entity_suffix
|
||||
) -> None:
|
||||
"""Test pump entity initial state."""
|
||||
"""Test pump entities."""
|
||||
|
||||
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}"
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
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}"
|
||||
assert state.state == pump_state
|
||||
|
||||
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
|
||||
|
||||
@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()
|
||||
await hass.services.async_call(
|
||||
"switch",
|
||||
"turn_off",
|
||||
{"entity_id": entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
pump.toggle.assert_called()
|
||||
|
||||
@@ -66,12 +66,6 @@ METADATA = {
|
||||
"name": "Home Assistant",
|
||||
}
|
||||
},
|
||||
"energy_sites": {
|
||||
"123456": {
|
||||
"access": True,
|
||||
"name": "Energy Site",
|
||||
}
|
||||
},
|
||||
}
|
||||
METADATA_LEGACY = {
|
||||
"uid": UNIQUE_ID,
|
||||
@@ -98,12 +92,6 @@ METADATA_LEGACY = {
|
||||
"name": "Home Assistant",
|
||||
}
|
||||
},
|
||||
"energy_sites": {
|
||||
"123456": {
|
||||
"access": True,
|
||||
"name": "Energy Site",
|
||||
}
|
||||
},
|
||||
}
|
||||
METADATA_NOSCOPE = {
|
||||
"uid": UNIQUE_ID,
|
||||
@@ -120,10 +108,4 @@ METADATA_NOSCOPE = {
|
||||
"name": "Home Assistant",
|
||||
}
|
||||
},
|
||||
"energy_sites": {
|
||||
"123456": {
|
||||
"access": True,
|
||||
"name": "Energy Site",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ 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
|
||||
@@ -44,8 +43,6 @@ from .const import (
|
||||
CONFIG_V1,
|
||||
ENERGY_HISTORY,
|
||||
LIVE_STATUS,
|
||||
METADATA,
|
||||
METADATA_NOSCOPE,
|
||||
PRODUCTS_MODERN,
|
||||
SITE_INFO,
|
||||
UNIQUE_ID,
|
||||
@@ -274,37 +271,6 @@ 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,
|
||||
@@ -898,83 +864,3 @@ 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()
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
|
||||
@@ -15,6 +16,7 @@ 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"
|
||||
|
||||
@@ -76,7 +76,6 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
unique_id="ViCare",
|
||||
entry_id="1234",
|
||||
data=ENTRY_CONFIG,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# name: test_form_dhcp
|
||||
dict({
|
||||
'client_id': '5678',
|
||||
'heating_type': 'auto',
|
||||
'password': '1234',
|
||||
'username': 'foo@bar.com',
|
||||
})
|
||||
@@ -9,6 +10,7 @@
|
||||
# name: test_user_create_entry
|
||||
dict({
|
||||
'client_id': '5678',
|
||||
'heating_type': 'auto',
|
||||
'password': '1234',
|
||||
'username': 'foo@bar.com',
|
||||
})
|
||||
|
||||
@@ -4724,6 +4724,7 @@
|
||||
'entry': dict({
|
||||
'data': dict({
|
||||
'client_id': '**REDACTED**',
|
||||
'heating_type': 'auto',
|
||||
'password': '**REDACTED**',
|
||||
'username': '**REDACTED**',
|
||||
}),
|
||||
@@ -4732,7 +4733,7 @@
|
||||
}),
|
||||
'domain': 'vicare',
|
||||
'entry_id': '1234',
|
||||
'minor_version': 2,
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'pref_disable_new_entities': False,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user