Compare commits

..

34 Commits

Author SHA1 Message Date
Erik
7641fbecfb Adjust after rebase 2026-03-17 07:26:51 +01:00
Erik
45810ba958 Adjust after rebase 2026-03-17 07:22:35 +01:00
Erik
5470d7fb81 Add temperature triggers 2026-03-17 07:22:35 +01:00
Mike Degatano
39b44445ec Use aiohasupervisor for all calls from hassio/coordinator (#164413)
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-03-17 01:06:56 +01:00
Robert Resch
589622c05a Fix pterodactyl tests (#165745) 2026-03-16 23:44:26 +01:00
Brett Adams
6abe576ec9 Platinum quality for Teslemetry (#165727) 2026-03-16 22:31:17 +00:00
Robert Resch
75978d8837 Fix demo tests for Python 3.14.3 (#165724) 2026-03-16 22:52:04 +01:00
Robert Resch
a2da13a0b3 Fix kitchen_sink tests for Python 3.14.3 (#165730) 2026-03-16 22:45:36 +01:00
Robert Resch
ce081d7e71 Fix local_file tests for Python 3.14.3 (#165731) 2026-03-16 22:45:15 +01:00
Robert Resch
037e123e11 Fix media_player tests for Python 3.14.3 (#165732) 2026-03-16 22:44:52 +01:00
Robert Resch
592b7e5594 Fix wake_on_lan tests for Python 3.14.3 (#165733) 2026-03-16 22:44:23 +01:00
Cyril MARIN
a963eed3a7 Add bearer token as optional setting to Ollama (#165325)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-16 22:14:33 +01:00
Devin Slick
2042f2e2bd Add Lojack integration (#162047)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-16 22:09:10 +01:00
mettolen
3580fab26e Initialize quality scale for Huum integration (#164902) 2026-03-16 22:08:43 +01:00
Matt Zimmerman
1817522107 Clean up SmartTub integration and tests (#165517)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-16 22:06:23 +01:00
Matt Zimmerman
98a9ce3a64 Add quality scale file for SmartTub integration (#162376)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 21:48:09 +01:00
johanzander
163bfb0fdd Add SPH inverter support to Growatt Server integration (#165314)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 21:46:48 +01:00
Jeff Terrace
66f04c702c Update onvif parsers library to latest parsing multiple (#165571)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-16 21:40:37 +01:00
Khole
41c497c49e Hive: Fix bug in config flow for authentication and device registration (#165061) 2026-03-16 21:07:34 +01:00
Ludovic BOUÉ
c25a664365 Fix Matter firmware update detection when version strings are identical (#165509) 2026-03-16 21:07:03 +01:00
Raj Laud
3dec70abce Add AC charger sensor support to victron_ble (#165497)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 20:59:30 +01:00
Robert Resch
3c2f696a23 Improve type hints for pilight (#165719) 2026-03-16 20:55:04 +01:00
Nathan Spencer
54745dc1f2 Remove stale devices at setup in Whisker (#165721) 2026-03-16 20:54:02 +01:00
Raj Laud
e4345c72d9 Fix SmartLithium 8-cell support in victron_ble (#165496)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 20:49:43 +01:00
J. Diego Rodríguez Royo
7acb253ae2 Add bread baking and dough proving programs to Home Connect (#165717) 2026-03-16 20:47:20 +01:00
J. Diego Rodríguez Royo
812c63eeb7 Bump aiohomeconnect to 0.32.0 (#165716) 2026-03-16 20:46:22 +01:00
Erwin Douna
7f13731035 Start orphaned entries in normal mode only (#164815)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-16 20:45:33 +01:00
Christian Lackas
879178e8a2 Add light support for HmIP-MP3P (Combination Signalling Device) (#162825) 2026-03-16 20:43:36 +01:00
Brett Adams
4d8cedb061 Add dynamic device discovery for Teslemetry (#162143)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Erwin Douna <e.douna@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-16 20:31:05 +01:00
Christian Lackas
e9f0d8a550 vicare: Remove heating type config, defaulting to auto-detection (#165649) 2026-03-16 20:26:02 +01:00
Joost Lekkerkerker
c5a04deb28 Add integration type to Orvibo (#165706) 2026-03-16 20:04:59 +01:00
Bouwe Westerdijk
f2a205e8d7 Improve Plugwise DataUpdateCoordinator (#165715)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-16 20:04:55 +01:00
prana-dev-official
254aa30ad8 Add sensor platform to prana (#165632) 2026-03-16 20:03:36 +01:00
J. Diego Rodríguez Royo
de4025634a Add start selected program action to Home Connect (#165362)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-16 20:03:20 +01:00
162 changed files with 12111 additions and 2173 deletions

View File

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

@@ -974,6 +974,8 @@ build.json @home-assistant/supervisor
/tests/components/logbook/ @home-assistant/core
/homeassistant/components/logger/ @home-assistant/core
/tests/components/logger/ @home-assistant/core
/homeassistant/components/lojack/ @devinslick
/tests/components/lojack/ @devinslick
/homeassistant/components/london_underground/ @jpbede
/tests/components/london_underground/ @jpbede
/homeassistant/components/lookin/ @ANMalko @bdraco
@@ -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

View File

@@ -247,6 +247,7 @@ DEFAULT_INTEGRATIONS = {
"humidity",
"motion",
"occupancy",
"temperature",
"window",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {

View File

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

View File

@@ -160,6 +160,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"schedule",
"siren",
"switch",
"temperature",
"text",
"update",
"vacuum",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -261,8 +261,10 @@
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
@@ -615,8 +617,10 @@
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
@@ -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"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
{
"triggers": {
"changed": {
"trigger": "mdi:thermometer"
},
"crossed_threshold": {
"trigger": "mdi:thermometer"
}
}
}

View 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"
}

View 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"
}
}
}

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3828,6 +3828,12 @@
}
}
},
"lojack": {
"name": "LoJack",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"london_air": {
"name": "London Air",
"integration_type": "hub",
@@ -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
View File

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

View File

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

View File

@@ -55,7 +55,6 @@ MISSING_INTEGRATION_TYPE = {
"ness_alarm",
"nmap_tracker",
"otp",
"orvibo",
"profiler",
"proximity",
"rhasspy",

View File

@@ -120,6 +120,7 @@ NO_IOT_CLASS = [
"system_health",
"system_log",
"tag",
"temperature",
"timer",
"trace",
"web_rtc",

View File

@@ -469,7 +469,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"huisbaasje",
"hunterdouglas_powerview",
"husqvarna_automower_ble",
"huum",
"hvv_departures",
"hydrawise",
"hyperion",
@@ -869,7 +868,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"sma",
"smappee",
"smart_meter_texas",
"smarttub",
"smarty",
"smhi",
"sms",
@@ -1455,7 +1453,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"huisbaasje",
"hunterdouglas_powerview",
"husqvarna_automower_ble",
"huum",
"hvv_departures",
"hydrawise",
"hyperion",
@@ -2153,6 +2150,7 @@ NO_QUALITY_SCALE = [
"system_health",
"system_log",
"tag",
"temperature",
"timer",
"trace",
"usage_prediction",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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