mirror of
https://github.com/home-assistant/core.git
synced 2026-03-17 16:32:04 +01:00
Compare commits
34 Commits
drop-ignor
...
add_temper
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7641fbecfb | ||
|
|
45810ba958 | ||
|
|
5470d7fb81 | ||
|
|
39b44445ec | ||
|
|
589622c05a | ||
|
|
6abe576ec9 | ||
|
|
75978d8837 | ||
|
|
a2da13a0b3 | ||
|
|
ce081d7e71 | ||
|
|
037e123e11 | ||
|
|
592b7e5594 | ||
|
|
a963eed3a7 | ||
|
|
2042f2e2bd | ||
|
|
3580fab26e | ||
|
|
1817522107 | ||
|
|
98a9ce3a64 | ||
|
|
163bfb0fdd | ||
|
|
66f04c702c | ||
|
|
41c497c49e | ||
|
|
c25a664365 | ||
|
|
3dec70abce | ||
|
|
3c2f696a23 | ||
|
|
54745dc1f2 | ||
|
|
e4345c72d9 | ||
|
|
7acb253ae2 | ||
|
|
812c63eeb7 | ||
|
|
7f13731035 | ||
|
|
879178e8a2 | ||
|
|
4d8cedb061 | ||
|
|
e9f0d8a550 | ||
|
|
c5a04deb28 | ||
|
|
f2a205e8d7 | ||
|
|
254aa30ad8 | ||
|
|
de4025634a |
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -709,7 +709,7 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pylint homeassistant
|
||||
pylint --ignore-missing-annotations=y homeassistant
|
||||
- name: Run pylint (partially)
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
shell: bash
|
||||
@@ -718,7 +718,7 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pylint $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
|
||||
pylint --ignore-missing-annotations=y $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
|
||||
|
||||
pylint-tests:
|
||||
name: Check pylint on tests
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -974,6 +974,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/logbook/ @home-assistant/core
|
||||
/homeassistant/components/logger/ @home-assistant/core
|
||||
/tests/components/logger/ @home-assistant/core
|
||||
/homeassistant/components/lojack/ @devinslick
|
||||
/tests/components/lojack/ @devinslick
|
||||
/homeassistant/components/london_underground/ @jpbede
|
||||
/tests/components/london_underground/ @jpbede
|
||||
/homeassistant/components/lookin/ @ANMalko @bdraco
|
||||
@@ -1697,6 +1699,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/tellduslive/ @fredrike
|
||||
/homeassistant/components/teltonika/ @karlbeecken
|
||||
/tests/components/teltonika/ @karlbeecken
|
||||
/homeassistant/components/temperature/ @home-assistant/core
|
||||
/tests/components/temperature/ @home-assistant/core
|
||||
/homeassistant/components/template/ @Petro31 @home-assistant/core
|
||||
/tests/components/template/ @Petro31 @home-assistant/core
|
||||
/homeassistant/components/tesla_fleet/ @Bre77
|
||||
|
||||
@@ -247,6 +247,7 @@ DEFAULT_INTEGRATIONS = {
|
||||
"humidity",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"temperature",
|
||||
"window",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
|
||||
@@ -338,6 +338,7 @@ class Analytics:
|
||||
|
||||
hass = self._hass
|
||||
supervisor_info = None
|
||||
addons_info: dict[str, Any] | None = None
|
||||
operating_system_info: dict[str, Any] = {}
|
||||
|
||||
if self._data.uuid is None:
|
||||
@@ -347,6 +348,7 @@ class Analytics:
|
||||
if self.supervisor:
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
operating_system_info = hassio.get_os_info(hass) or {}
|
||||
addons_info = hassio.get_addons_info(hass) or {}
|
||||
|
||||
system_info = await async_get_system_info(hass)
|
||||
integrations = []
|
||||
@@ -419,13 +421,10 @@ class Analytics:
|
||||
|
||||
integrations.append(integration.domain)
|
||||
|
||||
if supervisor_info is not None:
|
||||
if addons_info is not None:
|
||||
supervisor_client = hassio.get_supervisor_client(hass)
|
||||
installed_addons = await asyncio.gather(
|
||||
*(
|
||||
supervisor_client.addons.addon_info(addon[ATTR_SLUG])
|
||||
for addon in supervisor_info[ATTR_ADDONS]
|
||||
)
|
||||
*(supervisor_client.addons.addon_info(slug) for slug in addons_info)
|
||||
)
|
||||
addons.extend(
|
||||
{
|
||||
|
||||
@@ -160,6 +160,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"schedule",
|
||||
"siren",
|
||||
"switch",
|
||||
"temperature",
|
||||
"text",
|
||||
"update",
|
||||
"vacuum",
|
||||
|
||||
@@ -239,6 +239,9 @@ def _login_classic_api(
|
||||
return login_response
|
||||
|
||||
|
||||
V1_DEVICE_TYPES: dict[int, str] = {5: "sph", 7: "min"}
|
||||
|
||||
|
||||
def get_device_list_v1(
|
||||
api, config: Mapping[str, str]
|
||||
) -> tuple[list[dict[str, str]], str]:
|
||||
@@ -260,18 +263,17 @@ def get_device_list_v1(
|
||||
f"API error during device list: {e.error_msg or str(e)} (Code: {e.error_code})"
|
||||
) from e
|
||||
devices = devices_dict.get("devices", [])
|
||||
# Only MIN device (type = 7) support implemented in current V1 API
|
||||
supported_devices = [
|
||||
{
|
||||
"deviceSn": device.get("device_sn", ""),
|
||||
"deviceType": "min",
|
||||
"deviceType": V1_DEVICE_TYPES[device.get("type")],
|
||||
}
|
||||
for device in devices
|
||||
if device.get("type") == 7
|
||||
if device.get("type") in V1_DEVICE_TYPES
|
||||
]
|
||||
|
||||
for device in devices:
|
||||
if device.get("type") != 7:
|
||||
if device.get("type") not in V1_DEVICE_TYPES:
|
||||
_LOGGER.warning(
|
||||
"Device %s with type %s not supported in Open API V1, skipping",
|
||||
device.get("device_sn", ""),
|
||||
@@ -348,7 +350,7 @@ async def async_setup_entry(
|
||||
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
|
||||
)
|
||||
for device in devices
|
||||
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min"]
|
||||
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min", "sph"]
|
||||
}
|
||||
|
||||
# Perform the first refresh for the total coordinator
|
||||
|
||||
@@ -167,6 +167,36 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
**storage_info_detail["storageDetailBean"],
|
||||
**storage_energy_overview,
|
||||
}
|
||||
elif self.device_type == "sph":
|
||||
try:
|
||||
sph_detail = self.api.sph_detail(self.device_id)
|
||||
sph_energy = self.api.sph_energy(self.device_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching SPH device data: {err}") from err
|
||||
|
||||
combined = {**sph_detail, **sph_energy}
|
||||
|
||||
# Parse last update timestamp from sph_energy "time" field
|
||||
time_str = sph_energy.get("time")
|
||||
if time_str:
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S")
|
||||
combined["lastdataupdate"] = parsed.replace(
|
||||
tzinfo=dt_util.get_default_time_zone()
|
||||
)
|
||||
except ValueError, TypeError:
|
||||
_LOGGER.debug(
|
||||
"Could not parse SPH time field for %s: %r",
|
||||
self.device_id,
|
||||
time_str,
|
||||
)
|
||||
|
||||
self.data = combined
|
||||
_LOGGER.debug("sph_info for device %s: %r", self.device_id, self.data)
|
||||
elif self.device_type == "mix":
|
||||
mix_info = self.api.mix_info(self.device_id)
|
||||
mix_totals = self.api.mix_totals(self.device_id, self.plant_id)
|
||||
@@ -448,3 +478,123 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
return "00:00"
|
||||
else:
|
||||
return f"{hour:02d}:{minute:02d}"
|
||||
|
||||
async def update_ac_charge_times(
|
||||
self,
|
||||
charge_power: int,
|
||||
charge_stop_soc: int,
|
||||
mains_enabled: bool,
|
||||
periods: list[dict],
|
||||
) -> None:
|
||||
"""Update AC charge time periods for SPH device.
|
||||
|
||||
Args:
|
||||
charge_power: Charge power limit (0-100 %)
|
||||
charge_stop_soc: Stop charging at this SOC level (0-100 %)
|
||||
mains_enabled: Whether AC (mains) charging is enabled
|
||||
periods: List of up to 3 dicts with keys start_time, end_time, enabled
|
||||
"""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Updating AC charge times requires token authentication"
|
||||
)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.api.sph_write_ac_charge_times,
|
||||
self.device_id,
|
||||
charge_power,
|
||||
charge_stop_soc,
|
||||
mains_enabled,
|
||||
periods,
|
||||
)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
raise HomeAssistantError(
|
||||
f"API error updating AC charge times: {err}"
|
||||
) from err
|
||||
|
||||
if self.data:
|
||||
self.data["chargePowerCommand"] = charge_power
|
||||
self.data["wchargeSOCLowLimit"] = charge_stop_soc
|
||||
self.data["acChargeEnable"] = 1 if mains_enabled else 0
|
||||
for i, period in enumerate(periods, 1):
|
||||
self.data[f"forcedChargeTimeStart{i}"] = period["start_time"].strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
self.data[f"forcedChargeTimeStop{i}"] = period["end_time"].strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
self.data[f"forcedChargeStopSwitch{i}"] = (
|
||||
1 if period.get("enabled", False) else 0
|
||||
)
|
||||
self.async_set_updated_data(self.data)
|
||||
|
||||
async def update_ac_discharge_times(
|
||||
self,
|
||||
discharge_power: int,
|
||||
discharge_stop_soc: int,
|
||||
periods: list[dict],
|
||||
) -> None:
|
||||
"""Update AC discharge time periods for SPH device.
|
||||
|
||||
Args:
|
||||
discharge_power: Discharge power limit (0-100 %)
|
||||
discharge_stop_soc: Stop discharging at this SOC level (0-100 %)
|
||||
periods: List of up to 3 dicts with keys start_time, end_time, enabled
|
||||
"""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Updating AC discharge times requires token authentication"
|
||||
)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.api.sph_write_ac_discharge_times,
|
||||
self.device_id,
|
||||
discharge_power,
|
||||
discharge_stop_soc,
|
||||
periods,
|
||||
)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
raise HomeAssistantError(
|
||||
f"API error updating AC discharge times: {err}"
|
||||
) from err
|
||||
|
||||
if self.data:
|
||||
self.data["disChargePowerCommand"] = discharge_power
|
||||
self.data["wdisChargeSOCLowLimit"] = discharge_stop_soc
|
||||
for i, period in enumerate(periods, 1):
|
||||
self.data[f"forcedDischargeTimeStart{i}"] = period[
|
||||
"start_time"
|
||||
].strftime("%H:%M")
|
||||
self.data[f"forcedDischargeTimeStop{i}"] = period["end_time"].strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
self.data[f"forcedDischargeStopSwitch{i}"] = (
|
||||
1 if period.get("enabled", False) else 0
|
||||
)
|
||||
self.async_set_updated_data(self.data)
|
||||
|
||||
async def read_ac_charge_times(self) -> dict:
|
||||
"""Read AC charge time settings from SPH device cache."""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Reading AC charge times requires token authentication"
|
||||
)
|
||||
|
||||
if not self.data:
|
||||
await self.async_refresh()
|
||||
|
||||
return self.api.sph_read_ac_charge_times(settings_data=self.data)
|
||||
|
||||
async def read_ac_discharge_times(self) -> dict:
|
||||
"""Read AC discharge time settings from SPH device cache."""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Reading AC discharge times requires token authentication"
|
||||
)
|
||||
|
||||
if not self.data:
|
||||
await self.async_refresh()
|
||||
|
||||
return self.api.sph_read_ac_discharge_times(settings_data=self.data)
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
{
|
||||
"services": {
|
||||
"read_ac_charge_times": {
|
||||
"service": "mdi:battery-clock-outline"
|
||||
},
|
||||
"read_ac_discharge_times": {
|
||||
"service": "mdi:battery-clock-outline"
|
||||
},
|
||||
"read_time_segments": {
|
||||
"service": "mdi:clock-outline"
|
||||
},
|
||||
"update_time_segment": {
|
||||
"service": "mdi:clock-edit"
|
||||
},
|
||||
"write_ac_charge_times": {
|
||||
"service": "mdi:battery-clock"
|
||||
},
|
||||
"write_ac_discharge_times": {
|
||||
"service": "mdi:battery-clock"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ from ..coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
from .inverter import INVERTER_SENSOR_TYPES
|
||||
from .mix import MIX_SENSOR_TYPES
|
||||
from .sensor_entity_description import GrowattSensorEntityDescription
|
||||
from .sph import SPH_SENSOR_TYPES
|
||||
from .storage import STORAGE_SENSOR_TYPES
|
||||
from .tlx import TLX_SENSOR_TYPES
|
||||
from .total import TOTAL_SENSOR_TYPES
|
||||
@@ -57,6 +58,8 @@ async def async_setup_entry(
|
||||
sensor_descriptions = list(STORAGE_SENSOR_TYPES)
|
||||
elif device_coordinator.device_type == "mix":
|
||||
sensor_descriptions = list(MIX_SENSOR_TYPES)
|
||||
elif device_coordinator.device_type == "sph":
|
||||
sensor_descriptions = list(SPH_SENSOR_TYPES)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Device type %s was found but is not supported right now",
|
||||
|
||||
291
homeassistant/components/growatt_server/sensor/sph.py
Normal file
291
homeassistant/components/growatt_server/sensor/sph.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""Growatt Sensor definitions for the SPH type."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfFrequency,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
|
||||
from .sensor_entity_description import GrowattSensorEntityDescription
|
||||
|
||||
SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
# Values from 'sph_detail' API call
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_statement_of_charge",
|
||||
translation_key="mix_statement_of_charge",
|
||||
api_key="bmsSOC",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_voltage",
|
||||
translation_key="mix_battery_voltage",
|
||||
api_key="vbat",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_pv1_voltage",
|
||||
translation_key="mix_pv1_voltage",
|
||||
api_key="vpv1",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_pv2_voltage",
|
||||
translation_key="mix_pv2_voltage",
|
||||
api_key="vpv2",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_grid_voltage",
|
||||
translation_key="mix_grid_voltage",
|
||||
api_key="vac1",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_charge",
|
||||
translation_key="mix_battery_charge",
|
||||
api_key="pcharge1",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_discharge_w",
|
||||
translation_key="mix_battery_discharge_w",
|
||||
api_key="pdischarge1",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_export_to_grid",
|
||||
translation_key="mix_export_to_grid",
|
||||
api_key="pacToGridTotal",
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_import_from_grid",
|
||||
translation_key="mix_import_from_grid",
|
||||
api_key="pacToUserR",
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_grid_frequency",
|
||||
translation_key="sph_grid_frequency",
|
||||
api_key="fac",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_1",
|
||||
translation_key="sph_temperature_1",
|
||||
api_key="temp1",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_2",
|
||||
translation_key="sph_temperature_2",
|
||||
api_key="temp2",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_3",
|
||||
translation_key="sph_temperature_3",
|
||||
api_key="temp3",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_4",
|
||||
translation_key="sph_temperature_4",
|
||||
api_key="temp4",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_5",
|
||||
translation_key="sph_temperature_5",
|
||||
api_key="temp5",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Values from 'sph_energy' API call
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_wattage_pv_1",
|
||||
translation_key="mix_wattage_pv_1",
|
||||
api_key="ppv1",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_wattage_pv_2",
|
||||
translation_key="mix_wattage_pv_2",
|
||||
api_key="ppv2",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_wattage_pv_all",
|
||||
translation_key="mix_wattage_pv_all",
|
||||
api_key="ppv",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_charge_today",
|
||||
translation_key="mix_battery_charge_today",
|
||||
api_key="echarge1Today",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_charge_lifetime",
|
||||
translation_key="mix_battery_charge_lifetime",
|
||||
api_key="echarge1Total",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_discharge_today",
|
||||
translation_key="mix_battery_discharge_today",
|
||||
api_key="edischarge1Today",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_discharge_lifetime",
|
||||
translation_key="mix_battery_discharge_lifetime",
|
||||
api_key="edischarge1Total",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_solar_generation_today",
|
||||
translation_key="mix_solar_generation_today",
|
||||
api_key="epvtoday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_solar_generation_lifetime",
|
||||
translation_key="mix_solar_generation_lifetime",
|
||||
api_key="epvTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_system_production_today",
|
||||
translation_key="mix_system_production_today",
|
||||
api_key="esystemtoday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_self_consumption_today",
|
||||
translation_key="mix_self_consumption_today",
|
||||
api_key="eselfToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_import_from_grid_today",
|
||||
translation_key="mix_import_from_grid_today",
|
||||
api_key="etoUserToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_export_to_grid_today",
|
||||
translation_key="mix_export_to_grid_today",
|
||||
api_key="etoGridToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_export_to_grid_lifetime",
|
||||
translation_key="mix_export_to_grid_lifetime",
|
||||
api_key="etogridTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_load_consumption_today",
|
||||
translation_key="mix_load_consumption_today",
|
||||
api_key="elocalLoadToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_load_consumption_lifetime",
|
||||
translation_key="mix_load_consumption_lifetime",
|
||||
api_key="elocalLoadTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_load_consumption_battery_today",
|
||||
translation_key="mix_load_consumption_battery_today",
|
||||
api_key="echarge1",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_load_consumption_solar_today",
|
||||
translation_key="mix_load_consumption_solar_today",
|
||||
api_key="eChargeToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
# Synthetic timestamp from 'time' field in sph_energy response
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_last_update",
|
||||
translation_key="mix_last_update",
|
||||
api_key="lastdataupdate",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
),
|
||||
)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@@ -21,67 +21,77 @@ if TYPE_CHECKING:
|
||||
from .coordinator import GrowattCoordinator
|
||||
|
||||
|
||||
def _get_coordinators(
|
||||
hass: HomeAssistant, device_type: str
|
||||
) -> dict[str, GrowattCoordinator]:
|
||||
"""Get all coordinators of a given device type with V1 API."""
|
||||
coordinators: dict[str, GrowattCoordinator] = {}
|
||||
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.state != ConfigEntryState.LOADED:
|
||||
continue
|
||||
|
||||
for coord in entry.runtime_data.devices.values():
|
||||
if coord.device_type == device_type and coord.api_version == "v1":
|
||||
coordinators[coord.device_id] = coord
|
||||
|
||||
return coordinators
|
||||
|
||||
|
||||
def _get_coordinator(
|
||||
hass: HomeAssistant, device_id: str, device_type: str
|
||||
) -> GrowattCoordinator:
|
||||
"""Get coordinator by device registry ID and device type."""
|
||||
coordinators = _get_coordinators(hass, device_type)
|
||||
|
||||
if not coordinators:
|
||||
raise ServiceValidationError(
|
||||
f"No {device_type.upper()} devices with token authentication are configured. "
|
||||
f"Services require {device_type.upper()} devices with V1 API access."
|
||||
)
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if not device_entry:
|
||||
raise ServiceValidationError(f"Device '{device_id}' not found")
|
||||
|
||||
serial_number = None
|
||||
for identifier in device_entry.identifiers:
|
||||
if identifier[0] == DOMAIN:
|
||||
serial_number = identifier[1]
|
||||
break
|
||||
|
||||
if not serial_number:
|
||||
raise ServiceValidationError(f"Device '{device_id}' is not a Growatt device")
|
||||
|
||||
if serial_number not in coordinators:
|
||||
raise ServiceValidationError(
|
||||
f"{device_type.upper()} device '{serial_number}' not found or not configured for services"
|
||||
)
|
||||
|
||||
return coordinators[serial_number]
|
||||
|
||||
|
||||
def _parse_time_str(time_str: str, field_name: str) -> time:
|
||||
"""Parse a time string (HH:MM or HH:MM:SS) to a datetime.time object."""
|
||||
parts = time_str.split(":")
|
||||
if len(parts) not in (2, 3):
|
||||
raise ServiceValidationError(
|
||||
f"{field_name} must be in HH:MM or HH:MM:SS format"
|
||||
)
|
||||
try:
|
||||
return datetime.strptime(f"{parts[0]}:{parts[1]}", "%H:%M").time()
|
||||
except (ValueError, IndexError) as err:
|
||||
raise ServiceValidationError(
|
||||
f"{field_name} must be in HH:MM or HH:MM:SS format"
|
||||
) from err
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register services for Growatt Server integration."""
|
||||
|
||||
def get_min_coordinators() -> dict[str, GrowattCoordinator]:
|
||||
"""Get all MIN coordinators with V1 API from loaded config entries."""
|
||||
min_coordinators: dict[str, GrowattCoordinator] = {}
|
||||
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.state != ConfigEntryState.LOADED:
|
||||
continue
|
||||
|
||||
# Add MIN coordinators from this entry
|
||||
for coord in entry.runtime_data.devices.values():
|
||||
if coord.device_type == "min" and coord.api_version == "v1":
|
||||
min_coordinators[coord.device_id] = coord
|
||||
|
||||
return min_coordinators
|
||||
|
||||
def get_coordinator(device_id: str) -> GrowattCoordinator:
|
||||
"""Get coordinator by device_id.
|
||||
|
||||
Args:
|
||||
device_id: Device registry ID (not serial number)
|
||||
"""
|
||||
# Get current coordinators (they may have changed since service registration)
|
||||
min_coordinators = get_min_coordinators()
|
||||
|
||||
if not min_coordinators:
|
||||
raise ServiceValidationError(
|
||||
"No MIN devices with token authentication are configured. "
|
||||
"Services require MIN devices with V1 API access."
|
||||
)
|
||||
|
||||
# Device registry ID provided - map to serial number
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if not device_entry:
|
||||
raise ServiceValidationError(f"Device '{device_id}' not found")
|
||||
|
||||
# Extract serial number from device identifiers
|
||||
serial_number = None
|
||||
for identifier in device_entry.identifiers:
|
||||
if identifier[0] == DOMAIN:
|
||||
serial_number = identifier[1]
|
||||
break
|
||||
|
||||
if not serial_number:
|
||||
raise ServiceValidationError(
|
||||
f"Device '{device_id}' is not a Growatt device"
|
||||
)
|
||||
|
||||
# Find coordinator by serial number
|
||||
if serial_number not in min_coordinators:
|
||||
raise ServiceValidationError(
|
||||
f"MIN device '{serial_number}' not found or not configured for services"
|
||||
)
|
||||
|
||||
return min_coordinators[serial_number]
|
||||
|
||||
async def handle_update_time_segment(call: ServiceCall) -> None:
|
||||
"""Handle update_time_segment service call."""
|
||||
segment_id: int = int(call.data["segment_id"])
|
||||
@@ -91,13 +101,11 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
enabled: bool = call.data["enabled"]
|
||||
device_id: str = call.data["device_id"]
|
||||
|
||||
# Validate segment_id range
|
||||
if not 1 <= segment_id <= 9:
|
||||
raise ServiceValidationError(
|
||||
f"segment_id must be between 1 and 9, got {segment_id}"
|
||||
)
|
||||
|
||||
# Validate and convert batt_mode string to integer
|
||||
valid_modes = {
|
||||
"load_first": BATT_MODE_LOAD_FIRST,
|
||||
"battery_first": BATT_MODE_BATTERY_FIRST,
|
||||
@@ -109,50 +117,121 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
)
|
||||
batt_mode: int = valid_modes[batt_mode_str]
|
||||
|
||||
# Convert time strings to datetime.time objects
|
||||
# UI time selector sends HH:MM:SS, but we only need HH:MM (strip seconds)
|
||||
try:
|
||||
# Take only HH:MM part (ignore seconds if present)
|
||||
start_parts = start_time_str.split(":")
|
||||
start_time_hhmm = f"{start_parts[0]}:{start_parts[1]}"
|
||||
start_time = datetime.strptime(start_time_hhmm, "%H:%M").time()
|
||||
except (ValueError, IndexError) as err:
|
||||
raise ServiceValidationError(
|
||||
"start_time must be in HH:MM or HH:MM:SS format"
|
||||
) from err
|
||||
|
||||
try:
|
||||
# Take only HH:MM part (ignore seconds if present)
|
||||
end_parts = end_time_str.split(":")
|
||||
end_time_hhmm = f"{end_parts[0]}:{end_parts[1]}"
|
||||
end_time = datetime.strptime(end_time_hhmm, "%H:%M").time()
|
||||
except (ValueError, IndexError) as err:
|
||||
raise ServiceValidationError(
|
||||
"end_time must be in HH:MM or HH:MM:SS format"
|
||||
) from err
|
||||
|
||||
# Get the appropriate MIN coordinator
|
||||
coordinator: GrowattCoordinator = get_coordinator(device_id)
|
||||
start_time = _parse_time_str(start_time_str, "start_time")
|
||||
end_time = _parse_time_str(end_time_str, "end_time")
|
||||
|
||||
coordinator: GrowattCoordinator = _get_coordinator(hass, device_id, "min")
|
||||
await coordinator.update_time_segment(
|
||||
segment_id,
|
||||
batt_mode,
|
||||
start_time,
|
||||
end_time,
|
||||
enabled,
|
||||
segment_id, batt_mode, start_time, end_time, enabled
|
||||
)
|
||||
|
||||
async def handle_read_time_segments(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Handle read_time_segments service call."""
|
||||
device_id: str = call.data["device_id"]
|
||||
|
||||
# Get the appropriate MIN coordinator
|
||||
coordinator: GrowattCoordinator = get_coordinator(device_id)
|
||||
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "min"
|
||||
)
|
||||
time_segments: list[dict[str, Any]] = await coordinator.read_time_segments()
|
||||
|
||||
return {"time_segments": time_segments}
|
||||
|
||||
async def handle_write_ac_charge_times(call: ServiceCall) -> None:
|
||||
"""Handle write_ac_charge_times service call for SPH devices."""
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "sph"
|
||||
)
|
||||
# Read current settings first — the SPH API requires all 3 periods in
|
||||
# every write call. Any period not supplied by the caller is filled in
|
||||
# from the cache so existing settings are not overwritten with zeros.
|
||||
current = await coordinator.read_ac_charge_times()
|
||||
|
||||
charge_power: int = int(call.data.get("charge_power", current["charge_power"]))
|
||||
charge_stop_soc: int = int(
|
||||
call.data.get("charge_stop_soc", current["charge_stop_soc"])
|
||||
)
|
||||
mains_enabled: bool = call.data.get("mains_enabled", current["mains_enabled"])
|
||||
|
||||
if not 0 <= charge_power <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"charge_power must be between 0 and 100, got {charge_power}"
|
||||
)
|
||||
if not 0 <= charge_stop_soc <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"charge_stop_soc must be between 0 and 100, got {charge_stop_soc}"
|
||||
)
|
||||
|
||||
periods = []
|
||||
for i in range(1, 4):
|
||||
cached = current["periods"][i - 1]
|
||||
start = _parse_time_str(
|
||||
call.data.get(f"period_{i}_start", cached["start_time"]),
|
||||
f"period_{i}_start",
|
||||
)
|
||||
end = _parse_time_str(
|
||||
call.data.get(f"period_{i}_end", cached["end_time"]),
|
||||
f"period_{i}_end",
|
||||
)
|
||||
enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"])
|
||||
periods.append({"start_time": start, "end_time": end, "enabled": enabled})
|
||||
|
||||
await coordinator.update_ac_charge_times(
|
||||
charge_power, charge_stop_soc, mains_enabled, periods
|
||||
)
|
||||
|
||||
async def handle_write_ac_discharge_times(call: ServiceCall) -> None:
|
||||
"""Handle write_ac_discharge_times service call for SPH devices."""
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "sph"
|
||||
)
|
||||
# Read current settings first — same read-merge-write pattern as charge.
|
||||
current = await coordinator.read_ac_discharge_times()
|
||||
|
||||
discharge_power: int = int(
|
||||
call.data.get("discharge_power", current["discharge_power"])
|
||||
)
|
||||
discharge_stop_soc: int = int(
|
||||
call.data.get("discharge_stop_soc", current["discharge_stop_soc"])
|
||||
)
|
||||
|
||||
if not 0 <= discharge_power <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"discharge_power must be between 0 and 100, got {discharge_power}"
|
||||
)
|
||||
if not 0 <= discharge_stop_soc <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"discharge_stop_soc must be between 0 and 100, got {discharge_stop_soc}"
|
||||
)
|
||||
|
||||
periods = []
|
||||
for i in range(1, 4):
|
||||
cached = current["periods"][i - 1]
|
||||
start = _parse_time_str(
|
||||
call.data.get(f"period_{i}_start", cached["start_time"]),
|
||||
f"period_{i}_start",
|
||||
)
|
||||
end = _parse_time_str(
|
||||
call.data.get(f"period_{i}_end", cached["end_time"]),
|
||||
f"period_{i}_end",
|
||||
)
|
||||
enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"])
|
||||
periods.append({"start_time": start, "end_time": end, "enabled": enabled})
|
||||
|
||||
await coordinator.update_ac_discharge_times(
|
||||
discharge_power, discharge_stop_soc, periods
|
||||
)
|
||||
|
||||
async def handle_read_ac_charge_times(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Handle read_ac_charge_times service call for SPH devices."""
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "sph"
|
||||
)
|
||||
return await coordinator.read_ac_charge_times()
|
||||
|
||||
async def handle_read_ac_discharge_times(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Handle read_ac_discharge_times service call for SPH devices."""
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "sph"
|
||||
)
|
||||
return await coordinator.read_ac_discharge_times()
|
||||
|
||||
# Register services without schema - services.yaml will provide UI definition
|
||||
# Schema validation happens in the handler functions
|
||||
hass.services.async_register(
|
||||
@@ -168,3 +247,31 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
handle_read_time_segments,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"write_ac_charge_times",
|
||||
handle_write_ac_charge_times,
|
||||
supports_response=SupportsResponse.NONE,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"write_ac_discharge_times",
|
||||
handle_write_ac_discharge_times,
|
||||
supports_response=SupportsResponse.NONE,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"read_ac_charge_times",
|
||||
handle_read_ac_charge_times,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"read_ac_discharge_times",
|
||||
handle_read_ac_discharge_times,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
@@ -48,3 +48,162 @@ read_time_segments:
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
|
||||
write_ac_charge_times:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
charge_power:
|
||||
required: false
|
||||
example: 100
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: slider
|
||||
charge_stop_soc:
|
||||
required: false
|
||||
example: 100
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: slider
|
||||
mains_enabled:
|
||||
required: false
|
||||
example: true
|
||||
selector:
|
||||
boolean:
|
||||
period_1_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_1_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_1_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
period_2_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_2_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_2_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
period_3_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_3_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_3_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
write_ac_discharge_times:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
discharge_power:
|
||||
required: false
|
||||
example: 100
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: slider
|
||||
discharge_stop_soc:
|
||||
required: false
|
||||
example: 20
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: slider
|
||||
period_1_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_1_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_1_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
period_2_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_2_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_2_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
period_3_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_3_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_3_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
read_ac_charge_times:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
|
||||
read_ac_discharge_times:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
|
||||
@@ -58,14 +58,14 @@
|
||||
"region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]",
|
||||
"token": "The API token for your Growatt account. You can generate one via the Growatt web portal or ShinePhone app."
|
||||
},
|
||||
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
|
||||
"description": "Token authentication is only supported for MIN/SPH devices. For other device types, please use username/password authentication.",
|
||||
"title": "Enter your API token"
|
||||
},
|
||||
"user": {
|
||||
"description": "Note: Token authentication is currently only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
|
||||
"description": "Note: Token authentication is currently only supported for MIN/SPH devices. For other device types, please use username/password authentication.",
|
||||
"menu_options": {
|
||||
"password_auth": "Username/password",
|
||||
"token_auth": "API token (MIN/TLX only)"
|
||||
"token_auth": "API token (MIN/SPH only)"
|
||||
},
|
||||
"title": "Choose authentication method"
|
||||
}
|
||||
@@ -243,6 +243,24 @@
|
||||
"mix_wattage_pv_all": {
|
||||
"name": "All PV wattage"
|
||||
},
|
||||
"sph_grid_frequency": {
|
||||
"name": "AC frequency"
|
||||
},
|
||||
"sph_temperature_1": {
|
||||
"name": "Temperature 1"
|
||||
},
|
||||
"sph_temperature_2": {
|
||||
"name": "Temperature 2"
|
||||
},
|
||||
"sph_temperature_3": {
|
||||
"name": "Temperature 3"
|
||||
},
|
||||
"sph_temperature_4": {
|
||||
"name": "Temperature 4"
|
||||
},
|
||||
"sph_temperature_5": {
|
||||
"name": "Temperature 5"
|
||||
},
|
||||
"storage_ac_input_frequency_out": {
|
||||
"name": "AC input frequency"
|
||||
},
|
||||
@@ -576,6 +594,26 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"read_ac_charge_times": {
|
||||
"description": "Read AC charge time periods from an SPH device.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "The Growatt SPH device to read from.",
|
||||
"name": "Device"
|
||||
}
|
||||
},
|
||||
"name": "Read AC charge times"
|
||||
},
|
||||
"read_ac_discharge_times": {
|
||||
"description": "Read AC discharge time periods from an SPH device.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
|
||||
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Read AC discharge times"
|
||||
},
|
||||
"read_time_segments": {
|
||||
"description": "Read all time segments from a supported inverter.",
|
||||
"fields": {
|
||||
@@ -615,6 +653,118 @@
|
||||
}
|
||||
},
|
||||
"name": "Update time segment"
|
||||
},
|
||||
"write_ac_charge_times": {
|
||||
"description": "Write AC charge time periods to an SPH device.",
|
||||
"fields": {
|
||||
"charge_power": {
|
||||
"description": "Charge power limit (%).",
|
||||
"name": "Charge power"
|
||||
},
|
||||
"charge_stop_soc": {
|
||||
"description": "Stop charging at this state of charge (%).",
|
||||
"name": "Charge stop SOC"
|
||||
},
|
||||
"device_id": {
|
||||
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
|
||||
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
|
||||
},
|
||||
"mains_enabled": {
|
||||
"description": "Enable AC (mains) charging.",
|
||||
"name": "Mains charging enabled"
|
||||
},
|
||||
"period_1_enabled": {
|
||||
"description": "Enable time period 1.",
|
||||
"name": "Period 1 enabled"
|
||||
},
|
||||
"period_1_end": {
|
||||
"description": "End time for period 1 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 1 end"
|
||||
},
|
||||
"period_1_start": {
|
||||
"description": "Start time for period 1 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 1 start"
|
||||
},
|
||||
"period_2_enabled": {
|
||||
"description": "Enable time period 2.",
|
||||
"name": "Period 2 enabled"
|
||||
},
|
||||
"period_2_end": {
|
||||
"description": "End time for period 2 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 2 end"
|
||||
},
|
||||
"period_2_start": {
|
||||
"description": "Start time for period 2 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 2 start"
|
||||
},
|
||||
"period_3_enabled": {
|
||||
"description": "Enable time period 3.",
|
||||
"name": "Period 3 enabled"
|
||||
},
|
||||
"period_3_end": {
|
||||
"description": "End time for period 3 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 3 end"
|
||||
},
|
||||
"period_3_start": {
|
||||
"description": "Start time for period 3 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 3 start"
|
||||
}
|
||||
},
|
||||
"name": "Write AC charge times"
|
||||
},
|
||||
"write_ac_discharge_times": {
|
||||
"description": "Write AC discharge time periods to an SPH device.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
|
||||
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
|
||||
},
|
||||
"discharge_power": {
|
||||
"description": "Discharge power limit (%).",
|
||||
"name": "Discharge power"
|
||||
},
|
||||
"discharge_stop_soc": {
|
||||
"description": "Stop discharging at this state of charge (%).",
|
||||
"name": "Discharge stop SOC"
|
||||
},
|
||||
"period_1_enabled": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_enabled::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_enabled::name%]"
|
||||
},
|
||||
"period_1_end": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_end::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_end::name%]"
|
||||
},
|
||||
"period_1_start": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_start::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_start::name%]"
|
||||
},
|
||||
"period_2_enabled": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_enabled::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_enabled::name%]"
|
||||
},
|
||||
"period_2_end": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_end::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_end::name%]"
|
||||
},
|
||||
"period_2_start": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_start::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_start::name%]"
|
||||
},
|
||||
"period_3_enabled": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_enabled::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_enabled::name%]"
|
||||
},
|
||||
"period_3_end": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_end::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_end::name%]"
|
||||
},
|
||||
"period_3_start": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_start::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_start::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Write AC discharge times"
|
||||
}
|
||||
},
|
||||
"title": "Growatt Server"
|
||||
|
||||
@@ -9,10 +9,21 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
from typing import Any, NamedTuple
|
||||
from typing import Any, NamedTuple, cast
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import GreenOptions, YellowOptions # noqa: F401
|
||||
from aiohasupervisor.models import (
|
||||
GreenOptions,
|
||||
HomeAssistantInfo,
|
||||
HostInfo,
|
||||
InstalledAddon,
|
||||
NetworkInfo,
|
||||
OSInfo,
|
||||
RootInfo,
|
||||
StoreInfo,
|
||||
SupervisorInfo,
|
||||
YellowOptions,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
@@ -65,7 +76,7 @@ from . import ( # noqa: F401
|
||||
system_health,
|
||||
update,
|
||||
)
|
||||
from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # noqa: F401
|
||||
from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState
|
||||
from .addon_panel import async_setup_addon_panel
|
||||
from .auth import async_setup_auth_view
|
||||
from .config import HassioConfig
|
||||
@@ -82,7 +93,9 @@ from .const import (
|
||||
ATTR_INPUT,
|
||||
ATTR_LOCATION,
|
||||
ATTR_PASSWORD,
|
||||
ATTR_REPOSITORIES,
|
||||
ATTR_SLUG,
|
||||
DATA_ADDONS_LIST,
|
||||
DATA_COMPONENT,
|
||||
DATA_CONFIG_STORE,
|
||||
DATA_CORE_INFO,
|
||||
@@ -100,18 +113,21 @@ from .const import (
|
||||
from .coordinator import (
|
||||
HassioDataUpdateCoordinator,
|
||||
get_addons_info,
|
||||
get_addons_stats, # noqa: F401
|
||||
get_core_info, # noqa: F401
|
||||
get_core_stats, # noqa: F401
|
||||
get_host_info, # noqa: F401
|
||||
get_addons_list,
|
||||
get_addons_stats,
|
||||
get_core_info,
|
||||
get_core_stats,
|
||||
get_host_info,
|
||||
get_info,
|
||||
get_issues_info, # noqa: F401
|
||||
get_issues_info,
|
||||
get_network_info,
|
||||
get_os_info,
|
||||
get_supervisor_info, # noqa: F401
|
||||
get_supervisor_stats, # noqa: F401
|
||||
get_store,
|
||||
get_supervisor_info,
|
||||
get_supervisor_stats,
|
||||
)
|
||||
from .discovery import async_setup_discovery_view
|
||||
from .handler import ( # noqa: F401
|
||||
from .handler import (
|
||||
HassIO,
|
||||
HassioAPIError,
|
||||
async_update_diagnostics,
|
||||
@@ -122,6 +138,35 @@ from .ingress import async_setup_ingress_view
|
||||
from .issues import SupervisorIssues
|
||||
from .websocket_api import async_load_websocket_api
|
||||
|
||||
# Expose the future safe name now so integrations can use it
|
||||
# All references to addons will eventually be refactored and deprecated
|
||||
get_apps_list = get_addons_list
|
||||
__all__ = [
|
||||
"AddonError",
|
||||
"AddonInfo",
|
||||
"AddonManager",
|
||||
"AddonState",
|
||||
"GreenOptions",
|
||||
"SupervisorError",
|
||||
"YellowOptions",
|
||||
"async_update_diagnostics",
|
||||
"get_addons_info",
|
||||
"get_addons_list",
|
||||
"get_addons_stats",
|
||||
"get_apps_list",
|
||||
"get_core_info",
|
||||
"get_core_stats",
|
||||
"get_host_info",
|
||||
"get_info",
|
||||
"get_issues_info",
|
||||
"get_network_info",
|
||||
"get_os_info",
|
||||
"get_store",
|
||||
"get_supervisor_client",
|
||||
"get_supervisor_info",
|
||||
"get_supervisor_stats",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -504,27 +549,55 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
|
||||
try:
|
||||
(
|
||||
hass.data[DATA_INFO],
|
||||
hass.data[DATA_HOST_INFO],
|
||||
root_info,
|
||||
host_info,
|
||||
store_info,
|
||||
hass.data[DATA_CORE_INFO],
|
||||
hass.data[DATA_SUPERVISOR_INFO],
|
||||
hass.data[DATA_OS_INFO],
|
||||
hass.data[DATA_NETWORK_INFO],
|
||||
) = await asyncio.gather(
|
||||
create_eager_task(hassio.get_info()),
|
||||
create_eager_task(hassio.get_host_info()),
|
||||
create_eager_task(supervisor_client.store.info()),
|
||||
create_eager_task(hassio.get_core_info()),
|
||||
create_eager_task(hassio.get_supervisor_info()),
|
||||
create_eager_task(hassio.get_os_info()),
|
||||
create_eager_task(hassio.get_network_info()),
|
||||
homeassistant_info,
|
||||
supervisor_info,
|
||||
os_info,
|
||||
network_info,
|
||||
addons_list,
|
||||
) = cast(
|
||||
tuple[
|
||||
RootInfo,
|
||||
HostInfo,
|
||||
StoreInfo,
|
||||
HomeAssistantInfo,
|
||||
SupervisorInfo,
|
||||
OSInfo,
|
||||
NetworkInfo,
|
||||
list[InstalledAddon],
|
||||
],
|
||||
await asyncio.gather(
|
||||
create_eager_task(supervisor_client.info()),
|
||||
create_eager_task(supervisor_client.host.info()),
|
||||
create_eager_task(supervisor_client.store.info()),
|
||||
create_eager_task(supervisor_client.homeassistant.info()),
|
||||
create_eager_task(supervisor_client.supervisor.info()),
|
||||
create_eager_task(supervisor_client.os.info()),
|
||||
create_eager_task(supervisor_client.network.info()),
|
||||
create_eager_task(supervisor_client.addons.list()),
|
||||
),
|
||||
)
|
||||
|
||||
except HassioAPIError as err:
|
||||
except SupervisorError as err:
|
||||
_LOGGER.warning("Can't read Supervisor data: %s", err)
|
||||
else:
|
||||
hass.data[DATA_INFO] = root_info.to_dict()
|
||||
hass.data[DATA_HOST_INFO] = host_info.to_dict()
|
||||
hass.data[DATA_STORE] = store_info.to_dict()
|
||||
hass.data[DATA_CORE_INFO] = homeassistant_info.to_dict()
|
||||
hass.data[DATA_SUPERVISOR_INFO] = supervisor_info.to_dict()
|
||||
hass.data[DATA_OS_INFO] = os_info.to_dict()
|
||||
hass.data[DATA_NETWORK_INFO] = network_info.to_dict()
|
||||
hass.data[DATA_ADDONS_LIST] = [addon.to_dict() for addon in addons_list]
|
||||
|
||||
# Deprecated 2026.4.0: Folding repositories and addons.list results into supervisor_info for compatibility
|
||||
# Can drop this after removal period
|
||||
hass.data[DATA_SUPERVISOR_INFO]["repositories"] = hass.data[DATA_STORE][
|
||||
ATTR_REPOSITORIES
|
||||
]
|
||||
hass.data[DATA_SUPERVISOR_INFO]["addons"] = hass.data[DATA_ADDONS_LIST]
|
||||
|
||||
async_call_later(
|
||||
hass,
|
||||
|
||||
@@ -93,6 +93,7 @@ DATA_SUPERVISOR_INFO = "hassio_supervisor_info"
|
||||
DATA_SUPERVISOR_STATS = "hassio_supervisor_stats"
|
||||
DATA_ADDONS_INFO = "hassio_addons_info"
|
||||
DATA_ADDONS_STATS = "hassio_addons_stats"
|
||||
DATA_ADDONS_LIST = "hassio_addons_list"
|
||||
HASSIO_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
ATTR_AUTO_UPDATE = "auto_update"
|
||||
@@ -106,6 +107,7 @@ ATTR_STATE = "state"
|
||||
ATTR_STARTED = "started"
|
||||
ATTR_URL = "url"
|
||||
ATTR_REPOSITORY = "repository"
|
||||
ATTR_REPOSITORIES = "repositories"
|
||||
|
||||
DATA_KEY_ADDONS = "addons"
|
||||
DATA_KEY_OS = "os"
|
||||
|
||||
@@ -4,13 +4,20 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Awaitable
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from aiohasupervisor import SupervisorError, SupervisorNotFoundError
|
||||
from aiohasupervisor.models import StoreInfo
|
||||
from aiohasupervisor.models.mounts import CIFSMountResponse, NFSMountResponse
|
||||
from aiohasupervisor.models import (
|
||||
AddonState,
|
||||
CIFSMountResponse,
|
||||
InstalledAddon,
|
||||
NFSMountResponse,
|
||||
StoreInfo,
|
||||
)
|
||||
from aiohasupervisor.models.base import ResponseData
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME
|
||||
@@ -23,16 +30,16 @@ from homeassistant.loader import bind_hass
|
||||
|
||||
from .const import (
|
||||
ATTR_AUTO_UPDATE,
|
||||
ATTR_REPOSITORIES,
|
||||
ATTR_REPOSITORY,
|
||||
ATTR_SLUG,
|
||||
ATTR_STARTED,
|
||||
ATTR_STATE,
|
||||
ATTR_URL,
|
||||
ATTR_VERSION,
|
||||
CONTAINER_INFO,
|
||||
CONTAINER_STATS,
|
||||
CORE_CONTAINER,
|
||||
DATA_ADDONS_INFO,
|
||||
DATA_ADDONS_LIST,
|
||||
DATA_ADDONS_STATS,
|
||||
DATA_COMPONENT,
|
||||
DATA_CORE_INFO,
|
||||
@@ -57,7 +64,7 @@ from .const import (
|
||||
SUPERVISOR_CONTAINER,
|
||||
SupervisorEntityModel,
|
||||
)
|
||||
from .handler import HassioAPIError, get_supervisor_client
|
||||
from .handler import get_supervisor_client
|
||||
from .jobs import SupervisorJobs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -118,7 +125,7 @@ def get_network_info(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None:
|
||||
def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any] | None] | None:
|
||||
"""Return Addons info.
|
||||
|
||||
Async friendly.
|
||||
@@ -126,9 +133,18 @@ def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None:
|
||||
return hass.data.get(DATA_ADDONS_INFO)
|
||||
|
||||
|
||||
@callback
|
||||
def get_addons_list(hass: HomeAssistant) -> list[dict[str, Any]] | None:
|
||||
"""Return list of installed addons and subset of details for each.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return hass.data.get(DATA_ADDONS_LIST)
|
||||
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def get_addons_stats(hass: HomeAssistant) -> dict[str, Any]:
|
||||
def get_addons_stats(hass: HomeAssistant) -> dict[str, dict[str, Any] | None]:
|
||||
"""Return Addons stats.
|
||||
|
||||
Async friendly.
|
||||
@@ -341,7 +357,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
try:
|
||||
await self.force_data_refresh(is_first_update)
|
||||
except HassioAPIError as err:
|
||||
except SupervisorError as err:
|
||||
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
|
||||
|
||||
new_data: dict[str, Any] = {}
|
||||
@@ -350,6 +366,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
addons_stats = get_addons_stats(self.hass)
|
||||
store_data = get_store(self.hass)
|
||||
mounts_info = await self.supervisor_client.mounts.info()
|
||||
addons_list = get_addons_list(self.hass) or []
|
||||
|
||||
if store_data:
|
||||
repositories = {
|
||||
@@ -360,17 +377,17 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
repositories = {}
|
||||
|
||||
new_data[DATA_KEY_ADDONS] = {
|
||||
addon[ATTR_SLUG]: {
|
||||
(slug := addon[ATTR_SLUG]): {
|
||||
**addon,
|
||||
**((addons_stats or {}).get(addon[ATTR_SLUG]) or {}),
|
||||
ATTR_AUTO_UPDATE: (addons_info.get(addon[ATTR_SLUG]) or {}).get(
|
||||
**(addons_stats.get(slug) or {}),
|
||||
ATTR_AUTO_UPDATE: (addons_info.get(slug) or {}).get(
|
||||
ATTR_AUTO_UPDATE, False
|
||||
),
|
||||
ATTR_REPOSITORY: repositories.get(
|
||||
addon.get(ATTR_REPOSITORY), addon.get(ATTR_REPOSITORY, "")
|
||||
repo_slug := addon.get(ATTR_REPOSITORY, ""), repo_slug
|
||||
),
|
||||
}
|
||||
for addon in supervisor_info.get("addons", [])
|
||||
for addon in addons_list
|
||||
}
|
||||
if self.is_hass_os:
|
||||
new_data[DATA_KEY_OS] = get_os_info(self.hass)
|
||||
@@ -462,32 +479,48 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
container_updates = self._container_updates
|
||||
|
||||
data = self.hass.data
|
||||
hassio = self.hassio
|
||||
updates = {
|
||||
DATA_INFO: hassio.get_info(),
|
||||
DATA_CORE_INFO: hassio.get_core_info(),
|
||||
DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(),
|
||||
DATA_OS_INFO: hassio.get_os_info(),
|
||||
client = self.supervisor_client
|
||||
|
||||
updates: dict[str, Awaitable[ResponseData]] = {
|
||||
DATA_INFO: client.info(),
|
||||
DATA_CORE_INFO: client.homeassistant.info(),
|
||||
DATA_SUPERVISOR_INFO: client.supervisor.info(),
|
||||
DATA_OS_INFO: client.os.info(),
|
||||
DATA_STORE: client.store.info(),
|
||||
}
|
||||
if CONTAINER_STATS in container_updates[CORE_CONTAINER]:
|
||||
updates[DATA_CORE_STATS] = hassio.get_core_stats()
|
||||
updates[DATA_CORE_STATS] = client.homeassistant.stats()
|
||||
if CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]:
|
||||
updates[DATA_SUPERVISOR_STATS] = hassio.get_supervisor_stats()
|
||||
updates[DATA_SUPERVISOR_STATS] = client.supervisor.stats()
|
||||
|
||||
results = await asyncio.gather(*updates.values())
|
||||
for key, result in zip(updates, results, strict=False):
|
||||
data[key] = result
|
||||
# Pull off addons.list results for further processing before caching
|
||||
addons_list, *results = await asyncio.gather(
|
||||
client.addons.list(), *updates.values()
|
||||
)
|
||||
for key, result in zip(updates, cast(list[ResponseData], results), strict=True):
|
||||
data[key] = result.to_dict()
|
||||
|
||||
installed_addons = cast(list[InstalledAddon], addons_list)
|
||||
data[DATA_ADDONS_LIST] = [addon.to_dict() for addon in installed_addons]
|
||||
|
||||
# Deprecated 2026.4.0: Folding repositories and addons.list results into supervisor_info for compatibility
|
||||
# Can drop this after removal period
|
||||
data[DATA_SUPERVISOR_INFO].update(
|
||||
{
|
||||
"repositories": data[DATA_STORE][ATTR_REPOSITORIES],
|
||||
"addons": [addon.to_dict() for addon in installed_addons],
|
||||
}
|
||||
)
|
||||
|
||||
all_addons = {addon.slug for addon in installed_addons}
|
||||
started_addons = {
|
||||
addon.slug
|
||||
for addon in installed_addons
|
||||
if addon.state in {AddonState.STARTED, AddonState.STARTUP}
|
||||
}
|
||||
|
||||
_addon_data = data[DATA_SUPERVISOR_INFO].get("addons", [])
|
||||
all_addons: list[str] = []
|
||||
started_addons: list[str] = []
|
||||
for addon in _addon_data:
|
||||
slug = addon[ATTR_SLUG]
|
||||
all_addons.append(slug)
|
||||
if addon[ATTR_STATE] == ATTR_STARTED:
|
||||
started_addons.append(slug)
|
||||
#
|
||||
# Update add-on info if its the first update or
|
||||
# Update addon info if its the first update or
|
||||
# there is at least one entity that needs the data.
|
||||
#
|
||||
# When entities are added they call async_enable_container_updates
|
||||
@@ -514,6 +547,12 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
),
|
||||
):
|
||||
container_data: dict[str, Any] = data.setdefault(data_key, {})
|
||||
|
||||
# Clean up cache
|
||||
for slug in container_data.keys() - wanted_addons:
|
||||
del container_data[slug]
|
||||
|
||||
# Update cache from API
|
||||
container_data.update(
|
||||
dict(
|
||||
await asyncio.gather(
|
||||
@@ -540,7 +579,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
return (slug, stats.to_dict())
|
||||
|
||||
async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]:
|
||||
"""Return the info for an add-on."""
|
||||
"""Return the info for an addon."""
|
||||
try:
|
||||
info = await self.supervisor_client.addons.addon_info(slug)
|
||||
except SupervisorError as err:
|
||||
|
||||
@@ -87,70 +87,6 @@ class HassIO:
|
||||
"""Return base url for Supervisor."""
|
||||
return self._base_url
|
||||
|
||||
@api_data
|
||||
def get_info(self) -> Coroutine:
|
||||
"""Return generic Supervisor information.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_host_info(self) -> Coroutine:
|
||||
"""Return data for Host.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/host/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_os_info(self) -> Coroutine:
|
||||
"""Return data for the OS.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/os/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_core_info(self) -> Coroutine:
|
||||
"""Return data for Home Asssistant Core.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/core/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_supervisor_info(self) -> Coroutine:
|
||||
"""Return data for the Supervisor.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/supervisor/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_network_info(self) -> Coroutine:
|
||||
"""Return data for the Host Network.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/network/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_core_stats(self) -> Coroutine:
|
||||
"""Return stats for the core.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/core/stats", method="get")
|
||||
|
||||
@api_data
|
||||
def get_supervisor_stats(self) -> Coroutine:
|
||||
"""Return stats for the supervisor.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/supervisor/stats", method="get")
|
||||
|
||||
@api_data
|
||||
def get_ingress_panels(self) -> Coroutine:
|
||||
"""Return data for Add-on ingress panels.
|
||||
|
||||
@@ -17,6 +17,7 @@ from aiohasupervisor.models import (
|
||||
UnsupportedReason,
|
||||
)
|
||||
|
||||
from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import HassJob, HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
@@ -30,6 +31,7 @@ from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
ATTR_DATA,
|
||||
ATTR_HEALTHY,
|
||||
ATTR_SLUG,
|
||||
ATTR_STARTUP,
|
||||
ATTR_SUPPORTED,
|
||||
ATTR_UNHEALTHY_REASONS,
|
||||
@@ -59,7 +61,7 @@ from .const import (
|
||||
STARTUP_COMPLETE,
|
||||
UPDATE_KEY_SUPERVISOR,
|
||||
)
|
||||
from .coordinator import HassioDataUpdateCoordinator, get_addons_info, get_host_info
|
||||
from .coordinator import HassioDataUpdateCoordinator, get_addons_list, get_host_info
|
||||
from .handler import HassIO, get_supervisor_client
|
||||
|
||||
ISSUE_KEY_UNHEALTHY = "unhealthy"
|
||||
@@ -265,23 +267,18 @@ class SupervisorIssues:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON_URL] = (
|
||||
f"/hassio/addon/{issue.reference}"
|
||||
)
|
||||
addons = get_addons_info(self._hass)
|
||||
if addons and issue.reference in addons:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = addons[issue.reference][
|
||||
"name"
|
||||
]
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
|
||||
addons_list = get_addons_list(self._hass) or []
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
|
||||
for addon in addons_list:
|
||||
if addon[ATTR_SLUG] == issue.reference:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME]
|
||||
break
|
||||
|
||||
elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE:
|
||||
host_info = get_host_info(self._hass)
|
||||
if (
|
||||
host_info
|
||||
and "data" in host_info
|
||||
and "disk_free" in host_info["data"]
|
||||
):
|
||||
if host_info and "disk_free" in host_info:
|
||||
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str(
|
||||
host_info["data"]["disk_free"]
|
||||
host_info["disk_free"]
|
||||
)
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2"
|
||||
|
||||
@@ -11,11 +11,13 @@ from aiohasupervisor.models import ContextType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from . import get_addons_info, get_issues_info
|
||||
from . import get_addons_list, get_issues_info
|
||||
from .const import (
|
||||
ATTR_SLUG,
|
||||
EXTRA_PLACEHOLDERS,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_DEPRECATED,
|
||||
@@ -154,7 +156,7 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow):
|
||||
placeholders = {PLACEHOLDER_KEY_COMPONENTS: ""}
|
||||
supervisor_issues = get_issues_info(self.hass)
|
||||
if supervisor_issues and self.issue:
|
||||
addons = get_addons_info(self.hass) or {}
|
||||
addons_list = get_addons_list(self.hass) or []
|
||||
components: list[str] = []
|
||||
for issue in supervisor_issues.issues:
|
||||
if issue.key == self.issue.key or issue.type != self.issue.type:
|
||||
@@ -166,9 +168,9 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow):
|
||||
components.append(
|
||||
next(
|
||||
(
|
||||
info["name"]
|
||||
for slug, info in addons.items()
|
||||
if slug == issue.reference
|
||||
addon[ATTR_NAME]
|
||||
for addon in addons_list
|
||||
if addon[ATTR_SLUG] == issue.reference
|
||||
),
|
||||
issue.reference or "",
|
||||
)
|
||||
@@ -187,13 +189,12 @@ class AddonIssueRepairFlow(SupervisorIssueRepairFlow):
|
||||
"""Get description placeholders for steps."""
|
||||
placeholders: dict[str, str] = super().description_placeholders or {}
|
||||
if self.issue and self.issue.reference:
|
||||
addons = get_addons_info(self.hass)
|
||||
if addons and self.issue.reference in addons:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = addons[self.issue.reference][
|
||||
"name"
|
||||
]
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = self.issue.reference
|
||||
addons_list = get_addons_list(self.hass) or []
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = self.issue.reference
|
||||
for addon in addons_list:
|
||||
if addon[ATTR_SLUG] == self.issue.reference:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME]
|
||||
break
|
||||
|
||||
return placeholders or None
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .coordinator import (
|
||||
get_addons_list,
|
||||
get_host_info,
|
||||
get_info,
|
||||
get_network_info,
|
||||
@@ -35,6 +36,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
host_info = get_host_info(hass) or {}
|
||||
supervisor_info = get_supervisor_info(hass)
|
||||
network_info = get_network_info(hass) or {}
|
||||
addons_list = get_addons_list(hass) or []
|
||||
|
||||
healthy: bool | dict[str, str]
|
||||
if supervisor_info is not None and supervisor_info.get("healthy"):
|
||||
@@ -84,6 +86,8 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
os_info = get_os_info(hass) or {}
|
||||
information["board"] = os_info.get("board")
|
||||
|
||||
# Not using aiohasupervisor for ping call below intentionally. Given system health
|
||||
# context, it seems preferable to do this check with minimal dependencies
|
||||
information["supervisor_api"] = system_health.async_check_can_reach_url(
|
||||
hass,
|
||||
SUPERVISOR_PING.format(ip_address=ip_address),
|
||||
@@ -95,8 +99,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
information["installed_addons"] = ", ".join(
|
||||
f"{addon['name']} ({addon['version']})"
|
||||
for addon in (supervisor_info or {}).get("addons", [])
|
||||
f"{addon['name']} ({addon['version']})" for addon in addons_list
|
||||
)
|
||||
|
||||
return information
|
||||
|
||||
@@ -39,7 +39,7 @@ from .const import (
|
||||
WS_TYPE_EVENT,
|
||||
WS_TYPE_SUBSCRIBE,
|
||||
)
|
||||
from .coordinator import get_supervisor_info
|
||||
from .coordinator import get_addons_list
|
||||
from .update_helper import update_addon, update_core
|
||||
|
||||
SCHEMA_WEBSOCKET_EVENT = vol.Schema(
|
||||
@@ -168,8 +168,8 @@ async def websocket_update_addon(
|
||||
"""Websocket handler to update an addon."""
|
||||
addon_name: str | None = None
|
||||
addon_version: str | None = None
|
||||
addons: list = (get_supervisor_info(hass) or {}).get("addons", [])
|
||||
for addon in addons:
|
||||
addons_list: list[dict[str, Any]] = get_addons_list(hass) or []
|
||||
for addon in addons_list:
|
||||
if addon[ATTR_SLUG] == msg["addon"]:
|
||||
addon_name = addon[ATTR_NAME]
|
||||
addon_version = addon[ATTR_VERSION]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from apyhiveapi import Auth
|
||||
@@ -26,6 +27,8 @@ from homeassistant.core import callback
|
||||
from . import HiveConfigEntry
|
||||
from .const import CONF_CODE, CONF_DEVICE_NAME, CONFIG_ENTRY_VERSION, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Hive config flow."""
|
||||
@@ -36,7 +39,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.tokens: dict[str, str] = {}
|
||||
self.tokens: dict[str, Any] = {}
|
||||
self.device_registration: bool = False
|
||||
self.device_name = "Home Assistant"
|
||||
|
||||
@@ -67,11 +70,22 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
except HiveApiError:
|
||||
errors["base"] = "no_internet_available"
|
||||
|
||||
if (
|
||||
auth_result := self.tokens.get("AuthenticationResult", {})
|
||||
) and auth_result.get("NewDeviceMetadata"):
|
||||
_LOGGER.debug("Login successful, New device detected")
|
||||
self.device_registration = True
|
||||
return await self.async_step_configuration()
|
||||
|
||||
if self.tokens.get("ChallengeName") == "SMS_MFA":
|
||||
_LOGGER.debug("Login successful, SMS 2FA required")
|
||||
# Complete SMS 2FA.
|
||||
return await self.async_step_2fa()
|
||||
|
||||
if not errors:
|
||||
_LOGGER.debug(
|
||||
"Login successful, no new device detected, no 2FA required"
|
||||
)
|
||||
# Complete the entry.
|
||||
try:
|
||||
return await self.async_setup_hive_entry()
|
||||
@@ -103,6 +117,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "no_internet_available"
|
||||
|
||||
if not errors:
|
||||
_LOGGER.debug("2FA successful")
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return await self.async_setup_hive_entry()
|
||||
self.device_registration = True
|
||||
@@ -119,10 +134,11 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input:
|
||||
if self.device_registration:
|
||||
_LOGGER.debug("Attempting to register device")
|
||||
self.device_name = user_input["device_name"]
|
||||
await self.hive_auth.device_registration(user_input["device_name"])
|
||||
self.data["device_data"] = await self.hive_auth.get_device_data()
|
||||
|
||||
_LOGGER.debug("Device registration successful")
|
||||
try:
|
||||
return await self.async_setup_hive_entry()
|
||||
except UnknownHiveError:
|
||||
@@ -142,6 +158,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
raise UnknownHiveError
|
||||
|
||||
# Setup the config entry
|
||||
_LOGGER.debug("Setting up Hive entry")
|
||||
self.data["tokens"] = self.tokens
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return self.async_update_reload_and_abort(
|
||||
@@ -160,6 +177,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_USERNAME: entry_data[CONF_USERNAME],
|
||||
CONF_PASSWORD: entry_data[CONF_PASSWORD],
|
||||
}
|
||||
_LOGGER.debug("Reauthenticating user")
|
||||
return await self.async_step_user(data)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -63,6 +63,7 @@ BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open"
|
||||
|
||||
SERVICE_SET_PROGRAM_AND_OPTIONS = "set_program_and_options"
|
||||
SERVICE_SETTING = "change_setting"
|
||||
SERVICE_START_SELECTED_PROGRAM = "start_selected_program"
|
||||
|
||||
ATTR_AFFECTS_TO = "affects_to"
|
||||
ATTR_KEY = "key"
|
||||
|
||||
@@ -245,25 +245,10 @@
|
||||
"change_setting": {
|
||||
"service": "mdi:cog"
|
||||
},
|
||||
"pause_program": {
|
||||
"service": "mdi:pause"
|
||||
},
|
||||
"resume_program": {
|
||||
"service": "mdi:play-pause"
|
||||
},
|
||||
"select_program": {
|
||||
"service": "mdi:form-select"
|
||||
},
|
||||
"set_option_active": {
|
||||
"service": "mdi:gesture-tap"
|
||||
},
|
||||
"set_option_selected": {
|
||||
"service": "mdi:gesture-tap"
|
||||
},
|
||||
"set_program_and_options": {
|
||||
"service": "mdi:form-select"
|
||||
},
|
||||
"start_program": {
|
||||
"start_selected_program": {
|
||||
"service": "mdi:play"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.30.0"],
|
||||
"requirements": ["aiohomeconnect==0.32.0"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ from aiohomeconnect.model import (
|
||||
ProgramKey,
|
||||
SettingKey,
|
||||
)
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
from aiohomeconnect.model.error import HomeConnectError, NoProgramActiveError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID
|
||||
@@ -32,6 +32,7 @@ from .const import (
|
||||
PROGRAM_ENUM_OPTIONS,
|
||||
SERVICE_SET_PROGRAM_AND_OPTIONS,
|
||||
SERVICE_SETTING,
|
||||
SERVICE_START_SELECTED_PROGRAM,
|
||||
TRANSLATION_KEYS_PROGRAMS_MAP,
|
||||
)
|
||||
from .coordinator import HomeConnectConfigEntry
|
||||
@@ -124,7 +125,23 @@ SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All(
|
||||
_require_program_or_at_least_one_option,
|
||||
)
|
||||
|
||||
SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str})
|
||||
SERVICE_START_SELECTED_PROGRAM_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): str,
|
||||
}
|
||||
).extend(
|
||||
{
|
||||
vol.Optional(translation_key): schema
|
||||
for translation_key, (key, schema) in PROGRAM_OPTIONS.items()
|
||||
if key
|
||||
in (
|
||||
OptionKey.BSH_COMMON_START_IN_RELATIVE,
|
||||
OptionKey.BSH_COMMON_FINISH_IN_RELATIVE,
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _get_client_and_ha_id(
|
||||
@@ -262,6 +279,50 @@ async def async_service_set_program_and_options(call: ServiceCall) -> None:
|
||||
) from err
|
||||
|
||||
|
||||
async def async_service_start_selected_program(call: ServiceCall) -> None:
|
||||
"""Service to start a program that is already selected."""
|
||||
data = dict(call.data)
|
||||
client, ha_id = await _get_client_and_ha_id(call.hass, data.pop(ATTR_DEVICE_ID))
|
||||
try:
|
||||
try:
|
||||
program_obj = await client.get_active_program(ha_id)
|
||||
except NoProgramActiveError:
|
||||
program_obj = await client.get_selected_program(ha_id)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_program_error",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
if not program_obj.key:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_program_to_start",
|
||||
)
|
||||
|
||||
program = program_obj.key
|
||||
options_dict = {option.key: option for option in program_obj.options or []}
|
||||
for option, value in data.items():
|
||||
option_key = PROGRAM_OPTIONS[option][0]
|
||||
options_dict[option_key] = Option(option_key, value)
|
||||
|
||||
try:
|
||||
await client.start_program(
|
||||
ha_id,
|
||||
program_key=program,
|
||||
options=list(options_dict.values()) if options_dict else None,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="start_program",
|
||||
translation_placeholders={
|
||||
"program": program,
|
||||
**get_dict_from_home_connect_error(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register custom actions."""
|
||||
@@ -275,3 +336,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
async_service_set_program_and_options,
|
||||
schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_START_SELECTED_PROGRAM,
|
||||
async_service_start_selected_program,
|
||||
schema=SERVICE_START_SELECTED_PROGRAM_SCHEMA,
|
||||
)
|
||||
|
||||
@@ -127,6 +127,7 @@ set_program_and_options:
|
||||
- cooking_oven_program_heating_mode_top_bottom_heating
|
||||
- cooking_oven_program_heating_mode_top_bottom_heating_eco
|
||||
- cooking_oven_program_heating_mode_bottom_heating
|
||||
- cooking_oven_program_heating_mode_bread_baking
|
||||
- cooking_oven_program_heating_mode_pizza_setting
|
||||
- cooking_oven_program_heating_mode_slow_cook
|
||||
- cooking_oven_program_heating_mode_intensive_heat
|
||||
@@ -135,6 +136,7 @@ set_program_and_options:
|
||||
- cooking_oven_program_heating_mode_frozen_heatup_special
|
||||
- cooking_oven_program_heating_mode_desiccation
|
||||
- cooking_oven_program_heating_mode_defrost
|
||||
- cooking_oven_program_heating_mode_dough_proving
|
||||
- cooking_oven_program_heating_mode_proof
|
||||
- cooking_oven_program_heating_mode_hot_air_30_steam
|
||||
- cooking_oven_program_heating_mode_hot_air_60_steam
|
||||
@@ -678,3 +680,29 @@ change_setting:
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
start_selected_program:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: home_connect
|
||||
b_s_h_common_option_finish_in_relative:
|
||||
example: 3600
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
step: 1
|
||||
mode: box
|
||||
unit_of_measurement: s
|
||||
b_s_h_common_option_start_in_relative:
|
||||
example: 3600
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
step: 1
|
||||
mode: box
|
||||
unit_of_measurement: s
|
||||
|
||||
@@ -261,8 +261,10 @@
|
||||
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
|
||||
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
|
||||
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
|
||||
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
|
||||
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
|
||||
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
|
||||
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
|
||||
@@ -615,8 +617,10 @@
|
||||
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
|
||||
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
|
||||
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
|
||||
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
|
||||
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
|
||||
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
|
||||
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
|
||||
@@ -1340,6 +1344,12 @@
|
||||
"fetch_api_error": {
|
||||
"message": "Error obtaining data from the API: {error}"
|
||||
},
|
||||
"fetch_program_error": {
|
||||
"message": "Error obtaining the selected or active program: {error}"
|
||||
},
|
||||
"no_program_to_start": {
|
||||
"message": "No program to start"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
@@ -1612,8 +1622,10 @@
|
||||
"cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
|
||||
"cooking_common_program_hood_venting": "Venting",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "Bread baking",
|
||||
"cooking_oven_program_heating_mode_defrost": "Defrost",
|
||||
"cooking_oven_program_heating_mode_desiccation": "Desiccation",
|
||||
"cooking_oven_program_heating_mode_dough_proving": "Dough proving",
|
||||
"cooking_oven_program_heating_mode_frozen_heatup_special": "Special heat-up for frozen products",
|
||||
"cooking_oven_program_heating_mode_hot_air": "Hot air",
|
||||
"cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH",
|
||||
@@ -2072,6 +2084,24 @@
|
||||
"name": "Washer options"
|
||||
}
|
||||
}
|
||||
},
|
||||
"start_selected_program": {
|
||||
"description": "Starts the already selected program. You can update start-only options to start the program with them or modify them on a program that is already active with a delayed start.",
|
||||
"fields": {
|
||||
"b_s_h_common_option_finish_in_relative": {
|
||||
"description": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::description%]",
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::name%]"
|
||||
},
|
||||
"b_s_h_common_option_start_in_relative": {
|
||||
"description": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::description%]",
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::name%]"
|
||||
},
|
||||
"device_id": {
|
||||
"description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]",
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Start selected program"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,14 @@ from homematicip.base.enums import (
|
||||
OpticalSignalBehaviour,
|
||||
RGBColorState,
|
||||
)
|
||||
from homematicip.base.functionalChannels import NotificationLightChannel
|
||||
from homematicip.base.functionalChannels import (
|
||||
NotificationLightChannel,
|
||||
NotificationMp3SoundChannel,
|
||||
)
|
||||
from homematicip.device import (
|
||||
BrandDimmer,
|
||||
BrandSwitchNotificationLight,
|
||||
CombinationSignallingDevice,
|
||||
Device,
|
||||
Dimmer,
|
||||
DinRailDimmer3,
|
||||
@@ -108,6 +112,8 @@ async def async_setup_entry(
|
||||
entities.append(
|
||||
HomematicipOpticalSignalLight(hap, device, ch.index, led_number)
|
||||
)
|
||||
elif isinstance(device, CombinationSignallingDevice):
|
||||
entities.append(HomematicipCombinationSignallingLight(hap, device))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -586,3 +592,70 @@ class HomematicipOpticalSignalLight(HomematicipGenericEntity, LightEntity):
|
||||
rgb=simple_rgb_color,
|
||||
dimLevel=0.0,
|
||||
)
|
||||
|
||||
|
||||
class HomematicipCombinationSignallingLight(HomematicipGenericEntity, LightEntity):
|
||||
"""Representation of the HomematicIP combination signalling device light (HmIP-MP3P)."""
|
||||
|
||||
_attr_color_mode = ColorMode.HS
|
||||
_attr_supported_color_modes = {ColorMode.HS}
|
||||
|
||||
_color_switcher: dict[str, tuple[float, float]] = {
|
||||
RGBColorState.WHITE: (0.0, 0.0),
|
||||
RGBColorState.RED: (0.0, 100.0),
|
||||
RGBColorState.YELLOW: (60.0, 100.0),
|
||||
RGBColorState.GREEN: (120.0, 100.0),
|
||||
RGBColorState.TURQUOISE: (180.0, 100.0),
|
||||
RGBColorState.BLUE: (240.0, 100.0),
|
||||
RGBColorState.PURPLE: (300.0, 100.0),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self, hap: HomematicipHAP, device: CombinationSignallingDevice
|
||||
) -> None:
|
||||
"""Initialize the combination signalling light entity."""
|
||||
super().__init__(hap, device, channel=1, is_multi_channel=False)
|
||||
|
||||
@property
|
||||
def _func_channel(self) -> NotificationMp3SoundChannel:
|
||||
return self._device.functionalChannels[self._channel]
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if light is on."""
|
||||
return self._func_channel.on
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return int((self._func_channel.dimLevel or 0.0) * 255)
|
||||
|
||||
@property
|
||||
def hs_color(self) -> tuple[float, float]:
|
||||
"""Return the hue and saturation color value [float, float]."""
|
||||
simple_rgb_color = self._func_channel.simpleRGBColorState
|
||||
return self._color_switcher.get(simple_rgb_color, (0.0, 0.0))
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color)
|
||||
simple_rgb_color = _convert_color(hs_color)
|
||||
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness)
|
||||
|
||||
# Default to full brightness when no kwargs given
|
||||
if not kwargs:
|
||||
brightness = 255
|
||||
|
||||
# Minimum brightness is 10, otherwise the LED is disabled
|
||||
brightness = max(10, brightness)
|
||||
dim_level = brightness / 255.0
|
||||
|
||||
await self._func_channel.set_rgb_dim_level_async(
|
||||
rgb_color_state=simple_rgb_color.name,
|
||||
dim_level=dim_level,
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
await self._func_channel.turn_off_async()
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/huum",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["huum==0.8.1"]
|
||||
}
|
||||
|
||||
105
homeassistant/components/huum/quality_scale.yaml
Normal file
105
homeassistant/components/huum/quality_scale.yaml
Normal file
@@ -0,0 +1,105 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage:
|
||||
status: done
|
||||
comment: |
|
||||
PLANNED: Rename result2 -> result throughout test_config_flow.py.
|
||||
config-flow:
|
||||
status: done
|
||||
comment: |
|
||||
PLANNED: Move _async_abort_entries_match before the try block so duplicate
|
||||
entries are rejected before credentials are validated. Remove _LOGGER.error
|
||||
call from config_flow.py — the error message is redundant with the errors
|
||||
dict entry.
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry:
|
||||
status: done
|
||||
comment: |
|
||||
PLANNED: Move _async_abort_entries_match before the try block in
|
||||
config_flow.py so duplicate entries are rejected before credentials are
|
||||
validated.
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: Integration has no options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: done
|
||||
comment: |
|
||||
PLANNED: Remove _LOGGER.error from coordinator.py — the message is already
|
||||
passed to UpdateFailed, so logging it separately is redundant.
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
PLANNED: Remove unique_id from mock config entry in conftest.py. Use
|
||||
freezer-based time advancement instead of directly calling async_refresh().
|
||||
Rename result2 -> result in test files.
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Single device per account, no dynamic devices.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: All entities are core functionality.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: done
|
||||
comment: |
|
||||
PLANNED: Remove the icon property from climate.py — entities should not set
|
||||
custom icons. Use HA defaults or icon translations instead.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration has no repair scenarios.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Single device per config entry.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -46,6 +46,16 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
self.account = Account(websession=async_get_clientsession(hass))
|
||||
self.previous_members: set[str] = set()
|
||||
|
||||
# Initialize previous_members from the device registry so that
|
||||
# stale devices can be detected on the first update after restart.
|
||||
device_registry = dr.async_get(hass)
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry.entry_id
|
||||
):
|
||||
for domain, identifier in device.identifiers:
|
||||
if domain == DOMAIN:
|
||||
self.previous_members.add(identifier)
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Update all device states from the Litter-Robot API."""
|
||||
try:
|
||||
|
||||
78
homeassistant/components/lojack/__init__.py
Normal file
78
homeassistant/components/lojack/__init__.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""The LoJack integration for Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from lojack_api import ApiError, AuthenticationError, LoJackClient, Vehicle
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import LoJackCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoJackData:
|
||||
"""Runtime data for a LoJack config entry."""
|
||||
|
||||
client: LoJackClient
|
||||
coordinators: list[LoJackCoordinator] = field(default_factory=list)
|
||||
|
||||
|
||||
type LoJackConfigEntry = ConfigEntry[LoJackData]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: LoJackConfigEntry) -> bool:
|
||||
"""Set up LoJack from a config entry."""
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
try:
|
||||
client = await LoJackClient.create(
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_PASSWORD],
|
||||
session=session,
|
||||
)
|
||||
except AuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
|
||||
except ApiError as err:
|
||||
raise ConfigEntryNotReady(f"API error during setup: {err}") from err
|
||||
|
||||
try:
|
||||
vehicles = await client.list_devices()
|
||||
except AuthenticationError as err:
|
||||
await client.close()
|
||||
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
|
||||
except ApiError as err:
|
||||
await client.close()
|
||||
raise ConfigEntryNotReady(f"API error during setup: {err}") from err
|
||||
|
||||
data = LoJackData(client=client)
|
||||
entry.runtime_data = data
|
||||
|
||||
try:
|
||||
for vehicle in vehicles or []:
|
||||
if isinstance(vehicle, Vehicle):
|
||||
coordinator = LoJackCoordinator(hass, client, entry, vehicle)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
data.coordinators.append(coordinator)
|
||||
except Exception:
|
||||
await client.close()
|
||||
raise
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: LoJackConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
await entry.runtime_data.client.close()
|
||||
return unload_ok
|
||||
111
homeassistant/components/lojack/config_flow.py
Normal file
111
homeassistant/components/lojack/config_flow.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Config flow for LoJack integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from lojack_api import ApiError, AuthenticationError, LoJackClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class LoJackConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for LoJack."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
async with await LoJackClient.create(
|
||||
user_input[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
session=async_get_clientsession(self.hass),
|
||||
) as client:
|
||||
user_id = client.user_id
|
||||
except AuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ApiError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if not user_id:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(user_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=f"LoJack ({user_input[CONF_USERNAME]})",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication confirmation."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
async with await LoJackClient.create(
|
||||
reauth_entry.data[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
session=async_get_clientsession(self.hass),
|
||||
):
|
||||
pass
|
||||
except AuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ApiError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
|
||||
description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
|
||||
errors=errors,
|
||||
)
|
||||
13
homeassistant/components/lojack/const.py
Normal file
13
homeassistant/components/lojack/const.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Constants for the LoJack integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "lojack"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
# Default polling interval (in minutes)
|
||||
DEFAULT_UPDATE_INTERVAL: Final = 5
|
||||
68
homeassistant/components/lojack/coordinator.py
Normal file
68
homeassistant/components/lojack/coordinator.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Data update coordinator for the LoJack integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lojack_api import ApiError, AuthenticationError, LoJackClient
|
||||
from lojack_api.device import Vehicle
|
||||
from lojack_api.models import Location
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import LoJackConfigEntry
|
||||
|
||||
|
||||
def get_device_name(vehicle: Vehicle) -> str:
|
||||
"""Get a human-readable name for a vehicle."""
|
||||
parts = [
|
||||
str(vehicle.year) if vehicle.year else None,
|
||||
vehicle.make,
|
||||
vehicle.model,
|
||||
]
|
||||
name = " ".join(p for p in parts if p)
|
||||
return name or vehicle.name or "Vehicle"
|
||||
|
||||
|
||||
class LoJackCoordinator(DataUpdateCoordinator[Location]):
|
||||
"""Class to manage fetching LoJack data for a single vehicle."""
|
||||
|
||||
config_entry: LoJackConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
client: LoJackClient,
|
||||
entry: ConfigEntry,
|
||||
vehicle: Vehicle,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.client = client
|
||||
self.vehicle = vehicle
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=f"{DOMAIN}_{vehicle.id}",
|
||||
update_interval=timedelta(minutes=DEFAULT_UPDATE_INTERVAL),
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> Location:
|
||||
"""Fetch location data for this vehicle."""
|
||||
try:
|
||||
location = await self.vehicle.get_location(force=True)
|
||||
except AuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
|
||||
except ApiError as err:
|
||||
raise UpdateFailed(f"Error fetching data: {err}") from err
|
||||
if location is None:
|
||||
raise UpdateFailed("No location data available")
|
||||
return location
|
||||
78
homeassistant/components/lojack/device_tracker.py
Normal file
78
homeassistant/components/lojack/device_tracker.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Device tracker platform for LoJack integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.device_tracker import SourceType, TrackerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import LoJackConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LoJackCoordinator, get_device_name
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LoJackConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up LoJack device tracker from a config entry."""
|
||||
async_add_entities(
|
||||
LoJackDeviceTracker(coordinator)
|
||||
for coordinator in entry.runtime_data.coordinators
|
||||
)
|
||||
|
||||
|
||||
class LoJackDeviceTracker(CoordinatorEntity[LoJackCoordinator], TrackerEntity):
|
||||
"""Representation of a LoJack device tracker."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None # Main entity of the device, uses device name directly
|
||||
|
||||
def __init__(self, coordinator: LoJackCoordinator) -> None:
|
||||
"""Initialize the device tracker."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = coordinator.vehicle.id
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return the device info."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.coordinator.vehicle.id)},
|
||||
name=get_device_name(self.coordinator.vehicle),
|
||||
manufacturer="Spireon LoJack",
|
||||
model=self.coordinator.vehicle.model,
|
||||
serial_number=self.coordinator.vehicle.vin,
|
||||
)
|
||||
|
||||
@property
|
||||
def source_type(self) -> SourceType:
|
||||
"""Return the source type of the device."""
|
||||
return SourceType.GPS
|
||||
|
||||
@property
|
||||
def latitude(self) -> float | None:
|
||||
"""Return the latitude of the device."""
|
||||
return self.coordinator.data.latitude
|
||||
|
||||
@property
|
||||
def longitude(self) -> float | None:
|
||||
"""Return the longitude of the device."""
|
||||
return self.coordinator.data.longitude
|
||||
|
||||
@property
|
||||
def location_accuracy(self) -> int:
|
||||
"""Return the location accuracy of the device."""
|
||||
if self.coordinator.data.accuracy is not None:
|
||||
return int(self.coordinator.data.accuracy)
|
||||
return 0
|
||||
|
||||
@property
|
||||
def battery_level(self) -> int | None:
|
||||
"""Return the battery level of the device (if applicable)."""
|
||||
# LoJack devices report vehicle battery voltage, not percentage
|
||||
return None
|
||||
12
homeassistant/components/lojack/manifest.json
Normal file
12
homeassistant/components/lojack/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "lojack",
|
||||
"name": "LoJack",
|
||||
"codeowners": ["@devinslick"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lojack",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["lojack_api"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["lojack-api==0.7.1"]
|
||||
}
|
||||
81
homeassistant/components/lojack/quality_scale.yaml
Normal file
81
homeassistant/components/lojack/quality_scale.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: This integration does not provide an options flow.
|
||||
docs-installation-parameters:
|
||||
status: done
|
||||
comment: Documented in https://github.com/home-assistant/home-assistant.io/pull/43463
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: This is a cloud polling integration with no local discovery mechanism since the devices are not on a local network.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This is a cloud polling integration with no local discovery mechanism.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Vehicles are tied to the user account. Changes require integration reload.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: The device tracker entity is the primary entity and should be enabled by default.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No user-actionable repair scenarios identified for this integration.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Vehicles removed from the LoJack account stop appearing in API responses and become unavailable.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
38
homeassistant/components/lojack/strings.json
Normal file
38
homeassistant/components/lojack/strings.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "[%key:common::config_flow::initiate_flow::account%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::lojack::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "Re-enter the password for {username}."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "Your LoJack/Spireon account password",
|
||||
"username": "Your LoJack/Spireon account email address"
|
||||
},
|
||||
"description": "Enter your LoJack/Spireon account credentials."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ class MatterUpdate(MatterEntity, UpdateEntity):
|
||||
# Matter server.
|
||||
_attr_should_poll = True
|
||||
_software_update: MatterSoftwareVersion | None = None
|
||||
_installed_software_version: int | None = None
|
||||
_cancel_update: CALLBACK_TYPE | None = None
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL
|
||||
@@ -92,6 +93,9 @@ class MatterUpdate(MatterEntity, UpdateEntity):
|
||||
def _update_from_device(self) -> None:
|
||||
"""Update from device."""
|
||||
|
||||
self._installed_software_version = self.get_matter_attribute_value(
|
||||
clusters.BasicInformation.Attributes.SoftwareVersion
|
||||
)
|
||||
self._attr_installed_version = self.get_matter_attribute_value(
|
||||
clusters.BasicInformation.Attributes.SoftwareVersionString
|
||||
)
|
||||
@@ -123,6 +127,22 @@ class MatterUpdate(MatterEntity, UpdateEntity):
|
||||
else:
|
||||
self._attr_update_percentage = None
|
||||
|
||||
def _format_latest_version(
|
||||
self, update_information: MatterSoftwareVersion
|
||||
) -> str | None:
|
||||
"""Return the version string to expose in Home Assistant."""
|
||||
latest_version = update_information.software_version_string
|
||||
if self._installed_software_version is None:
|
||||
return latest_version
|
||||
|
||||
if update_information.software_version == self._installed_software_version:
|
||||
return self._attr_installed_version or latest_version
|
||||
|
||||
if latest_version == self._attr_installed_version:
|
||||
return f"{latest_version} ({update_information.software_version})"
|
||||
|
||||
return latest_version
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Call when the entity needs to be updated."""
|
||||
try:
|
||||
@@ -130,11 +150,13 @@ class MatterUpdate(MatterEntity, UpdateEntity):
|
||||
node_id=self._endpoint.node.node_id
|
||||
)
|
||||
if not update_information:
|
||||
self._software_update = None
|
||||
self._attr_latest_version = self._attr_installed_version
|
||||
self._attr_release_url = None
|
||||
return
|
||||
|
||||
self._software_update = update_information
|
||||
self._attr_latest_version = update_information.software_version_string
|
||||
self._attr_latest_version = self._format_latest_version(update_information)
|
||||
self._attr_release_url = update_information.release_notes_url
|
||||
|
||||
except UpdateCheckError as err:
|
||||
@@ -212,7 +234,12 @@ class MatterUpdate(MatterEntity, UpdateEntity):
|
||||
|
||||
software_version: str | int | None = version
|
||||
if self._software_update is not None and (
|
||||
version is None or version == self._software_update.software_version_string
|
||||
version is None
|
||||
or version
|
||||
in {
|
||||
self._software_update.software_version_string,
|
||||
self._attr_latest_version,
|
||||
}
|
||||
):
|
||||
# Update to the version previously fetched and shown.
|
||||
# We can pass the integer version directly to speedup download.
|
||||
|
||||
@@ -10,9 +10,13 @@ import httpx
|
||||
import ollama
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_URL, Platform
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
@@ -62,10 +66,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bool:
|
||||
"""Set up Ollama from a config entry."""
|
||||
settings = {**entry.data, **entry.options}
|
||||
client = ollama.AsyncClient(host=settings[CONF_URL], verify=get_default_context())
|
||||
api_key = settings.get(CONF_API_KEY)
|
||||
stripped_api_key = api_key.strip() if isinstance(api_key, str) else None
|
||||
client = ollama.AsyncClient(
|
||||
host=settings[CONF_URL],
|
||||
headers=(
|
||||
{"Authorization": f"Bearer {stripped_api_key}"}
|
||||
if stripped_api_key
|
||||
else None
|
||||
),
|
||||
verify=get_default_context(),
|
||||
)
|
||||
try:
|
||||
async with asyncio.timeout(DEFAULT_TIMEOUT):
|
||||
await client.list()
|
||||
except ollama.ResponseError as err:
|
||||
if err.status_code in (401, 403):
|
||||
raise ConfigEntryAuthFailed from err
|
||||
if err.status_code >= 500 or err.status_code == 429:
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
# If the response is a 4xx error other than 401 or 403, it likely means the URL is valid but not an Ollama instance,
|
||||
# so we raise ConfigEntryError to show an error in the UI, instead of ConfigEntryNotReady which would just keep retrying.
|
||||
raise ConfigEntryError(err) from err
|
||||
except (TimeoutError, httpx.ConnectError) as err:
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.config_entries import (
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_LLM_HASS_API, CONF_NAME, CONF_URL
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_URL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, llm
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -68,6 +68,17 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
vol.Required(CONF_URL): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.URL)
|
||||
),
|
||||
vol.Optional(CONF_API_KEY): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_API_KEY): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -78,9 +89,40 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 3
|
||||
MINOR_VERSION = 3
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize config flow."""
|
||||
self.url: str | None = None
|
||||
async def _async_validate_connection(
|
||||
self, url: str, api_key: str | None
|
||||
) -> dict[str, str]:
|
||||
"""Validate connection and credentials against the Ollama server."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
client = ollama.AsyncClient(
|
||||
host=url,
|
||||
headers={"Authorization": f"Bearer {api_key}"} if api_key else None,
|
||||
verify=get_default_context(),
|
||||
)
|
||||
|
||||
async with asyncio.timeout(DEFAULT_TIMEOUT):
|
||||
await client.list()
|
||||
|
||||
except ollama.ResponseError as err:
|
||||
if err.status_code in (401, 403):
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Error response from Ollama server at %s: status %s, detail: %s",
|
||||
url,
|
||||
err.status_code,
|
||||
str(err),
|
||||
)
|
||||
errors["base"] = "unknown"
|
||||
except TimeoutError, httpx.ConnectError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return errors
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -92,9 +134,10 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
errors = {}
|
||||
url = user_input[CONF_URL]
|
||||
|
||||
self._async_abort_entries_match({CONF_URL: url})
|
||||
url = user_input[CONF_URL].strip()
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
if api_key:
|
||||
api_key = api_key.strip()
|
||||
|
||||
try:
|
||||
url = cv.url(url)
|
||||
@@ -108,15 +151,8 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
try:
|
||||
client = ollama.AsyncClient(host=url, verify=get_default_context())
|
||||
async with asyncio.timeout(DEFAULT_TIMEOUT):
|
||||
await client.list()
|
||||
except TimeoutError, httpx.ConnectError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
self._async_abort_entries_match({CONF_URL: url})
|
||||
errors = await self._async_validate_connection(url, api_key)
|
||||
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
@@ -127,9 +163,65 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=url,
|
||||
data={CONF_URL: url},
|
||||
entry_data: dict[str, str] = {CONF_URL: url}
|
||||
if api_key:
|
||||
entry_data[CONF_API_KEY] = api_key
|
||||
|
||||
return self.async_create_entry(title=url, data=entry_data)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication when existing credentials are invalid."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication confirmation."""
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_DATA_SCHEMA,
|
||||
)
|
||||
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
if api_key:
|
||||
api_key = api_key.strip()
|
||||
|
||||
errors = await self._async_validate_connection(
|
||||
reauth_entry.data[CONF_URL], api_key
|
||||
)
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_REAUTH_DATA_SCHEMA, user_input
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
updated_data = {
|
||||
**reauth_entry.data,
|
||||
CONF_URL: reauth_entry.data[CONF_URL],
|
||||
}
|
||||
if api_key:
|
||||
updated_data[CONF_API_KEY] = api_key
|
||||
else:
|
||||
updated_data.pop(CONF_API_KEY, None)
|
||||
|
||||
updated_options = {
|
||||
key: value
|
||||
for key, value in reauth_entry.options.items()
|
||||
if key != CONF_API_KEY
|
||||
}
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data=updated_data,
|
||||
options=updated_options,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_url": "[%key:common::config_flow::error::invalid_host%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"description": "The Ollama integration needs to re-authenticate with your Ollama API key.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"url": "[%key:common::config_flow::data::url%]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ from onvif.client import (
|
||||
from onvif.exceptions import ONVIFError
|
||||
from onvif.util import stringify_onvif_error
|
||||
import onvif_parsers
|
||||
import onvif_parsers.util
|
||||
from zeep.exceptions import Fault, TransportError, ValidationError, XMLParseError
|
||||
|
||||
from homeassistant.components import webhook
|
||||
@@ -196,7 +197,7 @@ class EventManager:
|
||||
topic = msg.Topic._value_1.rstrip("/.") # noqa: SLF001
|
||||
|
||||
try:
|
||||
event = await onvif_parsers.parse(topic, unique_id, msg)
|
||||
events = await onvif_parsers.parse(topic, unique_id, msg)
|
||||
error = None
|
||||
except onvif_parsers.errors.UnknownTopicError:
|
||||
if topic not in UNHANDLED_TOPICS:
|
||||
@@ -204,42 +205,43 @@ class EventManager:
|
||||
"%s: No registered handler for event from %s: %s",
|
||||
self.name,
|
||||
unique_id,
|
||||
msg,
|
||||
onvif_parsers.util.event_to_debug_format(msg),
|
||||
)
|
||||
UNHANDLED_TOPICS.add(topic)
|
||||
continue
|
||||
except (AttributeError, KeyError) as e:
|
||||
event = None
|
||||
events = []
|
||||
error = e
|
||||
|
||||
if not event:
|
||||
if not events:
|
||||
LOGGER.warning(
|
||||
"%s: Unable to parse event from %s: %s: %s",
|
||||
self.name,
|
||||
unique_id,
|
||||
error,
|
||||
msg,
|
||||
onvif_parsers.util.event_to_debug_format(msg),
|
||||
)
|
||||
continue
|
||||
|
||||
value = event.value
|
||||
if event.device_class == "timestamp" and isinstance(value, str):
|
||||
value = _local_datetime_or_none(value)
|
||||
for event in events:
|
||||
value = event.value
|
||||
if event.device_class == "timestamp" and isinstance(value, str):
|
||||
value = _local_datetime_or_none(value)
|
||||
|
||||
ha_event = Event(
|
||||
uid=event.uid,
|
||||
name=event.name,
|
||||
platform=event.platform,
|
||||
device_class=event.device_class,
|
||||
unit_of_measurement=event.unit_of_measurement,
|
||||
value=value,
|
||||
entity_category=ENTITY_CATEGORY_MAPPING.get(
|
||||
event.entity_category or ""
|
||||
),
|
||||
entity_enabled=event.entity_enabled,
|
||||
)
|
||||
self.get_uids_by_platform(ha_event.platform).add(ha_event.uid)
|
||||
self._events[ha_event.uid] = ha_event
|
||||
ha_event = Event(
|
||||
uid=event.uid,
|
||||
name=event.name,
|
||||
platform=event.platform,
|
||||
device_class=event.device_class,
|
||||
unit_of_measurement=event.unit_of_measurement,
|
||||
value=value,
|
||||
entity_category=ENTITY_CATEGORY_MAPPING.get(
|
||||
event.entity_category or ""
|
||||
),
|
||||
entity_enabled=event.entity_enabled,
|
||||
)
|
||||
self.get_uids_by_platform(ha_event.platform).add(ha_event.uid)
|
||||
self._events[ha_event.uid] = ha_event
|
||||
|
||||
def get_uid(self, uid: str) -> Event | None:
|
||||
"""Retrieve event for given id."""
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"loggers": ["onvif", "wsdiscovery", "zeep"],
|
||||
"requirements": [
|
||||
"onvif-zeep-async==4.0.4",
|
||||
"onvif_parsers==1.2.2",
|
||||
"onvif_parsers==2.3.0",
|
||||
"WSDiscovery==2.1.2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/orvibo",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["orvibo"],
|
||||
"quality_scale": "legacy",
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.core import Event, HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.event import track_point_in_utc_time
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -37,6 +37,7 @@ DEFAULT_SEND_DELAY = 0.0
|
||||
DOMAIN = "pilight"
|
||||
|
||||
EVENT = "pilight_received"
|
||||
type EVENT_TYPE = Event[dict[str, Any]]
|
||||
|
||||
# The Pilight code schema depends on the protocol. Thus only require to have
|
||||
# the protocol information. Ensure that protocol is in a list otherwise
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -24,7 +25,7 @@ from homeassistant.helpers.event import track_point_in_time
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import EVENT
|
||||
from . import EVENT, EVENT_TYPE
|
||||
|
||||
CONF_VARIABLE = "variable"
|
||||
CONF_RESET_DELAY_SEC = "reset_delay_sec"
|
||||
@@ -46,6 +47,8 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
|
||||
}
|
||||
)
|
||||
|
||||
type _PAYLOAD_SET_TYPE = str | int | float
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass: HomeAssistant,
|
||||
@@ -59,12 +62,12 @@ def setup_platform(
|
||||
[
|
||||
PilightTriggerSensor(
|
||||
hass=hass,
|
||||
name=config.get(CONF_NAME),
|
||||
variable=config.get(CONF_VARIABLE),
|
||||
payload=config.get(CONF_PAYLOAD),
|
||||
on_value=config.get(CONF_PAYLOAD_ON),
|
||||
off_value=config.get(CONF_PAYLOAD_OFF),
|
||||
rst_dly_sec=config.get(CONF_RESET_DELAY_SEC),
|
||||
name=config[CONF_NAME],
|
||||
variable=config[CONF_VARIABLE],
|
||||
payload=config[CONF_PAYLOAD],
|
||||
on_value=config[CONF_PAYLOAD_ON],
|
||||
off_value=config[CONF_PAYLOAD_OFF],
|
||||
rst_dly_sec=config[CONF_RESET_DELAY_SEC],
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -73,11 +76,11 @@ def setup_platform(
|
||||
[
|
||||
PilightBinarySensor(
|
||||
hass=hass,
|
||||
name=config.get(CONF_NAME),
|
||||
variable=config.get(CONF_VARIABLE),
|
||||
payload=config.get(CONF_PAYLOAD),
|
||||
on_value=config.get(CONF_PAYLOAD_ON),
|
||||
off_value=config.get(CONF_PAYLOAD_OFF),
|
||||
name=config[CONF_NAME],
|
||||
variable=config[CONF_VARIABLE],
|
||||
payload=config[CONF_PAYLOAD],
|
||||
on_value=config[CONF_PAYLOAD_ON],
|
||||
off_value=config[CONF_PAYLOAD_OFF],
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -86,7 +89,15 @@ def setup_platform(
|
||||
class PilightBinarySensor(BinarySensorEntity):
|
||||
"""Representation of a binary sensor that can be updated using Pilight."""
|
||||
|
||||
def __init__(self, hass, name, variable, payload, on_value, off_value):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
name: str,
|
||||
variable: str,
|
||||
payload: dict[str, Any],
|
||||
on_value: _PAYLOAD_SET_TYPE,
|
||||
off_value: _PAYLOAD_SET_TYPE,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self._attr_is_on = False
|
||||
self._hass = hass
|
||||
@@ -98,7 +109,7 @@ class PilightBinarySensor(BinarySensorEntity):
|
||||
|
||||
hass.bus.listen(EVENT, self._handle_code)
|
||||
|
||||
def _handle_code(self, call):
|
||||
def _handle_code(self, call: EVENT_TYPE) -> None:
|
||||
"""Handle received code by the pilight-daemon.
|
||||
|
||||
If the code matches the defined payload
|
||||
@@ -126,8 +137,15 @@ class PilightTriggerSensor(BinarySensorEntity):
|
||||
"""Representation of a binary sensor that can be updated using Pilight."""
|
||||
|
||||
def __init__(
|
||||
self, hass, name, variable, payload, on_value, off_value, rst_dly_sec=30
|
||||
):
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
name: str,
|
||||
variable: str,
|
||||
payload: dict[str, Any],
|
||||
on_value: _PAYLOAD_SET_TYPE,
|
||||
off_value: _PAYLOAD_SET_TYPE,
|
||||
rst_dly_sec: int,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self._attr_is_on = False
|
||||
self._hass = hass
|
||||
@@ -137,17 +155,17 @@ class PilightTriggerSensor(BinarySensorEntity):
|
||||
self._on_value = on_value
|
||||
self._off_value = off_value
|
||||
self._reset_delay_sec = rst_dly_sec
|
||||
self._delay_after = None
|
||||
self._delay_after: datetime.datetime | None = None
|
||||
self._hass = hass
|
||||
|
||||
hass.bus.listen(EVENT, self._handle_code)
|
||||
|
||||
def _reset_state(self, call):
|
||||
def _reset_state(self, _: datetime.datetime) -> None:
|
||||
self._attr_is_on = False
|
||||
self._delay_after = None
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def _handle_code(self, call):
|
||||
def _handle_code(self, call: EVENT_TYPE) -> None:
|
||||
"""Handle received code by the pilight-daemon.
|
||||
|
||||
If the code matches the defined payload
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Base class for pilight."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
@@ -10,8 +12,10 @@ from homeassistant.const import (
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import DOMAIN, EVENT, SERVICE_NAME
|
||||
from .const import (
|
||||
@@ -60,19 +64,19 @@ class PilightBaseDevice(RestoreEntity):
|
||||
_attr_assumed_state = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, hass, name, config):
|
||||
def __init__(self, hass: HomeAssistant, name: str, config: ConfigType) -> None:
|
||||
"""Initialize a device."""
|
||||
self._hass = hass
|
||||
self._attr_name = config.get(CONF_NAME, name)
|
||||
self._attr_is_on = False
|
||||
self._attr_is_on: bool | None = False
|
||||
self._code_on = config.get(CONF_ON_CODE)
|
||||
self._code_off = config.get(CONF_OFF_CODE)
|
||||
|
||||
code_on_receive = config.get(CONF_ON_CODE_RECEIVE, [])
|
||||
code_off_receive = config.get(CONF_OFF_CODE_RECEIVE, [])
|
||||
|
||||
self._code_on_receive = []
|
||||
self._code_off_receive = []
|
||||
self._code_on_receive: list[_ReceiveHandle] = []
|
||||
self._code_off_receive: list[_ReceiveHandle] = []
|
||||
|
||||
for code_list, conf in (
|
||||
(self._code_on_receive, code_on_receive),
|
||||
@@ -85,7 +89,7 @@ class PilightBaseDevice(RestoreEntity):
|
||||
if any(self._code_on_receive) or any(self._code_off_receive):
|
||||
hass.bus.listen(EVENT, self._handle_code)
|
||||
|
||||
self._brightness = 255
|
||||
self._brightness: int | None = 255
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity about to be added to hass."""
|
||||
@@ -147,18 +151,18 @@ class PilightBaseDevice(RestoreEntity):
|
||||
|
||||
|
||||
class _ReceiveHandle:
|
||||
def __init__(self, config, echo):
|
||||
def __init__(self, config: dict[str, Any], echo: bool) -> None:
|
||||
"""Initialize the handle."""
|
||||
self.config_items = config.items()
|
||||
self.echo = echo
|
||||
|
||||
def match(self, code):
|
||||
def match(self, code: dict[str, Any]) -> bool:
|
||||
"""Test if the received code matches the configured values.
|
||||
|
||||
The received values have to be a subset of the configured options.
|
||||
"""
|
||||
return self.config_items <= code.items()
|
||||
|
||||
def run(self, switch, turn_on):
|
||||
def run(self, switch: PilightBaseDevice, turn_on: bool) -> None:
|
||||
"""Change the state of the switch."""
|
||||
switch.set_state(turn_on=turn_on, send_code=self.echo)
|
||||
|
||||
@@ -55,11 +55,11 @@ class PilightLight(PilightBaseDevice, LightEntity):
|
||||
_attr_color_mode = ColorMode.BRIGHTNESS
|
||||
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
|
||||
def __init__(self, hass, name, config):
|
||||
def __init__(self, hass: HomeAssistant, name: str, config: ConfigType) -> None:
|
||||
"""Initialize a switch."""
|
||||
super().__init__(hass, name, config)
|
||||
self._dimlevel_min = config.get(CONF_DIMLEVEL_MIN)
|
||||
self._dimlevel_max = config.get(CONF_DIMLEVEL_MAX)
|
||||
self._dimlevel_min: int = config[CONF_DIMLEVEL_MIN]
|
||||
self._dimlevel_max: int = config[CONF_DIMLEVEL_MAX]
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -16,7 +17,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import EVENT
|
||||
from . import EVENT, EVENT_TYPE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,9 +45,9 @@ def setup_platform(
|
||||
[
|
||||
PilightSensor(
|
||||
hass=hass,
|
||||
name=config.get(CONF_NAME),
|
||||
variable=config.get(CONF_VARIABLE),
|
||||
payload=config.get(CONF_PAYLOAD),
|
||||
name=config[CONF_NAME],
|
||||
variable=config[CONF_VARIABLE],
|
||||
payload=config[CONF_PAYLOAD],
|
||||
unit_of_measurement=config.get(CONF_UNIT_OF_MEASUREMENT),
|
||||
)
|
||||
]
|
||||
@@ -58,33 +59,24 @@ class PilightSensor(SensorEntity):
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, hass, name, variable, payload, unit_of_measurement):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
name: str,
|
||||
variable: str,
|
||||
payload: dict[str, Any],
|
||||
unit_of_measurement: str | None,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self._state = None
|
||||
self._hass = hass
|
||||
self._name = name
|
||||
self._attr_name = name
|
||||
self._variable = variable
|
||||
self._payload = payload
|
||||
self._unit_of_measurement = unit_of_measurement
|
||||
self._attr_native_unit_of_measurement = unit_of_measurement
|
||||
|
||||
hass.bus.listen(EVENT, self._handle_code)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
"""Return the unit this state is expressed in."""
|
||||
return self._unit_of_measurement
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the entity."""
|
||||
return self._state
|
||||
|
||||
def _handle_code(self, call):
|
||||
def _handle_code(self, call: EVENT_TYPE) -> None:
|
||||
"""Handle received code by the pilight-daemon.
|
||||
|
||||
If the code matches the defined payload
|
||||
@@ -96,7 +88,7 @@ class PilightSensor(SensorEntity):
|
||||
if self._payload.items() <= call.data.items():
|
||||
try:
|
||||
value = call.data[self._variable]
|
||||
self._state = value
|
||||
self._attr_native_value = value
|
||||
self.schedule_update_ha_state()
|
||||
except KeyError:
|
||||
_LOGGER.error(
|
||||
|
||||
@@ -36,11 +36,6 @@ type PlugwiseConfigEntry = ConfigEntry[PlugwiseDataUpdateCoordinator]
|
||||
class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData]]):
|
||||
"""Class to manage fetching Plugwise data from single endpoint."""
|
||||
|
||||
_connected: bool = False
|
||||
_current_devices: set[str]
|
||||
_stored_devices: set[str]
|
||||
new_devices: set[str]
|
||||
|
||||
config_entry: PlugwiseConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: PlugwiseConfigEntry) -> None:
|
||||
@@ -68,9 +63,10 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
|
||||
port=self.config_entry.data.get(CONF_PORT, DEFAULT_PORT),
|
||||
websession=async_get_clientsession(hass, verify_ssl=False),
|
||||
)
|
||||
self._current_devices = set()
|
||||
self._stored_devices = set()
|
||||
self.new_devices = set()
|
||||
self._connected: bool = False
|
||||
self._current_devices: set[str] = set()
|
||||
self._stored_devices: set[str] = set()
|
||||
self.new_devices: set[str] = set()
|
||||
|
||||
async def _connect(self) -> None:
|
||||
"""Connect to the Plugwise Smile.
|
||||
@@ -132,10 +128,10 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
|
||||
translation_key="unsupported_firmware",
|
||||
) from err
|
||||
|
||||
self._async_add_remove_devices(data)
|
||||
self._add_remove_devices(data)
|
||||
return data
|
||||
|
||||
def _async_add_remove_devices(self, data: dict[str, GwEntityData]) -> None:
|
||||
def _add_remove_devices(self, data: dict[str, GwEntityData]) -> None:
|
||||
"""Add new Plugwise devices, remove non-existing devices."""
|
||||
set_of_data = set(data)
|
||||
# Check for new or removed devices,
|
||||
@@ -146,35 +142,28 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
|
||||
self._stored_devices if not self._current_devices else self._current_devices
|
||||
)
|
||||
self._current_devices = set_of_data
|
||||
if current_devices - set_of_data: # device(s) to remove
|
||||
self._async_remove_devices(data)
|
||||
if removed_devices := (current_devices - set_of_data): # device(s) to remove
|
||||
self._remove_devices(removed_devices)
|
||||
|
||||
def _async_remove_devices(self, data: dict[str, GwEntityData]) -> None:
|
||||
def _remove_devices(self, removed_devices: set[str]) -> None:
|
||||
"""Clean registries when removed devices found."""
|
||||
device_reg = dr.async_get(self.hass)
|
||||
device_list = dr.async_entries_for_config_entry(
|
||||
device_reg, self.config_entry.entry_id
|
||||
)
|
||||
for device_id in removed_devices:
|
||||
device_entry = device_reg.async_get_device({(DOMAIN, device_id)})
|
||||
if device_entry is None:
|
||||
LOGGER.warning(
|
||||
"Failed to remove %s device/zone %s, not present in device_registry",
|
||||
DOMAIN,
|
||||
device_id,
|
||||
)
|
||||
continue # pragma: no cover
|
||||
|
||||
# First find the Plugwise via_device
|
||||
gateway_device = device_reg.async_get_device({(DOMAIN, self.api.gateway_id)})
|
||||
assert gateway_device is not None
|
||||
via_device_id = gateway_device.id
|
||||
# Then remove the connected orphaned device(s)
|
||||
for device_entry in device_list:
|
||||
for identifier in device_entry.identifiers:
|
||||
if (
|
||||
identifier[0] == DOMAIN
|
||||
and device_entry.via_device_id == via_device_id
|
||||
and identifier[1] not in data
|
||||
):
|
||||
device_reg.async_update_device(
|
||||
device_entry.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Removed %s device/zone %s %s from device_registry",
|
||||
DOMAIN,
|
||||
device_entry.model,
|
||||
identifier[1],
|
||||
)
|
||||
device_reg.async_update_device(
|
||||
device_entry.id, remove_config_entry_id=self.config_entry.entry_id
|
||||
)
|
||||
LOGGER.debug(
|
||||
"%s %s %s removed from device_registry",
|
||||
DOMAIN,
|
||||
device_entry.model,
|
||||
device_id,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ from .coordinator import PranaConfigEntry, PranaCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.FAN, Platform.SWITCH]
|
||||
PLATFORMS = [Platform.FAN, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: PranaConfigEntry) -> bool:
|
||||
|
||||
@@ -8,6 +8,20 @@
|
||||
"default": "mdi:arrow-expand-left"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"inside_temperature": {
|
||||
"default": "mdi:home-thermometer"
|
||||
},
|
||||
"inside_temperature_2": {
|
||||
"default": "mdi:home-thermometer"
|
||||
},
|
||||
"outside_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"outside_temperature_2": {
|
||||
"default": "mdi:thermometer"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"auto": {
|
||||
"default": "mdi:fan-auto"
|
||||
|
||||
129
homeassistant/components/prana/sensor.py
Normal file
129
homeassistant/components/prana/sensor.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Sensor platform for Prana integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
UnitOfPressure,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PranaConfigEntry
|
||||
from .entity import PranaBaseEntity, PranaCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
class PranaSensorType(StrEnum):
|
||||
"""Enumerates Prana sensor types exposed by the device API."""
|
||||
|
||||
HUMIDITY = "humidity"
|
||||
VOC = "voc"
|
||||
AIR_PRESSURE = "air_pressure"
|
||||
CO2 = "co2"
|
||||
INSIDE_TEMPERATURE = "inside_temperature"
|
||||
INSIDE_TEMPERATURE_2 = "inside_temperature_2"
|
||||
OUTSIDE_TEMPERATURE = "outside_temperature"
|
||||
OUTSIDE_TEMPERATURE_2 = "outside_temperature_2"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PranaSensorEntityDescription(SensorEntityDescription):
|
||||
"""Description of a Prana sensor entity."""
|
||||
|
||||
key: PranaSensorType
|
||||
state_class: SensorStateClass = SensorStateClass.MEASUREMENT
|
||||
value_fn: Callable[[PranaCoordinator], StateType | None]
|
||||
|
||||
|
||||
ENTITIES: tuple[PranaSensorEntityDescription, ...] = (
|
||||
PranaSensorEntityDescription(
|
||||
key=PranaSensorType.HUMIDITY,
|
||||
value_fn=lambda coord: coord.data.humidity,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
),
|
||||
PranaSensorEntityDescription(
|
||||
key=PranaSensorType.VOC,
|
||||
value_fn=lambda coord: coord.data.voc,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
),
|
||||
PranaSensorEntityDescription(
|
||||
key=PranaSensorType.AIR_PRESSURE,
|
||||
value_fn=lambda coord: coord.data.air_pressure,
|
||||
native_unit_of_measurement=UnitOfPressure.MMHG,
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
),
|
||||
PranaSensorEntityDescription(
|
||||
key=PranaSensorType.CO2,
|
||||
value_fn=lambda coord: coord.data.co2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
),
|
||||
PranaSensorEntityDescription(
|
||||
key=PranaSensorType.INSIDE_TEMPERATURE,
|
||||
translation_key="inside_temperature",
|
||||
value_fn=lambda coord: coord.data.inside_temperature,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
PranaSensorEntityDescription(
|
||||
key=PranaSensorType.INSIDE_TEMPERATURE_2,
|
||||
translation_key="inside_temperature_2",
|
||||
value_fn=lambda coord: coord.data.inside_temperature_2,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
PranaSensorEntityDescription(
|
||||
key=PranaSensorType.OUTSIDE_TEMPERATURE,
|
||||
translation_key="outside_temperature",
|
||||
value_fn=lambda coord: coord.data.outside_temperature,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
PranaSensorEntityDescription(
|
||||
key=PranaSensorType.OUTSIDE_TEMPERATURE_2,
|
||||
translation_key="outside_temperature_2",
|
||||
value_fn=lambda coord: coord.data.outside_temperature_2,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: PranaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Prana sensor entities from a config entry."""
|
||||
async_add_entities(
|
||||
PranaSensor(entry.runtime_data, description)
|
||||
for description in ENTITIES
|
||||
if description.value_fn(entry.runtime_data) is not None
|
||||
)
|
||||
|
||||
|
||||
class PranaSensor(PranaBaseEntity, SensorEntity):
|
||||
"""Representation of a Prana sensor entity."""
|
||||
|
||||
entity_description: PranaSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator)
|
||||
@@ -49,6 +49,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"inside_temperature": {
|
||||
"name": "Inside temperature"
|
||||
},
|
||||
"inside_temperature_2": {
|
||||
"name": "Inside temperature 2"
|
||||
},
|
||||
"outside_temperature": {
|
||||
"name": "Outside temperature"
|
||||
},
|
||||
"outside_temperature_2": {
|
||||
"name": "Outside temperature 2"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"auto": {
|
||||
"name": "Auto"
|
||||
|
||||
@@ -19,8 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartTubConfigEntry) ->
|
||||
|
||||
controller = SmartTubController(hass)
|
||||
|
||||
if not await controller.async_setup_entry(entry):
|
||||
return False
|
||||
await controller.async_setup_entry(entry)
|
||||
|
||||
entry.runtime_data = controller
|
||||
|
||||
|
||||
74
homeassistant/components/smarttub/quality_scale.yaml
Normal file
74
homeassistant/components/smarttub/quality_scale.yaml
Normal file
@@ -0,0 +1,74 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: todo
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions: todo
|
||||
docs-high-level-description: todo
|
||||
docs-installation-instructions: todo
|
||||
docs-removal-instructions: todo
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Entities use coordinator polling, no explicit event subscriptions.
|
||||
entity-unique-id: done
|
||||
has-entity-name: todo
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: This integration does not have configuration parameters.
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: This is a cloud-only service with no local discovery mechanism.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This is a cloud-only service with no local discovery mechanism.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Spa devices are fixed to the account and cannot be dynamically added or removed.
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: This integration does not raise any repairable issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Spa devices are fixed to the account and cannot be removed.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
17
homeassistant/components/temperature/__init__.py
Normal file
17
homeassistant/components/temperature/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Integration for temperature triggers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
DOMAIN = "temperature"
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
__all__ = []
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
return True
|
||||
10
homeassistant/components/temperature/icons.json
Normal file
10
homeassistant/components/temperature/icons.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"triggers": {
|
||||
"changed": {
|
||||
"trigger": "mdi:thermometer"
|
||||
},
|
||||
"crossed_threshold": {
|
||||
"trigger": "mdi:thermometer"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
homeassistant/components/temperature/manifest.json
Normal file
8
homeassistant/components/temperature/manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "temperature",
|
||||
"name": "Temperature",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/temperature",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
82
homeassistant/components/temperature/strings.json
Normal file
82
homeassistant/components/temperature/strings.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted entities to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"selector": {
|
||||
"number_or_entity": {
|
||||
"choices": {
|
||||
"entity": "Entity",
|
||||
"number": "Number"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
},
|
||||
"trigger_temperature_unit": {
|
||||
"options": {
|
||||
"celsius": "Celsius (°C)",
|
||||
"fahrenheit": "Fahrenheit (°F)"
|
||||
}
|
||||
},
|
||||
"trigger_threshold_type": {
|
||||
"options": {
|
||||
"above": "Above",
|
||||
"below": "Below",
|
||||
"between": "Between",
|
||||
"outside": "Outside"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Temperature",
|
||||
"triggers": {
|
||||
"changed": {
|
||||
"description": "Triggers when the temperature changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Only trigger when temperature is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Only trigger when temperature is below this value.",
|
||||
"name": "Below"
|
||||
},
|
||||
"unit": {
|
||||
"description": "The unit of temperature for the trigger values. Defaults to the system unit.",
|
||||
"name": "Unit"
|
||||
}
|
||||
},
|
||||
"name": "Temperature changed"
|
||||
},
|
||||
"crossed_threshold": {
|
||||
"description": "Triggers when the temperature crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::temperature::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::temperature::common::trigger_behavior_name%]"
|
||||
},
|
||||
"lower_limit": {
|
||||
"description": "The lower limit of the threshold.",
|
||||
"name": "Lower limit"
|
||||
},
|
||||
"threshold_type": {
|
||||
"description": "The type of threshold to use.",
|
||||
"name": "Threshold type"
|
||||
},
|
||||
"unit": {
|
||||
"description": "[%key:component::temperature::triggers::changed::fields::unit::description%]",
|
||||
"name": "[%key:component::temperature::triggers::changed::fields::unit::name%]"
|
||||
},
|
||||
"upper_limit": {
|
||||
"description": "The upper limit of the threshold.",
|
||||
"name": "Upper limit"
|
||||
}
|
||||
},
|
||||
"name": "Temperature crossed threshold"
|
||||
}
|
||||
}
|
||||
}
|
||||
185
homeassistant/components/temperature/trigger.py
Normal file
185
homeassistant/components/temperature/trigger.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Provides triggers for temperature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE,
|
||||
DOMAIN as CLIMATE_DOMAIN,
|
||||
)
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.components.water_heater import (
|
||||
ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE,
|
||||
DOMAIN as WATER_HEATER_DOMAIN,
|
||||
)
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_WEATHER_TEMPERATURE,
|
||||
ATTR_WEATHER_TEMPERATURE_UNIT,
|
||||
DOMAIN as WEATHER_DOMAIN,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CONF_ABOVE,
|
||||
CONF_BELOW,
|
||||
CONF_OPTIONS,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.helpers.automation import NumericalDomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ATTR_BEHAVIOR,
|
||||
BEHAVIOR_ANY,
|
||||
BEHAVIOR_FIRST,
|
||||
BEHAVIOR_LAST,
|
||||
CONF_LOWER_LIMIT,
|
||||
CONF_THRESHOLD_TYPE,
|
||||
CONF_UPPER_LIMIT,
|
||||
ENTITY_STATE_TRIGGER_SCHEMA,
|
||||
EntityNumericalStateChangedTriggerBase,
|
||||
EntityNumericalStateCrossedThresholdTriggerBase,
|
||||
EntityNumericalStateTriggerBase,
|
||||
ThresholdType,
|
||||
Trigger,
|
||||
TriggerConfig,
|
||||
_number_or_entity,
|
||||
_validate_limits_for_threshold_type,
|
||||
_validate_range,
|
||||
)
|
||||
from homeassistant.util.unit_conversion import TemperatureConverter
|
||||
|
||||
CONF_UNIT = "unit"
|
||||
|
||||
_UNIT_MAP = {
|
||||
"celsius": UnitOfTemperature.CELSIUS,
|
||||
"fahrenheit": UnitOfTemperature.FAHRENHEIT,
|
||||
}
|
||||
|
||||
|
||||
def _validate_temperature_unit(value: str) -> str:
|
||||
"""Convert temperature unit option to UnitOfTemperature."""
|
||||
if value in _UNIT_MAP:
|
||||
return _UNIT_MAP[value]
|
||||
raise vol.Invalid(f"Unknown temperature unit: {value}")
|
||||
|
||||
|
||||
_UNIT_VALIDATOR = _validate_temperature_unit
|
||||
|
||||
TEMPERATURE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): vol.All(
|
||||
{
|
||||
vol.Optional(CONF_ABOVE): _number_or_entity,
|
||||
vol.Optional(CONF_BELOW): _number_or_entity,
|
||||
vol.Optional(CONF_UNIT): _UNIT_VALIDATOR,
|
||||
},
|
||||
_validate_range(CONF_ABOVE, CONF_BELOW),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TEMPERATURE_CROSSED_THRESHOLD_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): vol.All(
|
||||
{
|
||||
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
|
||||
[BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY]
|
||||
),
|
||||
vol.Optional(CONF_LOWER_LIMIT): _number_or_entity,
|
||||
vol.Optional(CONF_UPPER_LIMIT): _number_or_entity,
|
||||
vol.Required(CONF_THRESHOLD_TYPE): vol.Coerce(ThresholdType),
|
||||
vol.Optional(CONF_UNIT): _UNIT_VALIDATOR,
|
||||
},
|
||||
_validate_range(CONF_LOWER_LIMIT, CONF_UPPER_LIMIT),
|
||||
_validate_limits_for_threshold_type,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
TEMPERATURE_DOMAIN_SPECS = {
|
||||
CLIMATE_DOMAIN: NumericalDomainSpec(
|
||||
value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE,
|
||||
),
|
||||
SENSOR_DOMAIN: NumericalDomainSpec(
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
WATER_HEATER_DOMAIN: NumericalDomainSpec(
|
||||
value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE
|
||||
),
|
||||
WEATHER_DOMAIN: NumericalDomainSpec(
|
||||
value_source=ATTR_WEATHER_TEMPERATURE,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class _TemperatureTriggerMixin(EntityNumericalStateTriggerBase):
|
||||
"""Mixin for temperature triggers providing entity filtering, value extraction, and unit conversion."""
|
||||
|
||||
_domain_specs = TEMPERATURE_DOMAIN_SPECS
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the trigger."""
|
||||
super().__init__(hass, config)
|
||||
self._trigger_unit: str = self._options.get(
|
||||
CONF_UNIT, hass.config.units.temperature_unit
|
||||
)
|
||||
|
||||
def _get_entity_unit(self, state: State) -> str | None:
|
||||
"""Get the temperature unit of an entity from its state."""
|
||||
domain = split_entity_id(state.entity_id)[0]
|
||||
if domain == SENSOR_DOMAIN:
|
||||
return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
if domain == WEATHER_DOMAIN:
|
||||
return state.attributes.get(
|
||||
ATTR_WEATHER_TEMPERATURE_UNIT,
|
||||
self._hass.config.units.temperature_unit,
|
||||
)
|
||||
# Climate and water_heater: show_temp converts to system unit
|
||||
return self._hass.config.units.temperature_unit
|
||||
|
||||
def _get_tracked_value(self, state: State) -> Any:
|
||||
"""Get the temperature value converted to the trigger's configured unit."""
|
||||
raw_value = super()._get_tracked_value(state)
|
||||
if raw_value is None:
|
||||
return None
|
||||
|
||||
entity_unit = self._get_entity_unit(state)
|
||||
if entity_unit is None or entity_unit == self._trigger_unit:
|
||||
return raw_value
|
||||
|
||||
try:
|
||||
return TemperatureConverter.convert(
|
||||
float(raw_value), entity_unit, self._trigger_unit
|
||||
)
|
||||
except TypeError, ValueError:
|
||||
return raw_value # Let the base class converter handle the error
|
||||
|
||||
|
||||
class TemperatureChangedTrigger(
|
||||
_TemperatureTriggerMixin, EntityNumericalStateChangedTriggerBase
|
||||
):
|
||||
"""Trigger for temperature value changes across multiple domains."""
|
||||
|
||||
_schema = TEMPERATURE_CHANGED_TRIGGER_SCHEMA
|
||||
|
||||
|
||||
class TemperatureCrossedThresholdTrigger(
|
||||
_TemperatureTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
|
||||
):
|
||||
"""Trigger for temperature value crossing a threshold across multiple domains."""
|
||||
|
||||
_schema = TEMPERATURE_CROSSED_THRESHOLD_TRIGGER_SCHEMA
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"changed": TemperatureChangedTrigger,
|
||||
"crossed_threshold": TemperatureCrossedThresholdTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for temperature."""
|
||||
return TRIGGERS
|
||||
75
homeassistant/components/temperature/triggers.yaml
Normal file
75
homeassistant/components/temperature/triggers.yaml
Normal file
@@ -0,0 +1,75 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
.number_or_entity: &number_or_entity
|
||||
required: false
|
||||
selector:
|
||||
choose:
|
||||
choices:
|
||||
number:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
entity:
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain:
|
||||
- input_number
|
||||
- number
|
||||
- sensor
|
||||
translation_key: number_or_entity
|
||||
|
||||
.trigger_threshold_type: &trigger_threshold_type
|
||||
required: true
|
||||
default: above
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- above
|
||||
- below
|
||||
- between
|
||||
- outside
|
||||
translation_key: trigger_threshold_type
|
||||
|
||||
.trigger_unit: &trigger_unit
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- celsius
|
||||
- fahrenheit
|
||||
translation_key: trigger_temperature_unit
|
||||
|
||||
.trigger_target: &trigger_target
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: temperature
|
||||
- domain: climate
|
||||
- domain: water_heater
|
||||
- domain: weather
|
||||
|
||||
changed:
|
||||
target: *trigger_target
|
||||
fields:
|
||||
above: *number_or_entity
|
||||
below: *number_or_entity
|
||||
unit: *trigger_unit
|
||||
|
||||
crossed_threshold:
|
||||
target: *trigger_target
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold_type: *trigger_threshold_type
|
||||
lower_limit: *number_or_entity
|
||||
upper_limit: *number_or_entity
|
||||
unit: *trigger_unit
|
||||
@@ -22,7 +22,7 @@ from homeassistant.components.application_credentials import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -39,6 +39,7 @@ from .coordinator import (
|
||||
TeslemetryEnergyHistoryCoordinator,
|
||||
TeslemetryEnergySiteInfoCoordinator,
|
||||
TeslemetryEnergySiteLiveCoordinator,
|
||||
TeslemetryMetadataCoordinator,
|
||||
TeslemetryVehicleDataCoordinator,
|
||||
)
|
||||
from .helpers import async_update_device_sw_version, flatten
|
||||
@@ -109,6 +110,61 @@ async def _get_access_token(oauth_session: OAuth2Session) -> str:
|
||||
return cast(str, oauth_session.token[CONF_ACCESS_TOKEN])
|
||||
|
||||
|
||||
def _get_subscribed_ids_from_metadata(
|
||||
data: dict[str, Any],
|
||||
) -> tuple[set[str], set[str]]:
|
||||
"""Return metadata device IDs that have an active subscription."""
|
||||
subscribed_vins = {
|
||||
vin for vin, info in data["vehicles"].items() if info.get("access")
|
||||
}
|
||||
subscribed_site_ids = {
|
||||
site_id for site_id, info in data["energy_sites"].items() if info.get("access")
|
||||
}
|
||||
|
||||
return subscribed_vins, subscribed_site_ids
|
||||
|
||||
|
||||
def _setup_dynamic_discovery(
|
||||
hass: HomeAssistant,
|
||||
entry: TeslemetryConfigEntry,
|
||||
metadata_coordinator: TeslemetryMetadataCoordinator,
|
||||
known_vins: set[str],
|
||||
known_site_ids: set[str],
|
||||
) -> None:
|
||||
"""Set up dynamic device discovery via reload when subscriptions change."""
|
||||
|
||||
@callback
|
||||
def _handle_metadata_update() -> None:
|
||||
"""Handle metadata coordinator update - detect subscription changes."""
|
||||
data = metadata_coordinator.data
|
||||
if not data:
|
||||
return
|
||||
|
||||
current_vins, current_site_ids = _get_subscribed_ids_from_metadata(data)
|
||||
|
||||
added_vins = current_vins - known_vins
|
||||
removed_vins = known_vins - current_vins
|
||||
added_sites = current_site_ids - known_site_ids
|
||||
removed_sites = known_site_ids - current_site_ids
|
||||
|
||||
if added_vins or removed_vins or added_sites or removed_sites:
|
||||
LOGGER.info(
|
||||
"Tesla subscription changes detected "
|
||||
"(added vehicles: %s, removed vehicles: %s, "
|
||||
"added energy sites: %s, removed energy sites: %s), "
|
||||
"reloading integration",
|
||||
added_vins or "none",
|
||||
removed_vins or "none",
|
||||
added_sites or "none",
|
||||
removed_sites or "none",
|
||||
)
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
entry.async_on_unload(
|
||||
metadata_coordinator.async_add_listener(_handle_metadata_update)
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool:
|
||||
"""Set up Teslemetry config."""
|
||||
|
||||
@@ -159,6 +215,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
scopes = calls[0]["scopes"]
|
||||
region = calls[0]["region"]
|
||||
vehicle_metadata = calls[0]["vehicles"]
|
||||
energy_site_metadata = calls[0]["energy_sites"]
|
||||
products = calls[1]["response"]
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
@@ -167,21 +224,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
vehicles: list[TeslemetryVehicleData] = []
|
||||
energysites: list[TeslemetryEnergyData] = []
|
||||
|
||||
# Create the stream
|
||||
# Create the stream (created lazily when first vehicle is found)
|
||||
stream: TeslemetryStream | None = None
|
||||
|
||||
# Remember each device identifier we create
|
||||
current_devices: set[tuple[str, str]] = set()
|
||||
|
||||
# Track known devices for dynamic discovery (based on metadata access state)
|
||||
known_vins, known_site_ids = _get_subscribed_ids_from_metadata(calls[0])
|
||||
|
||||
for product in products:
|
||||
if (
|
||||
"vin" in product
|
||||
and vehicle_metadata.get(product["vin"], {}).get("access")
|
||||
and Scope.VEHICLE_DEVICE_DATA in scopes
|
||||
):
|
||||
vin = product["vin"]
|
||||
current_devices.add((DOMAIN, vin))
|
||||
|
||||
# Create stream if required (for first vehicle)
|
||||
if not stream:
|
||||
stream = TeslemetryStream(
|
||||
session,
|
||||
access_token,
|
||||
server=f"{region.lower()}.teslemetry.com",
|
||||
parse_timestamp=True,
|
||||
manual=True,
|
||||
)
|
||||
|
||||
# Remove the protobuff 'cached_data' that we do not use to save memory
|
||||
product.pop("cached_data", None)
|
||||
vin = product["vin"]
|
||||
vehicle = teslemetry.vehicles.create(vin)
|
||||
coordinator = TeslemetryVehicleDataCoordinator(
|
||||
hass, entry, vehicle, product
|
||||
@@ -197,17 +269,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
serial_number=vin,
|
||||
sw_version=firmware,
|
||||
)
|
||||
current_devices.add((DOMAIN, vin))
|
||||
|
||||
# Create stream if required
|
||||
if not stream:
|
||||
stream = TeslemetryStream(
|
||||
session,
|
||||
access_token,
|
||||
server=f"{region.lower()}.teslemetry.com",
|
||||
parse_timestamp=True,
|
||||
manual=True,
|
||||
)
|
||||
poll = vehicle_metadata[vin].get("polling", False)
|
||||
|
||||
entry.async_on_unload(
|
||||
stream.async_add_listener(
|
||||
@@ -216,7 +279,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
)
|
||||
)
|
||||
stream_vehicle = stream.get_vehicle(vin)
|
||||
poll = vehicle_metadata[vin].get("polling", False)
|
||||
|
||||
vehicles.append(
|
||||
TeslemetryVehicleData(
|
||||
@@ -227,13 +289,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
stream=stream,
|
||||
stream_vehicle=stream_vehicle,
|
||||
vin=vin,
|
||||
firmware=firmware or "",
|
||||
firmware=firmware or "Unknown",
|
||||
device=device,
|
||||
)
|
||||
)
|
||||
|
||||
elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes:
|
||||
elif (
|
||||
"energy_site_id" in product
|
||||
and Scope.ENERGY_DEVICE_DATA in scopes
|
||||
and energy_site_metadata.get(str(product["energy_site_id"]), {}).get(
|
||||
"access"
|
||||
)
|
||||
):
|
||||
site_id = product["energy_site_id"]
|
||||
|
||||
powerwall = (
|
||||
product["components"]["battery"] or product["components"]["solar"]
|
||||
)
|
||||
@@ -245,6 +314,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
)
|
||||
continue
|
||||
|
||||
current_devices.add((DOMAIN, str(site_id)))
|
||||
if wall_connector:
|
||||
current_devices |= {
|
||||
(DOMAIN, c["din"]) for c in product["components"]["wall_connectors"]
|
||||
}
|
||||
|
||||
energy_site = teslemetry.energySites.create(site_id)
|
||||
device = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(site_id))},
|
||||
@@ -253,13 +328,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
name=product.get("site_name", "Energy Site"),
|
||||
serial_number=str(site_id),
|
||||
)
|
||||
current_devices.add((DOMAIN, str(site_id)))
|
||||
|
||||
if wall_connector:
|
||||
for connector in product["components"]["wall_connectors"]:
|
||||
current_devices.add((DOMAIN, connector["din"]))
|
||||
|
||||
# Check live status endpoint works before creating its coordinator
|
||||
# For initial setup, raise auth errors properly
|
||||
try:
|
||||
live_status = (await energy_site.live_status())["response"]
|
||||
except InvalidToken as e:
|
||||
@@ -348,10 +418,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
)
|
||||
|
||||
# Setup Platforms
|
||||
entry.runtime_data = TeslemetryData(vehicles, energysites, scopes, stream)
|
||||
metadata_coordinator = TeslemetryMetadataCoordinator(hass, entry, teslemetry)
|
||||
|
||||
entry.runtime_data = TeslemetryData(
|
||||
vehicles=vehicles,
|
||||
energysites=energysites,
|
||||
scopes=scopes,
|
||||
stream=stream,
|
||||
metadata_coordinator=metadata_coordinator,
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
_setup_dynamic_discovery(
|
||||
hass,
|
||||
entry,
|
||||
metadata_coordinator,
|
||||
known_vins,
|
||||
known_site_ids,
|
||||
)
|
||||
|
||||
if stream:
|
||||
entry.async_on_unload(stream.close)
|
||||
entry.async_create_background_task(hass, stream.listen(), "Teslemetry Stream")
|
||||
@@ -454,7 +539,6 @@ async def async_setup_stream(
|
||||
hass: HomeAssistant, entry: TeslemetryConfigEntry, vehicle: TeslemetryVehicleData
|
||||
) -> None:
|
||||
"""Set up the stream for a vehicle."""
|
||||
|
||||
await vehicle.stream_vehicle.get_config()
|
||||
entry.async_create_background_task(
|
||||
hass,
|
||||
|
||||
@@ -15,7 +15,7 @@ from tesla_fleet_api.exceptions import (
|
||||
SubscriptionRequired,
|
||||
TeslaFleetError,
|
||||
)
|
||||
from tesla_fleet_api.teslemetry import EnergySite, Vehicle
|
||||
from tesla_fleet_api.teslemetry import EnergySite, Teslemetry, Vehicle
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
@@ -48,6 +48,7 @@ VEHICLE_WAIT = timedelta(minutes=15)
|
||||
ENERGY_LIVE_INTERVAL = timedelta(seconds=30)
|
||||
ENERGY_INFO_INTERVAL = timedelta(seconds=30)
|
||||
ENERGY_HISTORY_INTERVAL = timedelta(seconds=60)
|
||||
METADATA_INTERVAL = timedelta(hours=1)
|
||||
|
||||
ENDPOINTS = [
|
||||
VehicleDataEndpoint.CHARGE_STATE,
|
||||
@@ -59,6 +60,50 @@ ENDPOINTS = [
|
||||
]
|
||||
|
||||
|
||||
class TeslemetryMetadataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Coordinator to poll for subscription changes via metadata."""
|
||||
|
||||
config_entry: TeslemetryConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: TeslemetryConfigEntry,
|
||||
teslemetry: Teslemetry,
|
||||
) -> None:
|
||||
"""Initialize Teslemetry Metadata coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=config_entry,
|
||||
name="Teslemetry Metadata",
|
||||
update_interval=METADATA_INTERVAL,
|
||||
)
|
||||
self.teslemetry = teslemetry
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch latest metadata for subscription status."""
|
||||
try:
|
||||
data = await self.teslemetry.metadata()
|
||||
except (InvalidToken, SubscriptionRequired) as e:
|
||||
raise ConfigEntryAuthFailed from e
|
||||
except RETRY_EXCEPTIONS as e:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"message": e.message},
|
||||
retry_after=_get_retry_after(e),
|
||||
) from e
|
||||
except TeslaFleetError as e:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"message": e.message},
|
||||
) from e
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Class to manage fetching data from the Teslemetry API."""
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tesla-fleet-api"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["tesla-fleet-api==1.4.3", "teslemetry-stream==0.9.0"]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ from .coordinator import (
|
||||
TeslemetryEnergyHistoryCoordinator,
|
||||
TeslemetryEnergySiteInfoCoordinator,
|
||||
TeslemetryEnergySiteLiveCoordinator,
|
||||
TeslemetryMetadataCoordinator,
|
||||
TeslemetryVehicleDataCoordinator,
|
||||
)
|
||||
|
||||
@@ -28,6 +29,7 @@ class TeslemetryData:
|
||||
energysites: list[TeslemetryEnergyData]
|
||||
scopes: list[Scope]
|
||||
stream: TeslemetryStream | None
|
||||
metadata_coordinator: TeslemetryMetadataCoordinator
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -45,13 +45,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: todo
|
||||
comment: |
|
||||
New vehicles/energy sites added to user's Tesla account after initial setup
|
||||
are not detected. Need to periodically poll teslemetry.products() and add
|
||||
new TeslemetryVehicleData/TeslemetryEnergyData to runtime_data, then trigger
|
||||
entity creation via coordinator listeners in each platform.
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
|
||||
@@ -27,11 +27,28 @@ from .const import (
|
||||
VICARE_TOKEN_FILENAME,
|
||||
)
|
||||
from .types import ViCareConfigEntry, ViCareData, ViCareDevice
|
||||
from .utils import get_device, get_device_serial, login
|
||||
from .utils import get_device_serial, login
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, config_entry: ViCareConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old entry."""
|
||||
if config_entry.version > 1:
|
||||
return False
|
||||
|
||||
if config_entry.version == 1 and config_entry.minor_version < 2:
|
||||
_LOGGER.debug("Migrating ViCare config entry from version 1.1 to 1.2")
|
||||
data = {**config_entry.data}
|
||||
data.pop("heating_type", None)
|
||||
hass.config_entries.async_update_entry(config_entry, data=data, minor_version=2)
|
||||
_LOGGER.debug("Migration to version 1.2 successful")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ViCareConfigEntry) -> bool:
|
||||
"""Set up from config entry."""
|
||||
_LOGGER.debug("Setting up ViCare component")
|
||||
@@ -74,7 +91,7 @@ def setup_vicare_api(hass: HomeAssistant, entry: ViCareConfigEntry) -> PyViCare:
|
||||
)
|
||||
|
||||
devices = [
|
||||
ViCareDevice(config=device_config, api=get_device(entry, device_config))
|
||||
ViCareDevice(config=device_config, api=device_config.asAutoDetectDevice())
|
||||
for device_config in device_config_list
|
||||
if bool(device_config.isOnline())
|
||||
]
|
||||
|
||||
@@ -18,14 +18,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import (
|
||||
CONF_HEATING_TYPE,
|
||||
DEFAULT_HEATING_TYPE,
|
||||
DOMAIN,
|
||||
VICARE_NAME,
|
||||
VIESSMANN_DEVELOPER_PORTAL,
|
||||
HeatingType,
|
||||
)
|
||||
from .const import DOMAIN, VICARE_NAME, VIESSMANN_DEVELOPER_PORTAL
|
||||
from .utils import login
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -40,9 +33,6 @@ REAUTH_SCHEMA = vol.Schema(
|
||||
USER_SCHEMA = REAUTH_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_HEATING_TYPE, default=DEFAULT_HEATING_TYPE.value): vol.In(
|
||||
[e.value for e in HeatingType]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -51,6 +41,7 @@ class ViCareConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for ViCare."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Constants for the ViCare integration."""
|
||||
|
||||
import enum
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "vicare"
|
||||
@@ -31,7 +29,6 @@ VICARE_TOKEN_FILENAME = "vicare_token.save"
|
||||
VIESSMANN_DEVELOPER_PORTAL = "https://app.developer.viessmann-climatesolutions.com"
|
||||
|
||||
CONF_CIRCUIT = "circuit"
|
||||
CONF_HEATING_TYPE = "heating_type"
|
||||
|
||||
DEFAULT_CACHE_DURATION = 60
|
||||
|
||||
@@ -43,28 +40,3 @@ VICARE_KWH = "kilowattHour"
|
||||
VICARE_PERCENT = "percent"
|
||||
VICARE_W = "watt"
|
||||
VICARE_WH = "wattHour"
|
||||
|
||||
|
||||
class HeatingType(enum.Enum):
|
||||
"""Possible options for heating type."""
|
||||
|
||||
auto = "auto"
|
||||
gas = "gas"
|
||||
oil = "oil"
|
||||
pellets = "pellets"
|
||||
heatpump = "heatpump"
|
||||
fuelcell = "fuelcell"
|
||||
hybrid = "hybrid"
|
||||
|
||||
|
||||
DEFAULT_HEATING_TYPE = HeatingType.auto
|
||||
|
||||
HEATING_TYPE_TO_CREATOR_METHOD = {
|
||||
HeatingType.auto: "asAutoDetectDevice",
|
||||
HeatingType.gas: "asGazBoiler",
|
||||
HeatingType.fuelcell: "asFuelCell",
|
||||
HeatingType.heatpump: "asHeatPump",
|
||||
HeatingType.oil: "asOilBoiler",
|
||||
HeatingType.pellets: "asPelletsBoiler",
|
||||
HeatingType.hybrid: "asHybridDevice",
|
||||
}
|
||||
|
||||
@@ -24,13 +24,11 @@
|
||||
"user": {
|
||||
"data": {
|
||||
"client_id": "Client ID",
|
||||
"heating_type": "Heating type",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::email%]"
|
||||
},
|
||||
"data_description": {
|
||||
"client_id": "The ID of the API client created in the [Viessmann developer portal]({viessmann_developer_portal}).",
|
||||
"heating_type": "Allows to overrule the device auto detection.",
|
||||
"password": "The password to log in to your ViCare account.",
|
||||
"username": "The email address to log in to your ViCare account."
|
||||
},
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import Any
|
||||
|
||||
from PyViCare.PyViCare import PyViCare
|
||||
from PyViCare.PyViCareDevice import Device as PyViCareDevice
|
||||
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
|
||||
from PyViCare.PyViCareHeatingDevice import (
|
||||
HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent,
|
||||
)
|
||||
@@ -25,14 +24,7 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import STORAGE_DIR
|
||||
|
||||
from .const import (
|
||||
CONF_HEATING_TYPE,
|
||||
DEFAULT_CACHE_DURATION,
|
||||
HEATING_TYPE_TO_CREATOR_METHOD,
|
||||
VICARE_TOKEN_FILENAME,
|
||||
HeatingType,
|
||||
)
|
||||
from .types import ViCareConfigEntry
|
||||
from .const import DEFAULT_CACHE_DURATION, VICARE_TOKEN_FILENAME
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,16 +46,6 @@ def login(
|
||||
return vicare_api
|
||||
|
||||
|
||||
def get_device(
|
||||
entry: ViCareConfigEntry, device_config: PyViCareDeviceConfig
|
||||
) -> PyViCareDevice:
|
||||
"""Get device for device config."""
|
||||
return getattr(
|
||||
device_config,
|
||||
HEATING_TYPE_TO_CREATOR_METHOD[HeatingType(entry.data[CONF_HEATING_TYPE])],
|
||||
)()
|
||||
|
||||
|
||||
def get_device_serial(device: PyViCareDevice) -> str | None:
|
||||
"""Get device serial for device if supported."""
|
||||
try:
|
||||
|
||||
@@ -408,9 +408,64 @@ SENSOR_DESCRIPTIONS = {
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
Keys.AC_CURRENT: VictronBLESensorEntityDescription(
|
||||
key=Keys.AC_CURRENT,
|
||||
translation_key=Keys.AC_CURRENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
Keys.OUTPUT_VOLTAGE_1: VictronBLESensorEntityDescription(
|
||||
key=Keys.OUTPUT_VOLTAGE_1,
|
||||
translation_key="output_phase_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_placeholders={"phase": "1"},
|
||||
),
|
||||
Keys.OUTPUT_CURRENT_1: VictronBLESensorEntityDescription(
|
||||
key=Keys.OUTPUT_CURRENT_1,
|
||||
translation_key="output_phase_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_placeholders={"phase": "1"},
|
||||
),
|
||||
Keys.OUTPUT_VOLTAGE_2: VictronBLESensorEntityDescription(
|
||||
key=Keys.OUTPUT_VOLTAGE_2,
|
||||
translation_key="output_phase_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_placeholders={"phase": "2"},
|
||||
),
|
||||
Keys.OUTPUT_CURRENT_2: VictronBLESensorEntityDescription(
|
||||
key=Keys.OUTPUT_CURRENT_2,
|
||||
translation_key="output_phase_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_placeholders={"phase": "2"},
|
||||
),
|
||||
Keys.OUTPUT_VOLTAGE_3: VictronBLESensorEntityDescription(
|
||||
key=Keys.OUTPUT_VOLTAGE_3,
|
||||
translation_key="output_phase_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_placeholders={"phase": "3"},
|
||||
),
|
||||
Keys.OUTPUT_CURRENT_3: VictronBLESensorEntityDescription(
|
||||
key=Keys.OUTPUT_CURRENT_3,
|
||||
translation_key="output_phase_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_placeholders={"phase": "3"},
|
||||
),
|
||||
}
|
||||
|
||||
for i in range(1, 8):
|
||||
for i in range(1, 9):
|
||||
cell_key = getattr(Keys, f"CELL_{i}_VOLTAGE")
|
||||
SENSOR_DESCRIPTIONS[cell_key] = VictronBLESensorEntityDescription(
|
||||
key=cell_key,
|
||||
@@ -418,6 +473,7 @@ for i in range(1, 8):
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_placeholders={"cell": str(i)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"ac_current": {
|
||||
"name": "AC current"
|
||||
},
|
||||
"ac_in_power": {
|
||||
"name": "AC-in power"
|
||||
},
|
||||
@@ -235,6 +238,12 @@
|
||||
"switched_off_switch": "Switched off by switch"
|
||||
}
|
||||
},
|
||||
"output_phase_current": {
|
||||
"name": "Output phase {phase} current"
|
||||
},
|
||||
"output_phase_voltage": {
|
||||
"name": "Output phase {phase} voltage"
|
||||
},
|
||||
"output_voltage": {
|
||||
"name": "Output voltage"
|
||||
},
|
||||
|
||||
@@ -2245,9 +2245,10 @@ class ConfigEntries:
|
||||
self._entries = entries
|
||||
self.async_update_issues()
|
||||
|
||||
self.hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STARTED, self._async_scan_orphan_ignored_entries
|
||||
)
|
||||
if not self.hass.config.recovery_mode and not self.hass.config.safe_mode:
|
||||
self.hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STARTED, self._async_scan_orphan_ignored_entries
|
||||
)
|
||||
|
||||
async def _async_scan_orphan_ignored_entries(
|
||||
self, event: Event[NoEventData]
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -399,6 +399,7 @@ FLOWS = {
|
||||
"local_ip",
|
||||
"local_todo",
|
||||
"locative",
|
||||
"lojack",
|
||||
"london_underground",
|
||||
"lookin",
|
||||
"loqed",
|
||||
|
||||
@@ -3828,6 +3828,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"lojack": {
|
||||
"name": "LoJack",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"london_air": {
|
||||
"name": "London Air",
|
||||
"integration_type": "hub",
|
||||
@@ -5008,7 +5014,7 @@
|
||||
},
|
||||
"orvibo": {
|
||||
"name": "Orvibo",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
|
||||
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.30.0
|
||||
aiohomeconnect==0.32.0
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==3.2.20
|
||||
@@ -1448,6 +1448,9 @@ livisi==0.0.25
|
||||
# homeassistant.components.google_maps
|
||||
locationsharinglib==5.0.1
|
||||
|
||||
# homeassistant.components.lojack
|
||||
lojack-api==0.7.1
|
||||
|
||||
# homeassistant.components.london_underground
|
||||
london-tube-status==0.5
|
||||
|
||||
@@ -1688,7 +1691,7 @@ onedrive-personal-sdk==0.1.7
|
||||
onvif-zeep-async==4.0.4
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif_parsers==1.2.2
|
||||
onvif_parsers==2.3.0
|
||||
|
||||
# homeassistant.components.opengarage
|
||||
open-garage==0.2.0
|
||||
|
||||
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.30.0
|
||||
aiohomeconnect==0.32.0
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==3.2.20
|
||||
@@ -1267,6 +1267,9 @@ libsoundtouch==0.8
|
||||
# homeassistant.components.livisi
|
||||
livisi==0.0.25
|
||||
|
||||
# homeassistant.components.lojack
|
||||
lojack-api==0.7.1
|
||||
|
||||
# homeassistant.components.london_underground
|
||||
london-tube-status==0.5
|
||||
|
||||
@@ -1474,7 +1477,7 @@ onedrive-personal-sdk==0.1.7
|
||||
onvif-zeep-async==4.0.4
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif_parsers==1.2.2
|
||||
onvif_parsers==2.3.0
|
||||
|
||||
# homeassistant.components.opengarage
|
||||
open-garage==0.2.0
|
||||
|
||||
@@ -55,7 +55,6 @@ MISSING_INTEGRATION_TYPE = {
|
||||
"ness_alarm",
|
||||
"nmap_tracker",
|
||||
"otp",
|
||||
"orvibo",
|
||||
"profiler",
|
||||
"proximity",
|
||||
"rhasspy",
|
||||
|
||||
@@ -120,6 +120,7 @@ NO_IOT_CLASS = [
|
||||
"system_health",
|
||||
"system_log",
|
||||
"tag",
|
||||
"temperature",
|
||||
"timer",
|
||||
"trace",
|
||||
"web_rtc",
|
||||
|
||||
@@ -469,7 +469,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
||||
"huisbaasje",
|
||||
"hunterdouglas_powerview",
|
||||
"husqvarna_automower_ble",
|
||||
"huum",
|
||||
"hvv_departures",
|
||||
"hydrawise",
|
||||
"hyperion",
|
||||
@@ -869,7 +868,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
||||
"sma",
|
||||
"smappee",
|
||||
"smart_meter_texas",
|
||||
"smarttub",
|
||||
"smarty",
|
||||
"smhi",
|
||||
"sms",
|
||||
@@ -1455,7 +1453,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
|
||||
"huisbaasje",
|
||||
"hunterdouglas_powerview",
|
||||
"husqvarna_automower_ble",
|
||||
"huum",
|
||||
"hvv_departures",
|
||||
"hydrawise",
|
||||
"hyperion",
|
||||
@@ -2153,6 +2150,7 @@ NO_QUALITY_SCALE = [
|
||||
"system_health",
|
||||
"system_log",
|
||||
"tag",
|
||||
"temperature",
|
||||
"timer",
|
||||
"trace",
|
||||
"usage_prediction",
|
||||
|
||||
@@ -359,10 +359,13 @@ async def test_send_usage_with_supervisor(
|
||||
"healthy": True,
|
||||
"supported": True,
|
||||
"arch": "amd64",
|
||||
"addons": [{"slug": "test_addon"}],
|
||||
}
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.hassio.get_addons_info",
|
||||
side_effect=Mock(return_value={"test_addon": {}}),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.hassio.get_os_info",
|
||||
side_effect=Mock(return_value={}),
|
||||
@@ -578,10 +581,13 @@ async def test_send_statistics_with_supervisor(
|
||||
"healthy": True,
|
||||
"supported": True,
|
||||
"arch": "amd64",
|
||||
"addons": [{"slug": "test_addon"}],
|
||||
}
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.hassio.get_addons_info",
|
||||
side_effect=Mock(return_value={"test_addon": {}}),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.hassio.get_os_info",
|
||||
side_effect=Mock(return_value={}),
|
||||
|
||||
@@ -7,23 +7,52 @@ from collections.abc import AsyncGenerator, Callable, Coroutine, Generator, Mapp
|
||||
from functools import lru_cache
|
||||
from importlib.util import find_spec
|
||||
import inspect
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from pathlib import Path
|
||||
import re
|
||||
import string
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from aiohasupervisor import SupervisorNotFoundError
|
||||
from aiohasupervisor import SupervisorClient, SupervisorNotFoundError
|
||||
from aiohasupervisor.addons import AddonsClient
|
||||
from aiohasupervisor.backups import BackupsClient
|
||||
from aiohasupervisor.discovery import DiscoveryClient
|
||||
from aiohasupervisor.homeassistant import HomeAssistantClient
|
||||
from aiohasupervisor.host import HostClient
|
||||
from aiohasupervisor.jobs import JobsClient
|
||||
from aiohasupervisor.models import (
|
||||
AddonStage,
|
||||
AddonState,
|
||||
Discovery,
|
||||
DockerNetwork,
|
||||
GreenInfo,
|
||||
HomeAssistantInfo,
|
||||
HomeAssistantStats,
|
||||
HostInfo,
|
||||
InstalledAddon,
|
||||
JobsInfo,
|
||||
LogLevel,
|
||||
MountsInfo,
|
||||
NetworkInfo,
|
||||
OSInfo,
|
||||
Repository,
|
||||
ResolutionInfo,
|
||||
RootInfo,
|
||||
StoreAddon,
|
||||
StoreInfo,
|
||||
SupervisorInfo,
|
||||
SupervisorState,
|
||||
SupervisorStats,
|
||||
UpdateChannel,
|
||||
YellowInfo,
|
||||
)
|
||||
from aiohasupervisor.mounts import MountsClient
|
||||
from aiohasupervisor.network import NetworkClient
|
||||
from aiohasupervisor.os import OSClient
|
||||
from aiohasupervisor.resolution import ResolutionClient
|
||||
from aiohasupervisor.store import StoreClient
|
||||
from aiohasupervisor.supervisor import SupervisorManagementClient
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -538,23 +567,239 @@ def os_green_info_fixture(supervisor_client: AsyncMock) -> AsyncMock:
|
||||
return supervisor_client.os.green_info
|
||||
|
||||
|
||||
@pytest.fixture(name="supervisor_root_info")
|
||||
def supervisor_root_info_fixture(supervisor_client: AsyncMock) -> AsyncMock:
|
||||
"""Mock root info API from supervisor."""
|
||||
supervisor_client.info.return_value = RootInfo(
|
||||
supervisor="222",
|
||||
homeassistant="0.110.0",
|
||||
hassos="1.2.3",
|
||||
docker="",
|
||||
hostname=None,
|
||||
operating_system=None,
|
||||
features=[],
|
||||
machine=None,
|
||||
machine_id=None,
|
||||
arch="",
|
||||
state=SupervisorState.RUNNING,
|
||||
supported_arch=[],
|
||||
supported=True,
|
||||
channel=UpdateChannel.STABLE,
|
||||
logging=LogLevel.INFO,
|
||||
timezone="Etc/UTC",
|
||||
)
|
||||
return supervisor_client.info
|
||||
|
||||
|
||||
@pytest.fixture(name="host_info")
|
||||
def host_info_fixture(supervisor_client: AsyncMock) -> AsyncMock:
|
||||
"""Mock host info API from supervisor."""
|
||||
supervisor_client.host.info.return_value = HostInfo(
|
||||
agent_version=None,
|
||||
apparmor_version=None,
|
||||
chassis="vm",
|
||||
virtualization=None,
|
||||
cpe=None,
|
||||
deployment=None,
|
||||
disk_free=1.6,
|
||||
disk_total=100.0,
|
||||
disk_used=98.4,
|
||||
disk_life_time=None,
|
||||
features=[],
|
||||
hostname=None,
|
||||
llmnr_hostname=None,
|
||||
kernel="4.19.0-6-amd64",
|
||||
operating_system="Debian GNU/Linux 10 (buster)",
|
||||
timezone=None,
|
||||
dt_utc=None,
|
||||
dt_synchronized=None,
|
||||
use_ntp=None,
|
||||
startup_time=None,
|
||||
boot_timestamp=None,
|
||||
broadcast_llmnr=None,
|
||||
broadcast_mdns=None,
|
||||
)
|
||||
return supervisor_client.host.info
|
||||
|
||||
|
||||
@pytest.fixture(name="homeassistant_info")
|
||||
def homeassistant_info_fixture(supervisor_client: AsyncMock) -> AsyncMock:
|
||||
"""Mock Home Assistant info API from supervisor."""
|
||||
supervisor_client.homeassistant.info.return_value = HomeAssistantInfo(
|
||||
version="1.0.0",
|
||||
version_latest="1.0.0",
|
||||
update_available=False,
|
||||
machine=None,
|
||||
ip_address=IPv4Address("172.30.32.1"),
|
||||
arch=None,
|
||||
image="homeassistant",
|
||||
boot=True,
|
||||
port=8123,
|
||||
ssl=False,
|
||||
watchdog=True,
|
||||
audio_input=None,
|
||||
audio_output=None,
|
||||
backups_exclude_database=False,
|
||||
duplicate_log_file=False,
|
||||
)
|
||||
return supervisor_client.homeassistant.info
|
||||
|
||||
|
||||
@pytest.fixture(name="supervisor_info")
|
||||
def supervisor_info_fixture(supervisor_client: AsyncMock) -> AsyncMock:
|
||||
"""Mock supervisor info API from supervisor."""
|
||||
supervisor_client.supervisor.info.return_value = SupervisorInfo(
|
||||
version="1.0.0",
|
||||
version_latest="1.0.0",
|
||||
update_available=False,
|
||||
channel=UpdateChannel.STABLE,
|
||||
arch="",
|
||||
supported=True,
|
||||
healthy=True,
|
||||
ip_address=IPv4Address("172.30.32.2"),
|
||||
timezone=None,
|
||||
logging=LogLevel.INFO,
|
||||
debug=False,
|
||||
debug_block=False,
|
||||
diagnostics=None,
|
||||
auto_update=True,
|
||||
country=None,
|
||||
detect_blocking_io=False,
|
||||
)
|
||||
return supervisor_client.supervisor.info
|
||||
|
||||
|
||||
@pytest.fixture(name="addons_list")
|
||||
def addons_list_fixture(supervisor_client: AsyncMock) -> AsyncMock:
|
||||
"""Mock addons list API from supervisor."""
|
||||
supervisor_client.addons.list.return_value = [
|
||||
InstalledAddon(
|
||||
detached=False,
|
||||
advanced=False,
|
||||
available=True,
|
||||
build=False,
|
||||
description="",
|
||||
homeassistant=None,
|
||||
icon=False,
|
||||
logo=False,
|
||||
name="test",
|
||||
repository="core",
|
||||
slug="test",
|
||||
stage=AddonStage.STABLE,
|
||||
update_available=True,
|
||||
url="https://github.com/home-assistant/addons/test",
|
||||
version_latest="2.0.1",
|
||||
version="2.0.0",
|
||||
state=AddonState.STARTED,
|
||||
),
|
||||
InstalledAddon(
|
||||
detached=False,
|
||||
advanced=False,
|
||||
available=True,
|
||||
build=False,
|
||||
description="",
|
||||
homeassistant=None,
|
||||
icon=False,
|
||||
logo=False,
|
||||
name="test2",
|
||||
repository="core",
|
||||
slug="test2",
|
||||
stage=AddonStage.STABLE,
|
||||
update_available=False,
|
||||
url="https://github.com",
|
||||
version_latest="3.1.0",
|
||||
version="3.1.0",
|
||||
state=AddonState.STOPPED,
|
||||
),
|
||||
]
|
||||
return supervisor_client.addons.list
|
||||
|
||||
|
||||
@pytest.fixture(name="network_info")
|
||||
def network_info_fixture(supervisor_client: AsyncMock) -> AsyncMock:
|
||||
"""Mock network info API from supervisor."""
|
||||
supervisor_client.network.info.return_value = NetworkInfo(
|
||||
interfaces=[],
|
||||
docker=DockerNetwork(
|
||||
interface="hassio",
|
||||
address=IPv4Network("172.30.32.0/23"),
|
||||
gateway=IPv4Address("172.30.32.1"),
|
||||
dns=IPv4Address("172.30.32.3"),
|
||||
),
|
||||
host_internet=True,
|
||||
supervisor_internet=True,
|
||||
)
|
||||
return supervisor_client.network.info
|
||||
|
||||
|
||||
@pytest.fixture(name="os_info")
|
||||
def os_info_fixture(supervisor_client: AsyncMock) -> AsyncMock:
|
||||
"""Mock os info API from supervisor."""
|
||||
supervisor_client.os.info.return_value = OSInfo(
|
||||
version="1.0.0",
|
||||
version_latest="1.0.0",
|
||||
update_available=False,
|
||||
board=None,
|
||||
boot=None,
|
||||
data_disk=None,
|
||||
boot_slots={},
|
||||
)
|
||||
return supervisor_client.os.info
|
||||
|
||||
|
||||
@pytest.fixture(name="homeassistant_stats")
|
||||
def homeassistant_stats_fixture(supervisor_client: AsyncMock) -> AsyncMock:
|
||||
"""Mock Home Assistant stats API from supervisor."""
|
||||
supervisor_client.homeassistant.stats.return_value = HomeAssistantStats(
|
||||
cpu_percent=0.99,
|
||||
memory_usage=182611968,
|
||||
memory_limit=3977146368,
|
||||
memory_percent=4.59,
|
||||
network_rx=362570232,
|
||||
network_tx=82374138,
|
||||
blk_read=46010945536,
|
||||
blk_write=15051526144,
|
||||
)
|
||||
return supervisor_client.homeassistant.stats
|
||||
|
||||
|
||||
@pytest.fixture(name="supervisor_stats")
|
||||
def supervisor_stats_fixture(supervisor_client: AsyncMock) -> AsyncMock:
|
||||
"""Mock supervisor stats API from supervisor."""
|
||||
supervisor_client.supervisor.stats.return_value = SupervisorStats(
|
||||
cpu_percent=0.99,
|
||||
memory_usage=182611968,
|
||||
memory_limit=3977146368,
|
||||
memory_percent=4.59,
|
||||
network_rx=362570232,
|
||||
network_tx=82374138,
|
||||
blk_read=46010945536,
|
||||
blk_write=15051526144,
|
||||
)
|
||||
return supervisor_client.supervisor.stats
|
||||
|
||||
|
||||
@pytest.fixture(name="supervisor_client")
|
||||
def supervisor_client() -> Generator[AsyncMock]:
|
||||
"""Mock the supervisor client."""
|
||||
mounts_info_mock = AsyncMock(spec_set=["default_backup_mount", "mounts"])
|
||||
mounts_info_mock.default_backup_mount = None
|
||||
mounts_info_mock.mounts = []
|
||||
supervisor_client = AsyncMock()
|
||||
supervisor_client.addons = AsyncMock()
|
||||
supervisor_client.discovery = AsyncMock()
|
||||
supervisor_client.homeassistant = AsyncMock()
|
||||
supervisor_client.host = AsyncMock()
|
||||
supervisor_client.jobs = AsyncMock()
|
||||
supervisor_client.jobs.info.return_value = MagicMock()
|
||||
supervisor_client.mounts.info.return_value = mounts_info_mock
|
||||
supervisor_client.os = AsyncMock()
|
||||
supervisor_client.resolution = AsyncMock()
|
||||
supervisor_client.supervisor = AsyncMock()
|
||||
supervisor_client = AsyncMock(spec=SupervisorClient)
|
||||
supervisor_client.addons = AsyncMock(spec=AddonsClient)
|
||||
supervisor_client.backups = AsyncMock(spec=BackupsClient)
|
||||
supervisor_client.discovery = AsyncMock(spec=DiscoveryClient)
|
||||
supervisor_client.homeassistant = AsyncMock(spec=HomeAssistantClient)
|
||||
supervisor_client.host = AsyncMock(spec=HostClient)
|
||||
supervisor_client.jobs = AsyncMock(spec=JobsClient)
|
||||
supervisor_client.jobs.info.return_value = JobsInfo(ignore_conditions=[], jobs=[])
|
||||
supervisor_client.mounts = AsyncMock(spec=MountsClient)
|
||||
supervisor_client.mounts.info.return_value = MagicMock(
|
||||
spec=MountsInfo, default_backup_mount=None, mounts=[]
|
||||
)
|
||||
supervisor_client.network = AsyncMock(spec=NetworkClient)
|
||||
supervisor_client.os = AsyncMock(spec=OSClient)
|
||||
supervisor_client.resolution = AsyncMock(spec=ResolutionClient)
|
||||
supervisor_client.supervisor = AsyncMock(spec=SupervisorManagementClient)
|
||||
supervisor_client.store = AsyncMock(spec=StoreClient)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.hassio.get_supervisor_client",
|
||||
|
||||
@@ -60,6 +60,7 @@ async def test_turn_on(hass: HomeAssistant, fan_entity_id) -> None:
|
||||
await hass.services.async_call(
|
||||
fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
|
||||
@@ -77,6 +78,7 @@ async def test_turn_on_with_speed_and_percentage(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 100},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 100
|
||||
@@ -87,6 +89,7 @@ async def test_turn_on_with_speed_and_percentage(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 66},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 66
|
||||
@@ -97,6 +100,7 @@ async def test_turn_on_with_speed_and_percentage(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 33},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 33
|
||||
@@ -107,6 +111,7 @@ async def test_turn_on_with_speed_and_percentage(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 100},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 100
|
||||
@@ -117,6 +122,7 @@ async def test_turn_on_with_speed_and_percentage(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 66},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 66
|
||||
@@ -127,6 +133,7 @@ async def test_turn_on_with_speed_and_percentage(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 33},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 33
|
||||
@@ -137,6 +144,7 @@ async def test_turn_on_with_speed_and_percentage(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 0},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_OFF
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 0
|
||||
@@ -155,6 +163,7 @@ async def test_turn_on_with_preset_mode_only(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_AUTO},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_AUTO
|
||||
@@ -171,6 +180,7 @@ async def test_turn_on_with_preset_mode_only(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_SMART},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_SMART
|
||||
@@ -178,6 +188,7 @@ async def test_turn_on_with_preset_mode_only(
|
||||
await hass.services.async_call(
|
||||
fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_OFF
|
||||
assert state.attributes[fan.ATTR_PRESET_MODE] is None
|
||||
@@ -214,6 +225,7 @@ async def test_turn_on_with_preset_mode_and_speed(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_AUTO},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] is None
|
||||
@@ -231,6 +243,7 @@ async def test_turn_on_with_preset_mode_and_speed(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 100},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 100
|
||||
@@ -242,6 +255,7 @@ async def test_turn_on_with_preset_mode_and_speed(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_SMART},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] is None
|
||||
@@ -250,6 +264,7 @@ async def test_turn_on_with_preset_mode_and_speed(
|
||||
await hass.services.async_call(
|
||||
fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_OFF
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 0
|
||||
@@ -284,12 +299,14 @@ async def test_turn_off(hass: HomeAssistant, fan_entity_id) -> None:
|
||||
await hass.services.async_call(
|
||||
fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
|
||||
await hass.services.async_call(
|
||||
fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
@@ -303,12 +320,14 @@ async def test_turn_off_without_entity_id(hass: HomeAssistant, fan_entity_id) ->
|
||||
await hass.services.async_call(
|
||||
fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
|
||||
await hass.services.async_call(
|
||||
fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_MATCH_ALL}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
@@ -325,6 +344,7 @@ async def test_set_direction(hass: HomeAssistant, fan_entity_id) -> None:
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_DIRECTION: fan.DIRECTION_REVERSE},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_DIRECTION] == fan.DIRECTION_REVERSE
|
||||
|
||||
@@ -341,6 +361,7 @@ async def test_set_preset_mode(hass: HomeAssistant, fan_entity_id) -> None:
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_AUTO},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] is None
|
||||
@@ -386,6 +407,7 @@ async def test_set_percentage(hass: HomeAssistant, fan_entity_id) -> None:
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 33},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 33
|
||||
|
||||
@@ -403,6 +425,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
|
||||
{ATTR_ENTITY_ID: fan_entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 33
|
||||
|
||||
@@ -412,6 +435,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
|
||||
{ATTR_ENTITY_ID: fan_entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 66
|
||||
|
||||
@@ -421,6 +445,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
|
||||
{ATTR_ENTITY_ID: fan_entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 100
|
||||
|
||||
@@ -430,6 +455,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
|
||||
{ATTR_ENTITY_ID: fan_entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 100
|
||||
|
||||
@@ -439,6 +465,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
|
||||
{ATTR_ENTITY_ID: fan_entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 66
|
||||
|
||||
@@ -448,6 +475,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
|
||||
{ATTR_ENTITY_ID: fan_entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 33
|
||||
|
||||
@@ -457,6 +485,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
|
||||
{ATTR_ENTITY_ID: fan_entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 0
|
||||
|
||||
@@ -466,6 +495,7 @@ async def test_increase_decrease_speed(hass: HomeAssistant, fan_entity_id) -> No
|
||||
{ATTR_ENTITY_ID: fan_entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 0
|
||||
|
||||
@@ -481,6 +511,7 @@ async def test_increase_decrease_speed_with_percentage_step(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE_STEP: 25},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 25
|
||||
|
||||
@@ -490,6 +521,7 @@ async def test_increase_decrease_speed_with_percentage_step(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE_STEP: 25},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 50
|
||||
|
||||
@@ -499,6 +531,7 @@ async def test_increase_decrease_speed_with_percentage_step(
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE_STEP: 25},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 75
|
||||
|
||||
@@ -516,6 +549,7 @@ async def test_oscillate(hass: HomeAssistant, fan_entity_id) -> None:
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_OSCILLATING: True},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_OSCILLATING] is True
|
||||
|
||||
@@ -525,6 +559,7 @@ async def test_oscillate(hass: HomeAssistant, fan_entity_id) -> None:
|
||||
{ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_OSCILLATING: False},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(fan_entity_id)
|
||||
assert state.attributes[fan.ATTR_OSCILLATING] is False
|
||||
|
||||
@@ -537,4 +572,5 @@ async def test_is_on(hass: HomeAssistant, fan_entity_id) -> None:
|
||||
await hass.services.async_call(
|
||||
fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert fan.is_on(hass, fan_entity_id)
|
||||
|
||||
@@ -107,6 +107,7 @@ async def test_source_select(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: entity_id, ATTR_INPUT_SOURCE: "xbox"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.attributes.get(ATTR_INPUT_SOURCE) == "xbox"
|
||||
|
||||
@@ -128,6 +129,7 @@ async def test_repeat_set(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_REPEAT: RepeatMode.ALL},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.attributes.get(ATTR_MEDIA_REPEAT) == RepeatMode.ALL
|
||||
|
||||
@@ -148,6 +150,7 @@ async def test_clear_playlist(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
@@ -179,6 +182,7 @@ async def test_volume_services(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0.5
|
||||
|
||||
@@ -188,6 +192,7 @@ async def test_volume_services(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0.4
|
||||
|
||||
@@ -197,6 +202,7 @@ async def test_volume_services(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0.5
|
||||
|
||||
@@ -219,6 +225,7 @@ async def test_volume_services(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True
|
||||
@@ -240,6 +247,7 @@ async def test_turning_off_and_on(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.state == STATE_OFF
|
||||
assert not is_on(hass, TEST_ENTITY_ID)
|
||||
@@ -250,6 +258,7 @@ async def test_turning_off_and_on(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.state == STATE_PLAYING
|
||||
assert is_on(hass, TEST_ENTITY_ID)
|
||||
@@ -260,6 +269,7 @@ async def test_turning_off_and_on(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.state == STATE_OFF
|
||||
assert not is_on(hass, TEST_ENTITY_ID)
|
||||
@@ -281,6 +291,7 @@ async def test_playing_pausing(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.state == STATE_PAUSED
|
||||
|
||||
@@ -290,6 +301,7 @@ async def test_playing_pausing(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.state == STATE_PLAYING
|
||||
|
||||
@@ -299,6 +311,7 @@ async def test_playing_pausing(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.state == STATE_PAUSED
|
||||
|
||||
@@ -308,6 +321,7 @@ async def test_playing_pausing(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.state == STATE_PLAYING
|
||||
|
||||
@@ -328,6 +342,7 @@ async def test_prev_next_track(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.attributes.get(ATTR_MEDIA_TRACK) == 2
|
||||
|
||||
@@ -337,6 +352,7 @@ async def test_prev_next_track(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.attributes.get(ATTR_MEDIA_TRACK) == 3
|
||||
|
||||
@@ -346,6 +362,7 @@ async def test_prev_next_track(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.attributes.get(ATTR_MEDIA_TRACK) == 2
|
||||
|
||||
@@ -364,6 +381,7 @@ async def test_prev_next_track(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: ent_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(ent_id)
|
||||
assert state.attributes.get(ATTR_MEDIA_EPISODE) == "2"
|
||||
|
||||
@@ -373,6 +391,7 @@ async def test_prev_next_track(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: ent_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(ent_id)
|
||||
assert state.attributes.get(ATTR_MEDIA_EPISODE) == "1"
|
||||
|
||||
@@ -418,6 +437,7 @@ async def test_play_media(hass: HomeAssistant) -> None:
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(ent_id)
|
||||
assert (
|
||||
MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
@@ -479,6 +499,7 @@ async def test_stop(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: TEST_ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(TEST_ENTITY_ID)
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
@@ -552,6 +573,7 @@ async def test_grouping(hass: HomeAssistant) -> None:
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(walkman)
|
||||
assert state.attributes.get(ATTR_GROUP_MEMBERS) == [walkman, kitchen]
|
||||
|
||||
@@ -561,6 +583,7 @@ async def test_grouping(hass: HomeAssistant) -> None:
|
||||
{ATTR_ENTITY_ID: walkman},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(walkman)
|
||||
assert state.attributes.get(ATTR_GROUP_MEMBERS) == []
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ async def test_turn_on(hass: HomeAssistant, switch_entity_id: str) -> None:
|
||||
{ATTR_ENTITY_ID: switch_entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(switch_entity_id)
|
||||
assert state.state == STATE_OFF
|
||||
@@ -56,6 +57,7 @@ async def test_turn_on(hass: HomeAssistant, switch_entity_id: str) -> None:
|
||||
{ATTR_ENTITY_ID: switch_entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(switch_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
@@ -70,6 +72,7 @@ async def test_turn_off(hass: HomeAssistant, switch_entity_id: str) -> None:
|
||||
{ATTR_ENTITY_ID: switch_entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(switch_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
@@ -80,6 +83,7 @@ async def test_turn_off(hass: HomeAssistant, switch_entity_id: str) -> None:
|
||||
{ATTR_ENTITY_ID: switch_entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(switch_entity_id)
|
||||
assert state.state == STATE_OFF
|
||||
@@ -93,6 +97,7 @@ async def test_turn_off_without_entity_id(
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "all"}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(switch_entity_id)
|
||||
assert state.state == STATE_ON
|
||||
@@ -100,6 +105,7 @@ async def test_turn_off_without_entity_id(
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(switch_entity_id)
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
@@ -38,8 +38,18 @@ def mock_growatt_v1_api():
|
||||
Methods mocked for switch and number operations:
|
||||
- min_write_parameter: Called by switch/number entities to change settings
|
||||
|
||||
Methods mocked for service operations:
|
||||
Methods mocked for MIN service operations:
|
||||
- min_write_time_segment: Called by time segment management services
|
||||
|
||||
Methods mocked for SPH device coordinator refresh:
|
||||
- sph_detail: Provides device state and charge/discharge settings
|
||||
- sph_energy: Provides energy data and last-update timestamp
|
||||
|
||||
Methods mocked for SPH service operations:
|
||||
- sph_write_ac_charge_times: Called by write_ac_charge_times service
|
||||
- sph_write_ac_discharge_times: Called by write_ac_discharge_times service
|
||||
- sph_read_ac_charge_times: Called by read_ac_charge_times service
|
||||
- sph_read_ac_discharge_times: Called by read_ac_discharge_times service
|
||||
"""
|
||||
with patch(
|
||||
"homeassistant.components.growatt_server.config_flow.growattServer.OpenApiV1",
|
||||
@@ -136,7 +146,7 @@ def mock_growatt_v1_api():
|
||||
# Called by switch/number entities during turn_on/turn_off/set_value
|
||||
mock_v1_api.min_write_parameter.return_value = None
|
||||
|
||||
# Called by time segment management services
|
||||
# Called by MIN time segment management services
|
||||
# Note: Don't use autospec for this method as it needs to accept variable arguments
|
||||
mock_v1_api.min_write_time_segment = Mock(
|
||||
return_value={
|
||||
@@ -145,6 +155,131 @@ def mock_growatt_v1_api():
|
||||
}
|
||||
)
|
||||
|
||||
# Called by SPH device coordinator during refresh
|
||||
mock_v1_api.sph_detail.return_value = {
|
||||
# Real-time data read by sensor entities
|
||||
"bmsSOC": 75,
|
||||
"vbat": 52.4,
|
||||
"vpv1": 380.0,
|
||||
"vpv2": 370.0,
|
||||
"vac1": 230.0,
|
||||
"pcharge1": 1200,
|
||||
"pdischarge1": 0,
|
||||
"pacToGridTotal": 0.5,
|
||||
"pacToUserR": 0.2,
|
||||
"fac": 50.0,
|
||||
"temp1": 35.0,
|
||||
"temp2": 36.0,
|
||||
"temp3": 34.0,
|
||||
"temp4": 33.0,
|
||||
"temp5": 32.0,
|
||||
# Charge settings (also used by sph_read_ac_charge_times)
|
||||
"chargePowerCommand": 100,
|
||||
"wchargeSOCLowLimit": 90,
|
||||
"acChargeEnable": 1,
|
||||
"forcedChargeTimeStart1": "01:00",
|
||||
"forcedChargeTimeStop1": "05:00",
|
||||
"forcedChargeStopSwitch1": 1,
|
||||
"forcedChargeTimeStart2": "00:00",
|
||||
"forcedChargeTimeStop2": "00:00",
|
||||
"forcedChargeStopSwitch2": 0,
|
||||
"forcedChargeTimeStart3": "00:00",
|
||||
"forcedChargeTimeStop3": "00:00",
|
||||
"forcedChargeStopSwitch3": 0,
|
||||
# Discharge settings (also used by sph_read_ac_discharge_times)
|
||||
"disChargePowerCommand": 100,
|
||||
"wdisChargeSOCLowLimit": 10,
|
||||
"forcedDischargeTimeStart1": "10:00",
|
||||
"forcedDischargeTimeStop1": "16:00",
|
||||
"forcedDischargeStopSwitch1": 1,
|
||||
"forcedDischargeTimeStart2": "00:00",
|
||||
"forcedDischargeTimeStop2": "00:00",
|
||||
"forcedDischargeStopSwitch2": 0,
|
||||
"forcedDischargeTimeStart3": "00:00",
|
||||
"forcedDischargeTimeStop3": "00:00",
|
||||
"forcedDischargeStopSwitch3": 0,
|
||||
}
|
||||
|
||||
# Called by SPH device coordinator during refresh
|
||||
mock_v1_api.sph_energy.return_value = {
|
||||
"ppv1": 800,
|
||||
"ppv2": 700,
|
||||
"ppv": 1500,
|
||||
"echarge1Today": 5.0,
|
||||
"echarge1Total": 120.0,
|
||||
"edischarge1Today": 3.0,
|
||||
"edischarge1Total": 90.0,
|
||||
"epvtoday": 8.0,
|
||||
"epvTotal": 2000.0,
|
||||
"esystemtoday": 10.0,
|
||||
"eselfToday": 7.5,
|
||||
"etoUserToday": 1.5,
|
||||
"etoGridToday": 2.0,
|
||||
"etogridTotal": 500.0,
|
||||
"elocalLoadToday": 9.0,
|
||||
"elocalLoadTotal": 1800.0,
|
||||
"echarge1": 3.5,
|
||||
"eChargeToday": 4.5,
|
||||
"time": "2024-01-15 12:30:00",
|
||||
}
|
||||
|
||||
# Called by read_ac_charge_times service (returns parsed data from cache)
|
||||
mock_v1_api.sph_read_ac_charge_times.return_value = {
|
||||
"charge_power": 100,
|
||||
"charge_stop_soc": 90,
|
||||
"mains_enabled": True,
|
||||
"periods": [
|
||||
{
|
||||
"period_id": 1,
|
||||
"start_time": "01:00",
|
||||
"end_time": "05:00",
|
||||
"enabled": True,
|
||||
},
|
||||
{
|
||||
"period_id": 2,
|
||||
"start_time": "00:00",
|
||||
"end_time": "00:00",
|
||||
"enabled": False,
|
||||
},
|
||||
{
|
||||
"period_id": 3,
|
||||
"start_time": "00:00",
|
||||
"end_time": "00:00",
|
||||
"enabled": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
# Called by read_ac_discharge_times service (returns parsed data from cache)
|
||||
mock_v1_api.sph_read_ac_discharge_times.return_value = {
|
||||
"discharge_power": 100,
|
||||
"discharge_stop_soc": 10,
|
||||
"periods": [
|
||||
{
|
||||
"period_id": 1,
|
||||
"start_time": "10:00",
|
||||
"end_time": "16:00",
|
||||
"enabled": True,
|
||||
},
|
||||
{
|
||||
"period_id": 2,
|
||||
"start_time": "00:00",
|
||||
"end_time": "00:00",
|
||||
"enabled": False,
|
||||
},
|
||||
{
|
||||
"period_id": 3,
|
||||
"start_time": "00:00",
|
||||
"end_time": "00:00",
|
||||
"enabled": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
# Called by write_ac_charge_times / write_ac_discharge_times services
|
||||
mock_v1_api.sph_write_ac_charge_times = Mock(return_value=None)
|
||||
mock_v1_api.sph_write_ac_discharge_times = Mock(return_value=None)
|
||||
|
||||
yield mock_v1_api
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,57 @@
|
||||
# serializer version: 1
|
||||
# name: test_read_ac_charge_times
|
||||
dict({
|
||||
'charge_power': 100,
|
||||
'charge_stop_soc': 90,
|
||||
'mains_enabled': True,
|
||||
'periods': list([
|
||||
dict({
|
||||
'enabled': True,
|
||||
'end_time': '05:00',
|
||||
'period_id': 1,
|
||||
'start_time': '01:00',
|
||||
}),
|
||||
dict({
|
||||
'enabled': False,
|
||||
'end_time': '00:00',
|
||||
'period_id': 2,
|
||||
'start_time': '00:00',
|
||||
}),
|
||||
dict({
|
||||
'enabled': False,
|
||||
'end_time': '00:00',
|
||||
'period_id': 3,
|
||||
'start_time': '00:00',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
# ---
|
||||
# name: test_read_ac_discharge_times
|
||||
dict({
|
||||
'discharge_power': 100,
|
||||
'discharge_stop_soc': 10,
|
||||
'periods': list([
|
||||
dict({
|
||||
'enabled': True,
|
||||
'end_time': '16:00',
|
||||
'period_id': 1,
|
||||
'start_time': '10:00',
|
||||
}),
|
||||
dict({
|
||||
'enabled': False,
|
||||
'end_time': '00:00',
|
||||
'period_id': 2,
|
||||
'start_time': '00:00',
|
||||
}),
|
||||
dict({
|
||||
'enabled': False,
|
||||
'end_time': '00:00',
|
||||
'period_id': 3,
|
||||
'start_time': '00:00',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
# ---
|
||||
# name: test_read_time_segments_single_device
|
||||
dict({
|
||||
'time_segments': list([
|
||||
|
||||
@@ -563,8 +563,8 @@ async def test_v1_api_unsupported_device_type(
|
||||
# Return mix of MIN (type 7) and other device types
|
||||
mock_growatt_v1_api.device_list.return_value = {
|
||||
"devices": [
|
||||
{"device_sn": "MIN123456", "type": 7}, # Supported
|
||||
{"device_sn": "TLX789012", "type": 5}, # Unsupported
|
||||
{"device_sn": "MIN123456", "type": 7}, # Supported (MIN)
|
||||
{"device_sn": "UNK999999", "type": 3}, # Unsupported
|
||||
]
|
||||
}
|
||||
|
||||
@@ -572,7 +572,7 @@ async def test_v1_api_unsupported_device_type(
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
# Verify warning was logged for unsupported device
|
||||
assert "Device TLX789012 with type 5 not supported in Open API V1" in caplog.text
|
||||
assert "Device UNK999999 with type 3 not supported in Open API V1" in caplog.text
|
||||
|
||||
|
||||
async def test_migrate_version_bump(
|
||||
|
||||
@@ -17,6 +17,57 @@ from . import setup_integration
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
@pytest.mark.freeze_time("2024-01-15 12:30:00")
|
||||
async def test_sph_sensors_v1_api(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test SPH device sensor entities with V1 API."""
|
||||
mock_growatt_v1_api.device_list.return_value = {
|
||||
"devices": [{"device_sn": "SPH123456", "type": 5}]
|
||||
}
|
||||
|
||||
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
async def test_sph_sensor_unavailable_on_coordinator_error(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test SPH sensors become unavailable when coordinator fails."""
|
||||
mock_growatt_v1_api.device_list.return_value = {
|
||||
"devices": [{"device_sn": "SPH123456", "type": 5}]
|
||||
}
|
||||
|
||||
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("sensor.sph123456_state_of_charge")
|
||||
assert state is not None
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
mock_growatt_v1_api.sph_detail.side_effect = growattServer.GrowattV1ApiError(
|
||||
"Connection timeout"
|
||||
)
|
||||
|
||||
freezer.tick(timedelta(minutes=5))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
state = hass.states.get("sensor.sph123456_state_of_charge")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_min_sensors_v1_api(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test Growatt Server services."""
|
||||
|
||||
import datetime as dt
|
||||
from unittest.mock import patch
|
||||
|
||||
import growattServer
|
||||
@@ -675,3 +676,487 @@ async def test_service_with_unloaded_config_entry(
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPH device service tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _setup_sph_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry,
|
||||
mock_growatt_v1_api,
|
||||
) -> None:
|
||||
"""Set up the integration with a single SPH device."""
|
||||
mock_growatt_v1_api.device_list.return_value = {
|
||||
"devices": [{"device_sn": "SPH123456", "type": 5}]
|
||||
}
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_growatt_v1_api")
|
||||
async def test_read_ac_charge_times(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_growatt_v1_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test reading AC charge times from SPH device."""
|
||||
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
|
||||
assert device_entry is not None
|
||||
|
||||
response = await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"read_ac_charge_times",
|
||||
{"device_id": device_entry.id},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert response == snapshot
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_growatt_v1_api")
|
||||
async def test_read_ac_discharge_times(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_growatt_v1_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test reading AC discharge times from SPH device."""
|
||||
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
|
||||
assert device_entry is not None
|
||||
|
||||
response = await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"read_ac_discharge_times",
|
||||
{"device_id": device_entry.id},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert response == snapshot
|
||||
|
||||
|
||||
async def test_write_ac_charge_times(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_growatt_v1_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test writing AC charge times to SPH device."""
|
||||
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
|
||||
assert device_entry is not None
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"write_ac_charge_times",
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"charge_power": 80,
|
||||
"charge_stop_soc": 95,
|
||||
"mains_enabled": True,
|
||||
"period_1_start": "02:00",
|
||||
"period_1_end": "06:00",
|
||||
"period_1_enabled": True,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_growatt_v1_api.sph_write_ac_charge_times.assert_called_once()
|
||||
|
||||
|
||||
async def test_write_ac_charge_times_with_seconds_format(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_growatt_v1_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test writing AC charge times with HH:MM:SS format from UI time selector."""
|
||||
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
|
||||
assert device_entry is not None
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"write_ac_charge_times",
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"charge_power": 100,
|
||||
"charge_stop_soc": 90,
|
||||
"mains_enabled": False,
|
||||
"period_1_start": "02:00:00",
|
||||
"period_1_end": "06:00:00",
|
||||
"period_1_enabled": True,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_growatt_v1_api.sph_write_ac_charge_times.assert_called_once()
|
||||
|
||||
|
||||
async def test_write_ac_discharge_times(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_growatt_v1_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test writing AC discharge times to SPH device."""
|
||||
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
|
||||
assert device_entry is not None
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"write_ac_discharge_times",
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"discharge_power": 75,
|
||||
"discharge_stop_soc": 20,
|
||||
"period_1_start": "11:00",
|
||||
"period_1_end": "15:00",
|
||||
"period_1_enabled": True,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_growatt_v1_api.sph_write_ac_discharge_times.assert_called_once()
|
||||
|
||||
|
||||
async def test_write_ac_charge_times_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_growatt_v1_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test handling API error when writing AC charge times."""
|
||||
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
|
||||
assert device_entry is not None
|
||||
|
||||
mock_growatt_v1_api.sph_write_ac_charge_times.side_effect = (
|
||||
growattServer.GrowattV1ApiError("Write failed", error_code=1, error_msg="Error")
|
||||
)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="API error updating AC charge times"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"write_ac_charge_times",
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"charge_power": 100,
|
||||
"charge_stop_soc": 90,
|
||||
"mains_enabled": True,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_write_ac_discharge_times_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_growatt_v1_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test handling API error when writing AC discharge times."""
|
||||
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
|
||||
assert device_entry is not None
|
||||
|
||||
mock_growatt_v1_api.sph_write_ac_discharge_times.side_effect = (
|
||||
growattServer.GrowattV1ApiError("Write failed", error_code=1, error_msg="Error")
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError, match="API error updating AC discharge times"
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"write_ac_discharge_times",
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"discharge_power": 100,
|
||||
"discharge_stop_soc": 10,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_write_ac_charge_times_invalid_charge_power(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_growatt_v1_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test validation of charge_power range."""
|
||||
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
|
||||
assert device_entry is not None
|
||||
|
||||
with pytest.raises(
|
||||
ServiceValidationError, match="charge_power must be between 0 and 100"
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"write_ac_charge_times",
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"charge_power": 150,
|
||||
"charge_stop_soc": 90,
|
||||
"mains_enabled": True,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_write_ac_charge_times_invalid_charge_stop_soc(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_growatt_v1_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test validation of charge_stop_soc range."""
|
||||
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
|
||||
assert device_entry is not None
|
||||
|
||||
with pytest.raises(
|
||||
ServiceValidationError, match="charge_stop_soc must be between 0 and 100"
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"write_ac_charge_times",
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"charge_power": 100,
|
||||
"charge_stop_soc": 110,
|
||||
"mains_enabled": True,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_write_ac_discharge_times_invalid_discharge_power(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_growatt_v1_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test validation of discharge_power range."""
|
||||
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
|
||||
assert device_entry is not None
|
||||
|
||||
with pytest.raises(
|
||||
ServiceValidationError, match="discharge_power must be between 0 and 100"
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"write_ac_discharge_times",
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"discharge_power": 200,
|
||||
"discharge_stop_soc": 10,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_write_ac_discharge_times_invalid_discharge_stop_soc(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_growatt_v1_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test validation of discharge_stop_soc range."""
|
||||
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
|
||||
assert device_entry is not None
|
||||
|
||||
with pytest.raises(
|
||||
ServiceValidationError, match="discharge_stop_soc must be between 0 and 100"
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"write_ac_discharge_times",
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"discharge_power": 100,
|
||||
"discharge_stop_soc": -5,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_write_ac_charge_times_invalid_period_time(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_growatt_v1_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test validation of invalid period time format."""
|
||||
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
|
||||
assert device_entry is not None
|
||||
|
||||
with pytest.raises(
|
||||
ServiceValidationError,
|
||||
match="period_1_start must be in HH:MM or HH:MM:SS format",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"write_ac_charge_times",
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"charge_power": 100,
|
||||
"charge_stop_soc": 90,
|
||||
"mains_enabled": True,
|
||||
"period_1_start": "invalid",
|
||||
"period_1_end": "06:00",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_no_sph_devices_fails_gracefully(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_classic: MockConfigEntry,
|
||||
mock_growatt_classic_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test that SPH services fail gracefully when no SPH devices exist."""
|
||||
mock_growatt_classic_api.device_list.return_value = [
|
||||
{"deviceSn": "TLX123456", "deviceType": "tlx"}
|
||||
]
|
||||
|
||||
mock_config_entry_classic.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry_classic.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.services.has_service(DOMAIN, "write_ac_charge_times")
|
||||
assert hass.services.has_service(DOMAIN, "read_ac_charge_times")
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "TLX123456")})
|
||||
assert device_entry is not None
|
||||
|
||||
with pytest.raises(
|
||||
ServiceValidationError, match="No SPH devices with token authentication"
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"write_ac_charge_times",
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"charge_power": 100,
|
||||
"charge_stop_soc": 90,
|
||||
"mains_enabled": True,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_sph_service_with_non_sph_growatt_device(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_growatt_v1_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test SPH service called with a non-SPH Growatt device."""
|
||||
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
|
||||
|
||||
# Manually register a MIN device (not SPH)
|
||||
min_device = device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
identifiers={(DOMAIN, "MIN999999")},
|
||||
name="MIN Device",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ServiceValidationError,
|
||||
match="SPH device 'MIN999999' not found or not configured for services",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"write_ac_charge_times",
|
||||
{
|
||||
"device_id": min_device.id,
|
||||
"charge_power": 100,
|
||||
"charge_stop_soc": 90,
|
||||
"mains_enabled": True,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_write_ac_charge_times_uses_cached_periods_for_unspecified(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_growatt_v1_api,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test that unspecified periods are filled from cached settings."""
|
||||
await _setup_sph_integration(hass, mock_config_entry, mock_growatt_v1_api)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SPH123456")})
|
||||
assert device_entry is not None
|
||||
|
||||
# Only override period 1; periods 2 and 3 should come from cache (all 00:00)
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"write_ac_charge_times",
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"charge_power": 100,
|
||||
"charge_stop_soc": 90,
|
||||
"mains_enabled": True,
|
||||
"period_1_start": "03:00",
|
||||
"period_1_end": "07:00",
|
||||
"period_1_enabled": True,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Verify the API was called with datetime.time objects (not strings)
|
||||
mock_growatt_v1_api.sph_write_ac_charge_times.assert_called_once_with(
|
||||
"SPH123456",
|
||||
100,
|
||||
90,
|
||||
True,
|
||||
[
|
||||
{
|
||||
"start_time": dt.time(3, 0),
|
||||
"end_time": dt.time(7, 0),
|
||||
"enabled": True,
|
||||
},
|
||||
{
|
||||
"start_time": dt.time(0, 0),
|
||||
"end_time": dt.time(0, 0),
|
||||
"enabled": False,
|
||||
},
|
||||
{
|
||||
"start_time": dt.time(0, 0),
|
||||
"end_time": dt.time(0, 0),
|
||||
"enabled": False,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@@ -84,6 +84,7 @@ def mock_addon_store_info(
|
||||
supervisor_client.store.addon_info.return_value = addon_info = Mock(
|
||||
spec=StoreAddonComplete,
|
||||
slug="test",
|
||||
name="test",
|
||||
repository="core",
|
||||
available=True,
|
||||
installed=False,
|
||||
@@ -109,15 +110,18 @@ def mock_addon_info(
|
||||
supervisor_client.addons.addon_info.return_value = addon_info = Mock(
|
||||
spec=InstalledAddonComplete,
|
||||
slug="test",
|
||||
name="test",
|
||||
repository="core",
|
||||
available=False,
|
||||
hostname="",
|
||||
options={},
|
||||
state="unknown",
|
||||
update_available=False,
|
||||
version=None,
|
||||
version="1.0.0",
|
||||
version_latest="1.0.0",
|
||||
supervisor_api=False,
|
||||
supervisor_role="default",
|
||||
icon=False,
|
||||
)
|
||||
addon_info.name = "test"
|
||||
addon_info.to_dict = MethodType(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Fixtures for Hass.io."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import replace
|
||||
import os
|
||||
import re
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from aiohasupervisor.models import AddonsStats, AddonState
|
||||
from aiohasupervisor.models import AddonsStats, AddonState, InstalledAddonComplete
|
||||
from aiohttp.test_utils import TestClient
|
||||
import pytest
|
||||
|
||||
@@ -80,6 +81,15 @@ def all_setup_requests(
|
||||
addon_changelog: AsyncMock,
|
||||
addon_stats: AsyncMock,
|
||||
jobs_info: AsyncMock,
|
||||
host_info: AsyncMock,
|
||||
supervisor_root_info: AsyncMock,
|
||||
homeassistant_info: AsyncMock,
|
||||
supervisor_info: AsyncMock,
|
||||
addons_list: AsyncMock,
|
||||
network_info: AsyncMock,
|
||||
os_info: AsyncMock,
|
||||
homeassistant_stats: AsyncMock,
|
||||
supervisor_stats: AsyncMock,
|
||||
) -> None:
|
||||
"""Mock all setup requests."""
|
||||
include_addons = hasattr(request, "param") and request.param.get(
|
||||
@@ -88,87 +98,26 @@ def all_setup_requests(
|
||||
|
||||
aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"})
|
||||
aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"})
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"supervisor": "222",
|
||||
"homeassistant": "0.110.0",
|
||||
"hassos": "1.2.3",
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/host/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"chassis": "vm",
|
||||
"operating_system": "Debian GNU/Linux 10 (buster)",
|
||||
"kernel": "4.19.0-6-amd64",
|
||||
"disk_free": 1.6,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/core/info",
|
||||
json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/os/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"version_latest": "1.0.0",
|
||||
"version": "1.0.0",
|
||||
"update_available": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/supervisor/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"result": "ok",
|
||||
"version": "1.0.0",
|
||||
"version_latest": "1.0.0",
|
||||
"auto_update": True,
|
||||
"addons": [
|
||||
{
|
||||
"name": "test",
|
||||
"slug": "test",
|
||||
"update_available": False,
|
||||
"version": "1.0.0",
|
||||
"version_latest": "1.0.0",
|
||||
"repository": "core",
|
||||
"state": "started",
|
||||
"icon": False,
|
||||
},
|
||||
{
|
||||
"name": "test2",
|
||||
"slug": "test2",
|
||||
"update_available": False,
|
||||
"version": "1.0.0",
|
||||
"version_latest": "1.0.0",
|
||||
"repository": "core",
|
||||
"state": "started",
|
||||
"icon": False,
|
||||
},
|
||||
]
|
||||
if include_addons
|
||||
else [],
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}}
|
||||
)
|
||||
|
||||
if include_addons:
|
||||
addons_list.return_value[0] = replace(
|
||||
addons_list.return_value[0],
|
||||
version="1.0.0",
|
||||
version_latest="1.0.0",
|
||||
update_available=False,
|
||||
)
|
||||
addons_list.return_value[1] = replace(
|
||||
addons_list.return_value[1],
|
||||
version="1.0.0",
|
||||
version_latest="1.0.0",
|
||||
state=AddonState.STARTED,
|
||||
)
|
||||
else:
|
||||
addons_list.return_value = []
|
||||
|
||||
addon_installed.return_value.update_available = False
|
||||
addon_installed.return_value.version = "1.0.0"
|
||||
addon_installed.return_value.version_latest = "1.0.0"
|
||||
@@ -177,56 +126,26 @@ def all_setup_requests(
|
||||
addon_installed.return_value.icon = False
|
||||
|
||||
def mock_addon_info(slug: str):
|
||||
addon = Mock(
|
||||
spec=InstalledAddonComplete,
|
||||
to_dict=addon_installed.return_value.to_dict,
|
||||
**addon_installed.return_value.to_dict(),
|
||||
)
|
||||
if slug == "test":
|
||||
addon_installed.return_value.name = "test"
|
||||
addon_installed.return_value.slug = "test"
|
||||
addon_installed.return_value.url = (
|
||||
"https://github.com/home-assistant/addons/test"
|
||||
)
|
||||
addon_installed.return_value.auto_update = True
|
||||
addon.name = "test"
|
||||
addon.slug = "test"
|
||||
addon.url = "https://github.com/home-assistant/addons/test"
|
||||
addon.auto_update = True
|
||||
else:
|
||||
addon_installed.return_value.name = "test2"
|
||||
addon_installed.return_value.slug = "test2"
|
||||
addon_installed.return_value.url = "https://github.com"
|
||||
addon_installed.return_value.auto_update = False
|
||||
addon.name = "test2"
|
||||
addon.slug = "test2"
|
||||
addon.url = "https://github.com"
|
||||
addon.auto_update = False
|
||||
|
||||
return addon_installed.return_value
|
||||
return addon
|
||||
|
||||
addon_installed.side_effect = mock_addon_info
|
||||
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/core/stats",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"cpu_percent": 0.99,
|
||||
"memory_usage": 182611968,
|
||||
"memory_limit": 3977146368,
|
||||
"memory_percent": 4.59,
|
||||
"network_rx": 362570232,
|
||||
"network_tx": 82374138,
|
||||
"blk_read": 46010945536,
|
||||
"blk_write": 15051526144,
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/supervisor/stats",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"cpu_percent": 0.99,
|
||||
"memory_usage": 182611968,
|
||||
"memory_limit": 3977146368,
|
||||
"memory_percent": 4.59,
|
||||
"network_rx": 362570232,
|
||||
"network_tx": 82374138,
|
||||
"blk_read": 46010945536,
|
||||
"blk_write": 15051526144,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async def mock_addon_stats(addon: str) -> AddonsStats:
|
||||
"""Mock addon stats for test and test2."""
|
||||
if addon == "test2":
|
||||
@@ -252,16 +171,6 @@ def all_setup_requests(
|
||||
)
|
||||
|
||||
addon_stats.side_effect = mock_addon_stats
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/network/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"host_internet": True,
|
||||
"supervisor_internet": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/jobs/info",
|
||||
|
||||
@@ -4,9 +4,10 @@ from dataclasses import replace
|
||||
from datetime import timedelta
|
||||
import os
|
||||
from pathlib import PurePath
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
from aiohasupervisor.models import AddonState, InstalledAddonComplete
|
||||
from aiohasupervisor.models.mounts import (
|
||||
CIFSMountResponse,
|
||||
MountsInfo,
|
||||
@@ -41,133 +42,51 @@ def mock_all(
|
||||
addon_stats: AsyncMock,
|
||||
resolution_info: AsyncMock,
|
||||
jobs_info: AsyncMock,
|
||||
host_info: AsyncMock,
|
||||
supervisor_root_info: AsyncMock,
|
||||
homeassistant_info: AsyncMock,
|
||||
supervisor_info: AsyncMock,
|
||||
addons_list: AsyncMock,
|
||||
network_info: AsyncMock,
|
||||
os_info: AsyncMock,
|
||||
homeassistant_stats: AsyncMock,
|
||||
supervisor_stats: AsyncMock,
|
||||
) -> None:
|
||||
"""Mock all setup requests."""
|
||||
aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"})
|
||||
aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"})
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"supervisor": "222",
|
||||
"homeassistant": "0.110.0",
|
||||
"hassos": "1.2.3",
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/host/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"chassis": "vm",
|
||||
"operating_system": "Debian GNU/Linux 10 (buster)",
|
||||
"kernel": "4.19.0-6-amd64",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/core/info",
|
||||
json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/os/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"version_latest": "1.0.0",
|
||||
"version": "1.0.0",
|
||||
"update_available": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/supervisor/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"result": "ok",
|
||||
"version": "1.0.0",
|
||||
"version_latest": "1.0.0",
|
||||
"auto_update": True,
|
||||
"addons": [
|
||||
{
|
||||
"name": "test",
|
||||
"state": "started",
|
||||
"slug": "test",
|
||||
"installed": True,
|
||||
"update_available": True,
|
||||
"version": "2.0.0",
|
||||
"version_latest": "2.0.1",
|
||||
"repository": "core",
|
||||
"url": "https://github.com/home-assistant/addons/test",
|
||||
"icon": False,
|
||||
},
|
||||
{
|
||||
"name": "test2",
|
||||
"state": "stopped",
|
||||
"slug": "test2",
|
||||
"installed": True,
|
||||
"update_available": False,
|
||||
"version": "3.1.0",
|
||||
"version_latest": "3.1.0",
|
||||
"repository": "core",
|
||||
"url": "https://github.com",
|
||||
"icon": False,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/core/stats",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"cpu_percent": 0.99,
|
||||
"memory_usage": 182611968,
|
||||
"memory_limit": 3977146368,
|
||||
"memory_percent": 4.59,
|
||||
"network_rx": 362570232,
|
||||
"network_tx": 82374138,
|
||||
"blk_read": 46010945536,
|
||||
"blk_write": 15051526144,
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/supervisor/stats",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"cpu_percent": 0.99,
|
||||
"memory_usage": 182611968,
|
||||
"memory_limit": 3977146368,
|
||||
"memory_percent": 4.59,
|
||||
"network_rx": 362570232,
|
||||
"network_tx": 82374138,
|
||||
"blk_read": 46010945536,
|
||||
"blk_write": 15051526144,
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}}
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/network/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"host_internet": True,
|
||||
"supervisor_internet": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def mock_addon_info(slug: str):
|
||||
addon = Mock(
|
||||
spec=InstalledAddonComplete,
|
||||
to_dict=addon_installed.return_value.to_dict,
|
||||
**addon_installed.return_value.to_dict(),
|
||||
)
|
||||
if slug == "test":
|
||||
addon.name = "test"
|
||||
addon.slug = "test"
|
||||
addon.version = "2.0.0"
|
||||
addon.version_latest = "2.0.1"
|
||||
addon.update_available = True
|
||||
addon.state = AddonState.STARTED
|
||||
addon.url = "https://github.com/home-assistant/addons/test"
|
||||
addon.auto_update = True
|
||||
else:
|
||||
addon.name = "test2"
|
||||
addon.slug = "test2"
|
||||
addon.version = "3.1.0"
|
||||
addon.version_latest = "3.1.0"
|
||||
addon.update_available = False
|
||||
addon.state = AddonState.STOPPED
|
||||
addon.url = "https://github.com"
|
||||
addon.auto_update = False
|
||||
|
||||
return addon
|
||||
|
||||
addon_installed.side_effect = mock_addon_info
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test websocket API."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
@@ -28,77 +29,24 @@ def mock_all(
|
||||
supervisor_is_connected: AsyncMock,
|
||||
resolution_info: AsyncMock,
|
||||
addon_info: AsyncMock,
|
||||
host_info: AsyncMock,
|
||||
supervisor_root_info: AsyncMock,
|
||||
homeassistant_info: AsyncMock,
|
||||
supervisor_info: AsyncMock,
|
||||
addons_list: AsyncMock,
|
||||
network_info: AsyncMock,
|
||||
os_info: AsyncMock,
|
||||
) -> None:
|
||||
"""Mock all setup requests."""
|
||||
aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"})
|
||||
aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"})
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {"supervisor": "222", "homeassistant": "0.110.0", "hassos": None},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/host/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"chassis": "vm",
|
||||
"operating_system": "Debian GNU/Linux 10 (buster)",
|
||||
"kernel": "4.19.0-6-amd64",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/core/info",
|
||||
json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/os/info",
|
||||
json={"result": "ok", "data": {"version_latest": "1.0.0"}},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/supervisor/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"version": "1.0.0",
|
||||
"version_latest": "1.0.0",
|
||||
"auto_update": True,
|
||||
"addons": [
|
||||
{
|
||||
"name": "test",
|
||||
"state": "started",
|
||||
"slug": "test",
|
||||
"installed": True,
|
||||
"update_available": True,
|
||||
"icon": False,
|
||||
"version": "2.0.0",
|
||||
"version_latest": "2.0.1",
|
||||
"repository": "core",
|
||||
"url": "https://github.com/home-assistant/addons/test",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
supervisor_root_info.return_value = replace(
|
||||
supervisor_root_info.return_value, hassos=None
|
||||
)
|
||||
addons_list.return_value.pop(1)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}}
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/network/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"host_internet": True,
|
||||
"supervisor_internet": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user