Compare commits

..

29 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
5aa32491c8 Merge branch 'dev' into homevolt 2026-01-15 16:25:46 +01:00
epenet
1293e7ed70 Improve type hints in mfi (#160985) 2026-01-15 10:51:32 +01:00
Josef Zweck
3e81cea99f Add descriptions to openai_conversation (#160979)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-01-15 10:40:52 +01:00
Josef Zweck
4ce2dae701 Bump onedrive-personal-sdk to 0.1.0 (#160976) 2026-01-15 10:39:22 +01:00
epenet
a14a8c4e43 Mark last_reset and state_class type hints as compulsory in sensor platform (#160982) 2026-01-15 10:38:34 +01:00
epenet
89e734d2de Improve type hints in ebusd (#160984) 2026-01-15 10:30:58 +01:00
Brett Adams
26c81f29e9 Teslemetry: Add OAuth error handling guards (#160968)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-01-15 10:28:11 +01:00
Niracler
ce82e88919 Bump PySrDaliGateway from 0.18.0 to 0.19.3 (#160972)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-01-15 09:44:39 +01:00
Erik Montnemery
60316a1232 Deduplicate light condition descriptions (#160977) 2026-01-15 09:14:28 +01:00
Erik Montnemery
aca4d3c5e6 Fix stale and misleading docstrings in alarm_control_panel.trigger (#160978) 2026-01-15 09:08:24 +01:00
epenet
9a93096e4b Move utility_meter service definitions (#160980) 2026-01-15 09:06:18 +01:00
Daniel Hjelseth Høyer
dc2cd2246b Merge branch 'dev' into homevolt 2026-01-15 07:06:51 +01:00
Daniel Hjelseth Høyer
181037820b Merge branch 'dev' into homevolt 2026-01-14 21:05:45 +01:00
Daniel Hjelseth Høyer
6cf15bf70c homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 19:09:37 +01:00
Daniel Hjelseth Høyer
5a34c31e42 Merge branch 'dev' into homevolt 2026-01-14 18:30:20 +01:00
Daniel Hjelseth Høyer
9dcc86f12e homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 18:03:21 +01:00
Daniel Hjelseth Høyer
04429a6eef homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 17:40:51 +01:00
Daniel Hjelseth Høyer
51e2506afb homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 16:41:08 +01:00
Daniel Hjelseth Høyer
e49e5c7c40 Merge branch 'dev' into homevolt 2026-01-14 14:41:26 +01:00
Daniel Hjelseth Høyer
b8dfc523da homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 14:36:43 +01:00
Daniel Hjelseth Høyer
a25fbf57ef Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 17:20:27 +01:00
Daniel Hjelseth Høyer
dac22002b0 Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 14:53:07 +01:00
Daniel Hjelseth Høyer
e61f00a3ae Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 14:15:56 +01:00
Daniel Hjelseth Høyer
14a67c6b5d Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:46:49 +01:00
Daniel Hjelseth Høyer
90ae81f02b Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:39:46 +01:00
Daniel Hjelseth Høyer
a741f214da Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:35:53 +01:00
Daniel Hjelseth Høyer
21d0bd3ce2 Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:22:32 +01:00
Daniel Hjelseth Høyer
d9c1f4850a Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:09:50 +01:00
Daniel Hjelseth Høyer
335994af7e Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 09:44:06 +01:00
48 changed files with 1562 additions and 522 deletions

2
CODEOWNERS generated
View File

@@ -711,6 +711,8 @@ build.json @home-assistant/supervisor
/tests/components/homematic/ @pvizeli
/homeassistant/components/homematicip_cloud/ @hahn-th
/tests/components/homematicip_cloud/ @hahn-th
/homeassistant/components/homevolt/ @danielhiversen
/tests/components/homevolt/ @danielhiversen
/homeassistant/components/homewizard/ @DCSBL
/tests/components/homewizard/ @DCSBL
/homeassistant/components/honeywell/ @rdfurman @mkmer

View File

@@ -14,7 +14,7 @@ from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelStat
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
"""Get the device class of an entity or UNDEFINED if not found."""
"""Test if an entity supports the specified features."""
try:
return bool(get_supported_features(hass, entity_id) & features)
except HomeAssistantError:
@@ -39,7 +39,7 @@ class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
def make_entity_state_trigger_required_features(
domain: str, to_state: str, required_features: int
) -> type[EntityTargetStateTriggerBase]:
"""Create an entity state trigger class."""
"""Create an entity state trigger class with required feature filtering."""
class CustomTrigger(EntityStateTriggerRequiredFeatures):
"""Trigger for entity state changes."""

View File

@@ -125,7 +125,6 @@ NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
_EXPERIMENTAL_CONDITION_PLATFORMS = {
"fan",
"light",
"switch",
}
_EXPERIMENTAL_TRIGGER_PLATFORMS = {

View File

@@ -1,5 +1,7 @@
"""BleBox sensor entities."""
from datetime import datetime
import blebox_uniapi.sensor
from homeassistant.components.sensor import (
@@ -146,7 +148,7 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
return self._feature.native_value
@property
def last_reset(self):
def last_reset(self) -> datetime | None:
"""Return the time when the sensor was last reset, if implemented."""
native_implementation = getattr(self._feature, "last_reset", None)

View File

@@ -1,6 +1,7 @@
"""Support for Ebusd daemon for communication with eBUS heating systems."""
import logging
from typing import Any
import ebusdpy
import voluptuous as vol
@@ -17,7 +18,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, SENSOR_TYPES
from .const import DOMAIN, EBUSD_DATA, SENSOR_TYPES
_LOGGER = logging.getLogger(__name__)
@@ -28,9 +29,9 @@ CACHE_TTL = 900
SERVICE_EBUSD_WRITE = "ebusd_write"
def verify_ebusd_config(config):
def verify_ebusd_config(config: ConfigType) -> ConfigType:
"""Verify eBusd config."""
circuit = config[CONF_CIRCUIT]
circuit: str = config[CONF_CIRCUIT]
for condition in config[CONF_MONITORED_CONDITIONS]:
if condition not in SENSOR_TYPES[circuit]:
raise vol.Invalid(f"Condition '{condition}' not in '{circuit}'.")
@@ -59,17 +60,17 @@ CONFIG_SCHEMA = vol.Schema(
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the eBusd component."""
_LOGGER.debug("Integration setup started")
conf = config[DOMAIN]
name = conf[CONF_NAME]
circuit = conf[CONF_CIRCUIT]
monitored_conditions = conf.get(CONF_MONITORED_CONDITIONS)
server_address = (conf.get(CONF_HOST), conf.get(CONF_PORT))
conf: ConfigType = config[DOMAIN]
name: str = conf[CONF_NAME]
circuit: str = conf[CONF_CIRCUIT]
monitored_conditions: list[str] = conf[CONF_MONITORED_CONDITIONS]
server_address: tuple[str, int] = (conf[CONF_HOST], conf[CONF_PORT])
try:
ebusdpy.init(server_address)
except (TimeoutError, OSError):
return False
hass.data[DOMAIN] = EbusdData(server_address, circuit)
hass.data[EBUSD_DATA] = EbusdData(server_address, circuit)
sensor_config = {
CONF_MONITORED_CONDITIONS: monitored_conditions,
"client_name": name,
@@ -77,7 +78,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
}
load_platform(hass, Platform.SENSOR, DOMAIN, sensor_config, config)
hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[DOMAIN].write)
hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[EBUSD_DATA].write)
_LOGGER.debug("Ebusd integration setup completed")
return True
@@ -86,13 +87,13 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
class EbusdData:
"""Get the latest data from Ebusd."""
def __init__(self, address, circuit):
def __init__(self, address: tuple[str, int], circuit: str) -> None:
"""Initialize the data object."""
self._circuit = circuit
self._address = address
self.value = {}
self.value: dict[str, Any] = {}
def update(self, name, stype):
def update(self, name: str, stype: int) -> None:
"""Call the Ebusd API to update the data."""
try:
_LOGGER.debug("Opening socket to ebusd %s", name)

View File

@@ -1,5 +1,9 @@
"""Constants for ebus component."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import (
PERCENTAGE,
@@ -8,277 +12,283 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import EbusdData
DOMAIN = "ebusd"
EBUSD_DATA: HassKey[EbusdData] = HassKey(DOMAIN)
# SensorTypes from ebusdpy module :
# 0='decimal', 1='time-schedule', 2='switch', 3='string', 4='value;status'
SENSOR_TYPES = {
type SensorSpecs = tuple[str, str | None, str | None, int, SensorDeviceClass | None]
SENSOR_TYPES: dict[str, dict[str, SensorSpecs]] = {
"700": {
"ActualFlowTemperatureDesired": [
"ActualFlowTemperatureDesired": (
"Hc1ActualFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"MaxFlowTemperatureDesired": [
),
"MaxFlowTemperatureDesired": (
"Hc1MaxFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"MinFlowTemperatureDesired": [
),
"MinFlowTemperatureDesired": (
"Hc1MinFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"PumpStatus": ["Hc1PumpStatus", None, "mdi:toggle-switch", 2, None],
"HCSummerTemperatureLimit": [
),
"PumpStatus": ("Hc1PumpStatus", None, "mdi:toggle-switch", 2, None),
"HCSummerTemperatureLimit": (
"Hc1SummerTempLimit",
UnitOfTemperature.CELSIUS,
"mdi:weather-sunny",
0,
SensorDeviceClass.TEMPERATURE,
],
"HolidayTemperature": [
),
"HolidayTemperature": (
"HolidayTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"HWTemperatureDesired": [
),
"HWTemperatureDesired": (
"HwcTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"HWActualTemperature": [
),
"HWActualTemperature": (
"HwcStorageTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"HWTimerMonday": ["hwcTimer.Monday", None, "mdi:timer-outline", 1, None],
"HWTimerTuesday": ["hwcTimer.Tuesday", None, "mdi:timer-outline", 1, None],
"HWTimerWednesday": ["hwcTimer.Wednesday", None, "mdi:timer-outline", 1, None],
"HWTimerThursday": ["hwcTimer.Thursday", None, "mdi:timer-outline", 1, None],
"HWTimerFriday": ["hwcTimer.Friday", None, "mdi:timer-outline", 1, None],
"HWTimerSaturday": ["hwcTimer.Saturday", None, "mdi:timer-outline", 1, None],
"HWTimerSunday": ["hwcTimer.Sunday", None, "mdi:timer-outline", 1, None],
"HWOperativeMode": ["HwcOpMode", None, "mdi:math-compass", 3, None],
"WaterPressure": [
),
"HWTimerMonday": ("hwcTimer.Monday", None, "mdi:timer-outline", 1, None),
"HWTimerTuesday": ("hwcTimer.Tuesday", None, "mdi:timer-outline", 1, None),
"HWTimerWednesday": ("hwcTimer.Wednesday", None, "mdi:timer-outline", 1, None),
"HWTimerThursday": ("hwcTimer.Thursday", None, "mdi:timer-outline", 1, None),
"HWTimerFriday": ("hwcTimer.Friday", None, "mdi:timer-outline", 1, None),
"HWTimerSaturday": ("hwcTimer.Saturday", None, "mdi:timer-outline", 1, None),
"HWTimerSunday": ("hwcTimer.Sunday", None, "mdi:timer-outline", 1, None),
"HWOperativeMode": ("HwcOpMode", None, "mdi:math-compass", 3, None),
"WaterPressure": (
"WaterPressure",
UnitOfPressure.BAR,
"mdi:water-pump",
0,
SensorDeviceClass.PRESSURE,
],
"Zone1RoomZoneMapping": ["z1RoomZoneMapping", None, "mdi:label", 0, None],
"Zone1NightTemperature": [
),
"Zone1RoomZoneMapping": ("z1RoomZoneMapping", None, "mdi:label", 0, None),
"Zone1NightTemperature": (
"z1NightTemp",
UnitOfTemperature.CELSIUS,
"mdi:weather-night",
0,
SensorDeviceClass.TEMPERATURE,
],
"Zone1DayTemperature": [
),
"Zone1DayTemperature": (
"z1DayTemp",
UnitOfTemperature.CELSIUS,
"mdi:weather-sunny",
0,
SensorDeviceClass.TEMPERATURE,
],
"Zone1HolidayTemperature": [
),
"Zone1HolidayTemperature": (
"z1HolidayTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"Zone1RoomTemperature": [
),
"Zone1RoomTemperature": (
"z1RoomTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"Zone1ActualRoomTemperatureDesired": [
),
"Zone1ActualRoomTemperatureDesired": (
"z1ActualRoomTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"Zone1TimerMonday": ["z1Timer.Monday", None, "mdi:timer-outline", 1, None],
"Zone1TimerTuesday": ["z1Timer.Tuesday", None, "mdi:timer-outline", 1, None],
"Zone1TimerWednesday": [
),
"Zone1TimerMonday": ("z1Timer.Monday", None, "mdi:timer-outline", 1, None),
"Zone1TimerTuesday": ("z1Timer.Tuesday", None, "mdi:timer-outline", 1, None),
"Zone1TimerWednesday": (
"z1Timer.Wednesday",
None,
"mdi:timer-outline",
1,
None,
],
"Zone1TimerThursday": ["z1Timer.Thursday", None, "mdi:timer-outline", 1, None],
"Zone1TimerFriday": ["z1Timer.Friday", None, "mdi:timer-outline", 1, None],
"Zone1TimerSaturday": ["z1Timer.Saturday", None, "mdi:timer-outline", 1, None],
"Zone1TimerSunday": ["z1Timer.Sunday", None, "mdi:timer-outline", 1, None],
"Zone1OperativeMode": ["z1OpMode", None, "mdi:math-compass", 3, None],
"ContinuosHeating": [
),
"Zone1TimerThursday": ("z1Timer.Thursday", None, "mdi:timer-outline", 1, None),
"Zone1TimerFriday": ("z1Timer.Friday", None, "mdi:timer-outline", 1, None),
"Zone1TimerSaturday": ("z1Timer.Saturday", None, "mdi:timer-outline", 1, None),
"Zone1TimerSunday": ("z1Timer.Sunday", None, "mdi:timer-outline", 1, None),
"Zone1OperativeMode": ("z1OpMode", None, "mdi:math-compass", 3, None),
"ContinuosHeating": (
"ContinuosHeating",
UnitOfTemperature.CELSIUS,
"mdi:weather-snowy",
0,
SensorDeviceClass.TEMPERATURE,
],
"PowerEnergyConsumptionLastMonth": [
),
"PowerEnergyConsumptionLastMonth": (
"PrEnergySumHcLastMonth",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
],
"PowerEnergyConsumptionThisMonth": [
),
"PowerEnergyConsumptionThisMonth": (
"PrEnergySumHcThisMonth",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
],
),
},
"ehp": {
"HWTemperature": [
"HWTemperature": (
"HwcTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
],
"OutsideTemp": [
),
"OutsideTemp": (
"OutsideTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
],
),
},
"bai": {
"HotWaterTemperature": [
"HotWaterTemperature": (
"HwcTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
],
"StorageTemperature": [
),
"StorageTemperature": (
"StorageTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
],
"DesiredStorageTemperature": [
),
"DesiredStorageTemperature": (
"StorageTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"OutdoorsTemperature": [
),
"OutdoorsTemperature": (
"OutdoorstempSensor",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
],
"WaterPressure": [
),
"WaterPressure": (
"WaterPressure",
UnitOfPressure.BAR,
"mdi:pipe",
4,
SensorDeviceClass.PRESSURE,
],
"AverageIgnitionTime": [
),
"AverageIgnitionTime": (
"averageIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
],
"MaximumIgnitionTime": [
),
"MaximumIgnitionTime": (
"maxIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
],
"MinimumIgnitionTime": [
),
"MinimumIgnitionTime": (
"minIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
],
"ReturnTemperature": [
),
"ReturnTemperature": (
"ReturnTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
],
"CentralHeatingPump": ["WP", None, "mdi:toggle-switch", 2, None],
"HeatingSwitch": ["HeatingSwitch", None, "mdi:toggle-switch", 2, None],
"DesiredFlowTemperature": [
),
"CentralHeatingPump": ("WP", None, "mdi:toggle-switch", 2, None),
"HeatingSwitch": ("HeatingSwitch", None, "mdi:toggle-switch", 2, None),
"DesiredFlowTemperature": (
"FlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"FlowTemperature": [
),
"FlowTemperature": (
"FlowTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
],
"Flame": ["Flame", None, "mdi:toggle-switch", 2, None],
"PowerEnergyConsumptionHeatingCircuit": [
),
"Flame": ("Flame", None, "mdi:toggle-switch", 2, None),
"PowerEnergyConsumptionHeatingCircuit": (
"PrEnergySumHc1",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
],
"PowerEnergyConsumptionHotWaterCircuit": [
),
"PowerEnergyConsumptionHotWaterCircuit": (
"PrEnergySumHwc1",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
],
"RoomThermostat": ["DCRoomthermostat", None, "mdi:toggle-switch", 2, None],
"HeatingPartLoad": [
),
"RoomThermostat": ("DCRoomthermostat", None, "mdi:toggle-switch", 2, None),
"HeatingPartLoad": (
"PartloadHcKW",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
],
"StateNumber": ["StateNumber", None, "mdi:fire", 3, None],
"ModulationPercentage": [
),
"StateNumber": ("StateNumber", None, "mdi:fire", 3, None),
"ModulationPercentage": (
"ModulationTempDesired",
PERCENTAGE,
"mdi:percent",
0,
None,
],
),
},
}

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import datetime
import logging
from typing import Any, cast
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.core import HomeAssistant
@@ -11,7 +12,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle, dt as dt_util
from .const import DOMAIN
from . import EbusdData
from .const import EBUSD_DATA, SensorSpecs
TIME_FRAME1_BEGIN = "time_frame1_begin"
TIME_FRAME1_END = "time_frame1_end"
@@ -33,9 +35,9 @@ def setup_platform(
"""Set up the Ebus sensor."""
if not discovery_info:
return
ebusd_api = hass.data[DOMAIN]
monitored_conditions = discovery_info["monitored_conditions"]
name = discovery_info["client_name"]
ebusd_api = hass.data[EBUSD_DATA]
monitored_conditions: list[str] = discovery_info["monitored_conditions"]
name: str = discovery_info["client_name"]
add_entities(
(
@@ -49,9 +51,8 @@ def setup_platform(
class EbusdSensor(SensorEntity):
"""Ebusd component sensor methods definition."""
def __init__(self, data, sensor, name):
def __init__(self, data: EbusdData, sensor: SensorSpecs, name: str) -> None:
"""Initialize the sensor."""
self._state = None
self._client_name = name
(
self._name,
@@ -63,20 +64,15 @@ class EbusdSensor(SensorEntity):
self.data = data
@property
def name(self):
def name(self) -> str:
"""Return the name of the sensor."""
return f"{self._client_name} {self._name}"
@property
def native_value(self):
"""Return the state of the sensor."""
return self._state
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the device state attributes."""
if self._type == 1 and self._state is not None:
schedule = {
if self._type == 1 and (native_value := self.native_value) is not None:
schedule: dict[str, str | None] = {
TIME_FRAME1_BEGIN: None,
TIME_FRAME1_END: None,
TIME_FRAME2_BEGIN: None,
@@ -84,7 +80,7 @@ class EbusdSensor(SensorEntity):
TIME_FRAME3_BEGIN: None,
TIME_FRAME3_END: None,
}
time_frame = self._state.split(";")
time_frame = cast(str, native_value).split(";")
for index, item in enumerate(sorted(schedule.items())):
if index < len(time_frame):
parsed = datetime.datetime.strptime(time_frame[index], "%H:%M")
@@ -101,12 +97,12 @@ class EbusdSensor(SensorEntity):
return self._device_class
@property
def icon(self):
def icon(self) -> str | None:
"""Icon to use in the frontend, if any."""
return self._icon
@property
def native_unit_of_measurement(self):
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement."""
return self._unit_of_measurement
@@ -118,6 +114,6 @@ class EbusdSensor(SensorEntity):
if self._name not in self.data.value:
return
self._state = self.data.value[self._name]
self._attr_native_value = self.data.value[self._name]
except RuntimeError:
_LOGGER.debug("EbusdData.update exception")

View File

@@ -0,0 +1,36 @@
"""The Homevolt integration."""
from __future__ import annotations
from homevolt import Homevolt
from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:
"""Set up Homevolt from a config entry."""
host: str = entry.data[CONF_HOST]
password: str | None = entry.data.get(CONF_PASSWORD)
websession = async_get_clientsession(hass)
client = Homevolt(host, password, websession=websession)
coordinator = HomevoltDataUpdateCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,70 @@
"""Config flow for the Homevolt integration."""
from __future__ import annotations
import logging
from typing import Any
from homevolt import Homevolt, HomevoltAuthenticationError, HomevoltConnectionError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD
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_HOST): str,
vol.Optional(CONF_PASSWORD): str,
}
)
class HomevoltConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Homevolt."""
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:
host = user_input[CONF_HOST]
password = user_input.get(CONF_PASSWORD)
websession = async_get_clientsession(self.hass)
client = Homevolt(host, password, websession=websession)
try:
await client.update_info()
device = client.get_device()
device_id = device.device_id
except HomevoltAuthenticationError:
errors["base"] = "invalid_auth"
except HomevoltConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception(
"Error occurred while connecting to the Homevolt battery"
)
errors["base"] = "unknown"
else:
await self.async_set_unique_id(device_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Homevolt Local",
data={
CONF_HOST: host,
CONF_PASSWORD: password,
},
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@@ -0,0 +1,9 @@
"""Constants for the Homevolt integration."""
from __future__ import annotations
from datetime import timedelta
DOMAIN = "homevolt"
MANUFACTURER = "Homevolt"
SCAN_INTERVAL = timedelta(seconds=15)

View File

@@ -0,0 +1,56 @@
"""Data update coordinator for Homevolt integration."""
from __future__ import annotations
import logging
from homevolt import (
Device,
Homevolt,
HomevoltAuthenticationError,
HomevoltConnectionError,
HomevoltError,
)
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 DOMAIN, SCAN_INTERVAL
type HomevoltConfigEntry = ConfigEntry[HomevoltDataUpdateCoordinator]
_LOGGER = logging.getLogger(__name__)
class HomevoltDataUpdateCoordinator(DataUpdateCoordinator[Device]):
"""Class to manage fetching Homevolt data."""
config_entry: HomevoltConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: HomevoltConfigEntry,
client: Homevolt,
) -> None:
"""Initialize the Homevolt coordinator."""
self.client = client
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
config_entry=entry,
)
async def _async_update_data(self) -> Device:
"""Fetch data from the Homevolt API."""
try:
await self.client.update_info()
return self.client.get_device()
except HomevoltAuthenticationError as err:
raise ConfigEntryAuthFailed from err
except (HomevoltConnectionError, HomevoltError) as err:
raise UpdateFailed(f"Error communicating with device: {err}") from err

View File

@@ -0,0 +1,12 @@
{
"domain": "homevolt",
"name": "Homevolt",
"codeowners": ["@danielhiversen"],
"config_flow": true,
"dependencies": [],
"documentation": "https://www.home-assistant.io/integrations/homevolt",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["homevolt==0.2.4"]
}

View File

@@ -0,0 +1,70 @@
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: done
config-flow: done
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: Local_polling without events
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: Integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration does not have an options flow.
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
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: todo
entity-category: todo
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,162 @@
"""Support for Homevolt sensors."""
from __future__ import annotations
from homevolt.models import SensorType
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
PARALLEL_UPDATES = 0 # Coordinator-based updates
SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=SensorType.COUNT,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key=SensorType.ENERGY_TOTAL,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
),
SensorEntityDescription(
key=SensorType.ENERGY_INCREASING,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
),
SensorEntityDescription(
key=SensorType.FREQUENCY,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
),
SensorEntityDescription(
key=SensorType.PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
),
SensorEntityDescription(
key=SensorType.POWER,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
),
SensorEntityDescription(
key=SensorType.SCHEDULE_TYPE,
),
SensorEntityDescription(
key=SensorType.SIGNAL_STRENGTH,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
),
SensorEntityDescription(
key=SensorType.TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
SensorEntityDescription(
key=SensorType.TEXT,
),
SensorEntityDescription(
key=SensorType.VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
),
SensorEntityDescription(
key=SensorType.CURRENT,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Homevolt sensor."""
coordinator = entry.runtime_data
entities = []
sensors_by_key = {sensor.key: sensor for sensor in SENSORS}
for sensor_key, sensor in coordinator.data.sensors.items():
if (description := sensors_by_key.get(sensor.type)) is None:
continue
entities.append(
HomevoltSensor(
description,
coordinator,
sensor_key,
)
)
async_add_entities(entities)
class HomevoltSensor(CoordinatorEntity[HomevoltDataUpdateCoordinator], SensorEntity):
"""Representation of a Homevolt sensor."""
_attr_has_entity_name = True
def __init__(
self,
description: SensorEntityDescription,
coordinator: HomevoltDataUpdateCoordinator,
sensor_key: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
device_id = coordinator.data.device_id
self._attr_unique_id = f"{device_id}_{sensor_key}"
sensor_data = coordinator.data.sensors[sensor_key]
self._attr_translation_key = sensor_data.slug
self._sensor_key = sensor_key
device_metadata = coordinator.data.device_metadata.get(
sensor_data.device_identifier
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{device_id}_{sensor_data.device_identifier}")},
configuration_url=coordinator.client.base_url,
manufacturer=MANUFACTURER,
model=device_metadata.model if device_metadata else None,
name=device_metadata.name if device_metadata else None,
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._sensor_key in self.coordinator.data.sensors
@property
def native_value(self) -> StateType:
"""Return the native value of the sensor."""
return self.coordinator.data.sensors[self._sensor_key].value

View File

@@ -0,0 +1,198 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"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%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"host": "The IP address or hostname of your Homevolt battery on your local network.",
"password": "The local password configured for your Homevolt battery, if required."
},
"description": "Connect Home Assistant to your Homevolt battery over the local network.",
"title": "Homevolt Local"
}
}
},
"entity": {
"sensor": {
"available_charging_energy": {
"name": "Available charging energy"
},
"available_charging_power": {
"name": "Available charging power"
},
"available_discharge_energy": {
"name": "Available discharge energy"
},
"available_discharge_power": {
"name": "Available discharge power"
},
"average_rssi_grid": {
"name": "Grid average RSSI"
},
"average_rssi_load": {
"name": "Load average RSSI"
},
"battery_state_of_charge": {
"name": "Battery state of charge"
},
"charge_cycles": {
"name": "Charge cycles"
},
"energy_exported_grid": {
"name": "Grid exported energy"
},
"energy_exported_load": {
"name": "Load exported energy"
},
"energy_imported_grid": {
"name": "Grid imported energy"
},
"energy_imported_load": {
"name": "Load imported energy"
},
"exported_energy": {
"name": "Exported energy"
},
"frequency": {
"name": "Frequency"
},
"imported_energy": {
"name": "Imported energy"
},
"l1_current": {
"name": "L1 current"
},
"l1_current_grid": {
"name": "Grid L1 current"
},
"l1_current_load": {
"name": "Load L1 current"
},
"l1_l2_voltage": {
"name": "L1-L2 voltage"
},
"l1_power_grid": {
"name": "Grid L1 power"
},
"l1_power_load": {
"name": "Load L1 power"
},
"l1_voltage": {
"name": "L1 voltage"
},
"l1_voltage_grid": {
"name": "Grid L1 voltage"
},
"l1_voltage_load": {
"name": "Load L1 voltage"
},
"l2_current": {
"name": "L2 current"
},
"l2_current_grid": {
"name": "Grid L2 current"
},
"l2_current_load": {
"name": "Load L2 current"
},
"l2_l3_voltage": {
"name": "L2-L3 voltage"
},
"l2_power_grid": {
"name": "Grid L2 power"
},
"l2_power_load": {
"name": "Load L2 power"
},
"l2_voltage": {
"name": "L2 voltage"
},
"l2_voltage_grid": {
"name": "Grid L2 voltage"
},
"l2_voltage_load": {
"name": "Load L2 voltage"
},
"l3_current": {
"name": "L3 current"
},
"l3_current_grid": {
"name": "Grid L3 current"
},
"l3_current_load": {
"name": "Load L3 current"
},
"l3_l1_voltage": {
"name": "L3-L1 voltage"
},
"l3_power_grid": {
"name": "Grid L3 power"
},
"l3_power_load": {
"name": "Load L3 power"
},
"l3_voltage": {
"name": "L3 voltage"
},
"l3_voltage_grid": {
"name": "Grid L3 voltage"
},
"l3_voltage_load": {
"name": "Load L3 voltage"
},
"power": {
"name": "Power"
},
"power_grid": {
"name": "Grid power"
},
"power_load": {
"name": "Load power"
},
"rssi_grid": {
"name": "Grid RSSI"
},
"rssi_load": {
"name": "Load RSSI"
},
"schedule_id": {
"name": "Schedule ID"
},
"schedule_max_discharge": {
"name": "Schedule max discharge"
},
"schedule_max_power": {
"name": "Schedule max power"
},
"schedule_power_setpoint": {
"name": "Schedule power setpoint"
},
"schedule_type": {
"name": "Schedule type"
},
"state_of_charge": {
"name": "State of charge"
},
"system_temperature": {
"name": "System temperature"
},
"tmax": {
"name": "Maximum temperature"
},
"tmin": {
"name": "Minimum temperature"
}
}
}
}

View File

@@ -181,7 +181,7 @@ class GenericHueSensor(GenericHueDevice, entity.Entity): # pylint: disable=hass
)
@property
def state_class(self):
def state_class(self) -> SensorStateClass:
"""Return the state class of this entity, from STATE_CLASSES, if any."""
return SensorStateClass.MEASUREMENT

View File

@@ -1,18 +1,4 @@
is_off:
target:
entity:
domain: light
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_on:
.condition_common: &condition_common
target:
entity:
domain: light
@@ -26,3 +12,6 @@ is_on:
options:
- all
- any
is_off: *condition_common
is_on: *condition_common

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from mficlient.client import FailedToLogin, MFiClient
from mficlient.client import FailedToLogin, MFiClient, Port as MFiPort
import requests
import voluptuous as vol
@@ -12,6 +12,7 @@ from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
StateType,
)
from homeassistant.const import (
CONF_HOST,
@@ -64,24 +65,29 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up mFi sensors."""
host = config.get(CONF_HOST)
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
use_tls = config.get(CONF_SSL)
verify_tls = config.get(CONF_VERIFY_SSL)
host: str = config[CONF_HOST]
username: str = config[CONF_USERNAME]
password: str = config[CONF_PASSWORD]
use_tls: bool = config[CONF_SSL]
verify_tls: bool = config[CONF_VERIFY_SSL]
default_port = 6443 if use_tls else 6080
port = int(config.get(CONF_PORT, default_port))
network_port: int = config.get(CONF_PORT, default_port)
try:
client = MFiClient(
host, username, password, port=port, use_tls=use_tls, verify=verify_tls
host,
username,
password,
port=network_port,
use_tls=use_tls,
verify=verify_tls,
)
except (FailedToLogin, requests.exceptions.ConnectionError) as ex:
_LOGGER.error("Unable to connect to mFi: %s", str(ex))
return
add_entities(
MfiSensor(port, hass)
MfiSensor(port)
for device in client.get_devices()
for port in device.ports.values()
if port.model in SENSOR_MODELS
@@ -91,18 +97,17 @@ def setup_platform(
class MfiSensor(SensorEntity):
"""Representation of a mFi sensor."""
def __init__(self, port, hass):
def __init__(self, port: MFiPort) -> None:
"""Initialize the sensor."""
self._port = port
self._hass = hass
@property
def name(self):
def name(self) -> str:
"""Return the name of the sensor."""
return self._port.label
@property
def native_value(self):
def native_value(self) -> StateType:
"""Return the state of the sensor."""
try:
tag = self._port.tag
@@ -129,7 +134,7 @@ class MfiSensor(SensorEntity):
return None
@property
def native_unit_of_measurement(self):
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement of this entity, if any."""
try:
tag = self._port.tag

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from typing import Any
from mficlient.client import FailedToLogin, MFiClient
from mficlient.client import FailedToLogin, MFiClient, Port as MFiPort
import requests
import voluptuous as vol
@@ -51,18 +51,23 @@ def setup_platform(
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up mFi sensors."""
host = config.get(CONF_HOST)
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
use_tls = config[CONF_SSL]
verify_tls = config.get(CONF_VERIFY_SSL)
"""Set up mFi switches."""
host: str = config[CONF_HOST]
username: str = config[CONF_USERNAME]
password: str = config[CONF_PASSWORD]
use_tls: bool = config[CONF_SSL]
verify_tls: bool = config[CONF_VERIFY_SSL]
default_port = 6443 if use_tls else 6080
port = int(config.get(CONF_PORT, default_port))
network_port: int = config.get(CONF_PORT, default_port)
try:
client = MFiClient(
host, username, password, port=port, use_tls=use_tls, verify=verify_tls
host,
username,
password,
port=network_port,
use_tls=use_tls,
verify=verify_tls,
)
except (FailedToLogin, requests.exceptions.ConnectionError) as ex:
_LOGGER.error("Unable to connect to mFi: %s", str(ex))
@@ -79,23 +84,23 @@ def setup_platform(
class MfiSwitch(SwitchEntity):
"""Representation of an mFi switch-able device."""
def __init__(self, port):
def __init__(self, port: MFiPort) -> None:
"""Initialize the mFi device."""
self._port = port
self._target_state = None
self._target_state: bool | None = None
@property
def unique_id(self):
def unique_id(self) -> str:
"""Return the unique ID of the device."""
return self._port.ident
@property
def name(self):
def name(self) -> str:
"""Return the name of the device."""
return self._port.label
@property
def is_on(self):
def is_on(self) -> bool | None:
"""Return true if the device is on."""
return self._port.output

View File

@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.0.17"]
"requirements": ["onedrive-personal-sdk==0.1.0"]
}

View File

@@ -112,45 +112,49 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is None:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)
errors: dict[str, str] = {}
self._async_abort_entries_match(user_input)
try:
await validate_input(self.hass, user_input)
except openai.APIConnectionError:
errors["base"] = "cannot_connect"
except openai.AuthenticationError:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="ChatGPT",
data=user_input,
subentries=[
{
"subentry_type": "conversation",
"data": RECOMMENDED_CONVERSATION_OPTIONS,
"title": DEFAULT_CONVERSATION_NAME,
"unique_id": None,
},
{
"subentry_type": "ai_task_data",
"data": RECOMMENDED_AI_TASK_OPTIONS,
"title": DEFAULT_AI_TASK_NAME,
"unique_id": None,
},
],
)
if user_input is not None:
self._async_abort_entries_match(user_input)
try:
await validate_input(self.hass, user_input)
except openai.APIConnectionError:
errors["base"] = "cannot_connect"
except openai.AuthenticationError:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="ChatGPT",
data=user_input,
subentries=[
{
"subentry_type": "conversation",
"data": RECOMMENDED_CONVERSATION_OPTIONS,
"title": DEFAULT_CONVERSATION_NAME,
"unique_id": None,
},
{
"subentry_type": "ai_task_data",
"data": RECOMMENDED_AI_TASK_OPTIONS,
"title": DEFAULT_AI_TASK_NAME,
"unique_id": None,
},
],
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
description_placeholders={
"instructions_url": "https://www.home-assistant.io/integrations/openai_conversation/#generate-an-api-key",
},
)
@classmethod

View File

@@ -12,7 +12,11 @@
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
},
"data_description": {
"api_key": "Your OpenAI API key."
},
"description": "Set up OpenAI Conversation integration by providing your OpenAI API key. Instructions to obtain an API key can be found [here]({instructions_url})."
}
}
},

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/sunricher_dali",
"iot_class": "local_push",
"quality_scale": "silver",
"requirements": ["PySrDaliGateway==0.18.0"]
"requirements": ["PySrDaliGateway==0.19.3"]
}

View File

@@ -1,17 +0,0 @@
"""Provides conditions for switches."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the switch conditions."""
return CONDITIONS

View File

@@ -1,17 +0,0 @@
.condition_common: &condition_common
target:
entity:
domain: switch
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_off: *condition_common
is_on: *condition_common

View File

@@ -1,12 +1,4 @@
{
"conditions": {
"is_off": {
"condition": "mdi:toggle-switch-variant-off"
},
"is_on": {
"condition": "mdi:toggle-switch-variant"
}
},
"entity_component": {
"_": {
"default": "mdi:toggle-switch-variant",

View File

@@ -1,32 +1,8 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted switches.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted switches to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_off": {
"description": "Tests if one or more switches are off.",
"fields": {
"behavior": {
"description": "[%key:component::switch::common::condition_behavior_description%]",
"name": "[%key:component::switch::common::condition_behavior_name%]"
}
},
"name": "If a switch is off"
},
"is_on": {
"description": "Tests if one or more switches are on.",
"fields": {
"behavior": {
"description": "[%key:component::switch::common::condition_behavior_description%]",
"name": "[%key:component::switch::common::condition_behavior_name%]"
}
},
"name": "If a switch is on"
}
},
"device_automation": {
"action_type": {
"toggle": "[%key:common::device_automation::action_type::toggle%]",
@@ -70,12 +46,6 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -2,10 +2,10 @@
import asyncio
from collections.abc import Callable
from functools import partial
from typing import Final
from aiohttp import ClientResponseError
from aiohttp.client_exceptions import ClientError
from aiohttp import ClientError, ClientResponseError
from tesla_fleet_api.const import Scope
from tesla_fleet_api.exceptions import (
Forbidden,
@@ -27,6 +27,7 @@ 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
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
@@ -75,28 +76,48 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def _get_access_token(oauth_session: OAuth2Session) -> str:
"""Get a valid access token, refreshing if necessary."""
LOGGER.debug(
"Token valid: %s, expires_at: %s",
oauth_session.valid_token,
oauth_session.token.get("expires_at"),
)
try:
await oauth_session.async_ensure_token_valid()
except ClientResponseError as err:
if err.status == 401:
raise ConfigEntryAuthFailed from err
raise ConfigEntryNotReady from err
except (KeyError, TypeError) as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="token_data_malformed",
) from err
except ClientError as err:
raise ConfigEntryNotReady from err
return oauth_session.token[CONF_ACCESS_TOKEN]
async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool:
"""Set up Teslemetry config."""
session = async_get_clientsession(hass)
implementation = await async_get_config_entry_implementation(hass, entry)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="oauth_implementation_not_available",
) from err
oauth_session = OAuth2Session(hass, entry, implementation)
async def _get_access_token() -> str:
try:
await oauth_session.async_ensure_token_valid()
except ClientResponseError as e:
if e.status == 401:
raise ConfigEntryAuthFailed from e
raise ConfigEntryNotReady from e
token: str = oauth_session.token[CONF_ACCESS_TOKEN]
return token
# Create API connection
access_token = partial(_get_access_token, oauth_session)
teslemetry = Teslemetry(
session=session,
access_token=_get_access_token,
access_token=access_token,
)
try:
calls = await asyncio.gather(
@@ -154,7 +175,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
if not stream:
stream = TeslemetryStream(
session,
_get_access_token,
access_token,
server=f"{region.lower()}.teslemetry.com",
parse_timestamp=True,
manual=True,

View File

@@ -997,7 +997,6 @@
"total_grid_energy_exported": {
"name": "Grid exported"
},
"total_home_usage": {
"name": "Home usage"
},
@@ -1127,6 +1126,9 @@
"no_vehicle_data_for_device": {
"message": "No vehicle data for device ID: {device_id}"
},
"oauth_implementation_not_available": {
"message": "OAuth implementation not available, try reauthenticating"
},
"set_scheduled_charging_time": {
"message": "Scheduled charging time is required when enabling"
},
@@ -1136,6 +1138,9 @@
"set_scheduled_departure_preconditioning": {
"message": "Preconditioning departure time is required when enabling"
},
"token_data_malformed": {
"message": "Token data malformed, try reauthenticating"
},
"wake_up_failed": {
"message": "Failed to wake up vehicle: {message}"
},

View File

@@ -9,8 +9,8 @@ import voluptuous as vol
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, Platform
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
config_validation as cv,
discovery,
@@ -20,7 +20,6 @@ from homeassistant.helpers.device import (
async_entity_id_to_device_id,
async_remove_stale_devices_links_keep_entity_device,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.helper_integration import (
async_handle_source_entity_changes,
async_remove_helper_config_entry_from_source_device,
@@ -44,9 +43,8 @@ from .const import (
DATA_UTILITY,
DOMAIN,
METER_TYPES,
SERVICE_RESET,
SIGNAL_RESET_METER,
)
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
@@ -120,27 +118,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up an Utility Meter."""
hass.data[DATA_UTILITY] = {}
async def async_reset_meters(service_call):
"""Reset all sensors of a meter."""
meters = service_call.data["entity_id"]
for meter in meters:
_LOGGER.debug("resetting meter %s", meter)
domain, entity = split_entity_id(meter)
# backward compatibility up to 2022.07:
if domain == DOMAIN:
async_dispatcher_send(
hass, SIGNAL_RESET_METER, f"{SELECT_DOMAIN}.{entity}"
)
else:
async_dispatcher_send(hass, SIGNAL_RESET_METER, meter)
hass.services.async_register(
DOMAIN,
SERVICE_RESET,
async_reset_meters,
vol.Schema({ATTR_ENTITY_ID: vol.All(cv.ensure_list, [cv.entity_id])}),
)
async_setup_services(hass)
if DOMAIN not in config:
return True

View File

@@ -700,7 +700,7 @@ class UtilityMeterSensor(RestoreSensor):
return None
@property
def state_class(self):
def state_class(self) -> SensorStateClass:
"""Return the device class of the sensor."""
return (
SensorStateClass.TOTAL

View File

@@ -0,0 +1,43 @@
"""Support for tracking consumption over given periods of time."""
import logging
import voluptuous as vol
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DOMAIN, SERVICE_RESET, SIGNAL_RESET_METER
_LOGGER = logging.getLogger(__name__)
async def async_reset_meters(service_call: ServiceCall) -> None:
"""Reset all sensors of a meter."""
meters = service_call.data["entity_id"]
for meter in meters:
_LOGGER.debug("resetting meter %s", meter)
domain, entity = split_entity_id(meter)
# backward compatibility up to 2022.07:
if domain == DOMAIN:
async_dispatcher_send(
service_call.hass, SIGNAL_RESET_METER, f"{SELECT_DOMAIN}.{entity}"
)
else:
async_dispatcher_send(service_call.hass, SIGNAL_RESET_METER, meter)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services."""
hass.services.async_register(
DOMAIN,
SERVICE_RESET,
async_reset_meters,
vol.Schema({ATTR_ENTITY_ID: vol.All(cv.ensure_list, [cv.entity_id])}),
)

View File

@@ -294,6 +294,7 @@ FLOWS = {
"homekit",
"homekit_controller",
"homematicip_cloud",
"homevolt",
"homewizard",
"homeworks",
"honeywell",

View File

@@ -2836,6 +2836,12 @@
"zwave"
]
},
"homevolt": {
"name": "Homevolt",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"homewizard": {
"name": "HomeWizard",
"integration_type": "device",

View File

@@ -700,6 +700,7 @@ _ENTITY_MATCH: list[TypeHintMatch] = [
TypeHintMatch(
function_name="device_class",
return_type=["str", None],
mandatory=True,
),
TypeHintMatch(
function_name="unit_of_measurement",
@@ -2518,10 +2519,12 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="state_class",
return_type=["SensorStateClass", "str", None],
mandatory=True,
),
TypeHintMatch(
function_name="last_reset",
return_type=["datetime", None],
mandatory=True,
),
TypeHintMatch(
function_name="native_value",

7
requirements_all.txt generated
View File

@@ -80,7 +80,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.sunricher_dali
PySrDaliGateway==0.18.0
PySrDaliGateway==0.19.3
# homeassistant.components.switchbot
PySwitchbot==0.76.0
@@ -1226,6 +1226,9 @@ homelink-integration-api==0.0.1
# homeassistant.components.homematicip_cloud
homematicip==2.4.0
# homeassistant.components.homevolt
homevolt==0.2.4
# homeassistant.components.horizon
horimote==0.4.1
@@ -1646,7 +1649,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.0.17
onedrive-personal-sdk==0.1.0
# homeassistant.components.onvif
onvif-zeep-async==4.0.4

View File

@@ -80,7 +80,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.sunricher_dali
PySrDaliGateway==0.18.0
PySrDaliGateway==0.19.3
# homeassistant.components.switchbot
PySwitchbot==0.76.0
@@ -1084,6 +1084,9 @@ homelink-integration-api==0.0.1
# homeassistant.components.homematicip_cloud
homematicip==2.4.0
# homeassistant.components.homevolt
homevolt==0.2.4
# homeassistant.components.remember_the_milk
httplib2==0.20.4
@@ -1429,7 +1432,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.0.17
onedrive-personal-sdk==0.1.0
# homeassistant.components.onvif
onvif-zeep-async==4.0.4

View File

@@ -0,0 +1 @@
"""Tests for the Homevolt integration."""

View File

@@ -0,0 +1,110 @@
"""Common fixtures for the Homevolt tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from homevolt import Device, DeviceMetadata, Sensor, SensorType
import pytest
from homeassistant.components.homevolt.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.homevolt.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="Homevolt",
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "test-password",
},
unique_id="40580137858664",
)
@pytest.fixture
def mock_homevolt_client() -> Generator[MagicMock]:
"""Return a mocked Homevolt client."""
with (
patch(
"homeassistant.components.homevolt.Homevolt",
autospec=True,
) as homevolt_mock,
patch(
"homeassistant.components.homevolt.config_flow.Homevolt",
new=homevolt_mock,
),
):
client = homevolt_mock.return_value
client.base_url = "http://127.0.0.1"
client.update_info = AsyncMock()
# Create a mock Device with sensors
device = MagicMock(spec=Device)
device.device_id = "40580137858664"
device.sensors = {
"L1 Voltage": Sensor(
value=234.5,
type=SensorType.VOLTAGE,
device_identifier="ems_40580137858664",
slug="l1_voltage",
),
"Battery State of Charge": Sensor(
value=80.6,
type=SensorType.PERCENTAGE,
device_identifier="ems_40580137858664",
slug="battery_state_of_charge",
),
"Power": Sensor(
value=-12,
type=SensorType.POWER,
device_identifier="ems_40580137858664",
slug="power",
),
}
device.device_metadata = {
"ems_40580137858664": DeviceMetadata(
name="Homevolt EMS",
model="EMS-1000",
),
}
client.get_device.return_value = device
yield client
@pytest.fixture
def platforms() -> list[Platform]:
"""Return the platforms to test."""
return [Platform.SENSOR]
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_homevolt_client: MagicMock,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the Homevolt integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.homevolt.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View File

@@ -0,0 +1,169 @@
# serializer version: 1
# name: test_entities[sensor.homevolt_ems_battery_state_of_charge-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.homevolt_ems_battery_state_of_charge',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Battery state of charge',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
'original_icon': None,
'original_name': 'Battery state of charge',
'platform': 'homevolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'battery_state_of_charge',
'unique_id': '40580137858664_Battery State of Charge',
'unit_of_measurement': '%',
})
# ---
# name: test_entities[sensor.homevolt_ems_battery_state_of_charge-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'Homevolt EMS Battery state of charge',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.homevolt_ems_battery_state_of_charge',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '80.6',
})
# ---
# name: test_entities[sensor.homevolt_ems_l1_voltage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.homevolt_ems_l1_voltage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'L1 voltage',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
'original_icon': None,
'original_name': 'L1 voltage',
'platform': 'homevolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'l1_voltage',
'unique_id': '40580137858664_L1 Voltage',
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
})
# ---
# name: test_entities[sensor.homevolt_ems_l1_voltage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'voltage',
'friendly_name': 'Homevolt EMS L1 voltage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
}),
'context': <ANY>,
'entity_id': 'sensor.homevolt_ems_l1_voltage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '234.5',
})
# ---
# name: test_entities[sensor.homevolt_ems_power-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.homevolt_ems_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Power',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'Power',
'platform': 'homevolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'power',
'unique_id': '40580137858664_Power',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_entities[sensor.homevolt_ems_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'Homevolt EMS Power',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.homevolt_ems_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-12',
})
# ---

View File

@@ -0,0 +1,170 @@
"""Tests for the Homevolt config flow."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
from homevolt import HomevoltAuthenticationError, HomevoltConnectionError
import pytest
from homeassistant.components.homevolt.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_full_flow_success(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test a complete successful user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
user_input = {
CONF_HOST: "192.168.1.100",
CONF_PASSWORD: "test-password",
}
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Homevolt Local"
assert result["data"] == user_input
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "expected_error"),
[
(HomevoltAuthenticationError, "invalid_auth"),
(HomevoltConnectionError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_step_user_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
exception: Exception,
expected_error: str,
) -> None:
"""Test error cases for the user step with recovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
user_input = {
CONF_HOST: "192.168.1.100",
CONF_PASSWORD: "test-password",
}
with patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
) as mock_update_info:
mock_update_info.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": expected_error}
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Homevolt Local"
assert result["data"] == user_input
assert len(mock_setup_entry.mock_calls) == 1
async def test_duplicate_entry(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Test that a duplicate device_id aborts the flow."""
existing_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.1.100", CONF_PASSWORD: "test-password"},
unique_id="40580137858664",
)
existing_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
user_input = {
CONF_HOST: "192.168.1.200",
CONF_PASSWORD: "test-password",
}
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@@ -0,0 +1,65 @@
"""Test the Homevolt init module."""
from __future__ import annotations
from unittest.mock import MagicMock
from homevolt import HomevoltAuthenticationError, HomevoltConnectionError
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_load_unload_entry(
hass: HomeAssistant,
mock_homevolt_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test load and unload entry."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_remove(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_config_entry_not_ready(
hass: HomeAssistant,
mock_homevolt_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Homevolt configuration entry not ready."""
mock_homevolt_client.update_info.side_effect = HomevoltConnectionError(
"Connection failed"
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_config_entry_auth_failed(
hass: HomeAssistant,
mock_homevolt_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Homevolt configuration entry authentication failed."""
mock_homevolt_client.update_info.side_effect = HomevoltAuthenticationError(
"Authentication failed"
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR

View File

@@ -0,0 +1,60 @@
"""Tests for the Homevolt sensor platform."""
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.homevolt.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = pytest.mark.usefixtures(
"entity_registry_enabled_by_default", "init_integration"
)
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the sensor entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "40580137858664_ems_40580137858664")}
)
assert device_entry
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
for entity_entry in entity_entries:
assert entity_entry.device_id == device_entry.id
async def test_sensor_exposes_values_from_coordinator(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_homevolt_client,
) -> None:
"""Ensure sensor entities are created and expose values from the coordinator."""
unique_id = "40580137858664_L1 Voltage"
entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state is not None
assert float(state.state) == 234.5
mock_homevolt_client.get_device.return_value.sensors["L1 Voltage"].value = 240.1
coordinator = mock_config_entry.runtime_data
await coordinator.async_refresh()
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert float(state.state) == 240.1

View File

@@ -34,7 +34,7 @@ async def test_setup_missing_config(hass: HomeAssistant) -> None:
"""Test setup with missing configuration."""
with mock.patch("homeassistant.components.mfi.sensor.MFiClient") as mock_client:
config = {"sensor": {"platform": "mfi"}}
assert await async_setup_component(hass, "sensor", config)
assert await async_setup_component(hass, COMPONENT.DOMAIN, config)
assert not mock_client.called
@@ -42,14 +42,14 @@ async def test_setup_failed_login(hass: HomeAssistant) -> None:
"""Test setup with login failure."""
with mock.patch("homeassistant.components.mfi.sensor.MFiClient") as mock_client:
mock_client.side_effect = FailedToLogin
assert not PLATFORM.setup_platform(hass, GOOD_CONFIG, None)
assert not PLATFORM.setup_platform(hass, GOOD_CONFIG[COMPONENT.DOMAIN], None)
async def test_setup_failed_connect(hass: HomeAssistant) -> None:
"""Test setup with connection failure."""
with mock.patch("homeassistant.components.mfi.sensor.MFiClient") as mock_client:
mock_client.side_effect = requests.exceptions.ConnectionError
assert not PLATFORM.setup_platform(hass, GOOD_CONFIG, None)
assert not PLATFORM.setup_platform(hass, GOOD_CONFIG[COMPONENT.DOMAIN], None)
async def test_setup_minimum(hass: HomeAssistant) -> None:
@@ -111,7 +111,7 @@ async def test_setup_adds_proper_devices(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
for ident, port in ports.items():
if ident != "bad":
mock_sensor.assert_any_call(port, hass)
mock_sensor.assert_any_call(port)
assert mock.call(ports["bad"], hass) not in mock_sensor.mock_calls
@@ -124,7 +124,7 @@ def port_fixture() -> mock.MagicMock:
@pytest.fixture(name="sensor")
def sensor_fixture(hass: HomeAssistant, port: mock.MagicMock) -> mfi.MfiSensor:
"""Sensor fixture."""
sensor = mfi.MfiSensor(port, hass)
sensor = mfi.MfiSensor(port)
sensor.hass = hass
return sensor

View File

@@ -58,7 +58,7 @@ async def test_form(hass: HomeAssistant) -> None:
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
assert result["errors"] == {}
with (
patch(

View File

@@ -1,185 +0,0 @@
"""Test switch conditions."""
from typing import Any
import pytest
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from tests.components import (
ConditionStateDescription,
assert_condition_gated_by_labs_flag,
create_target_condition,
parametrize_condition_states,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture
async def target_lights(hass: HomeAssistant) -> list[str]:
"""Create multiple light entities associated with different targets."""
return (await target_entities(hass, "light"))["included"]
@pytest.fixture
async def target_switches(hass: HomeAssistant) -> list[str]:
"""Create multiple switch entities associated with different targets."""
return (await target_entities(hass, "switch"))["included"]
@pytest.mark.parametrize(
"condition",
[
"switch.is_off",
"switch.is_on",
],
)
async def test_switch_conditions_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
) -> None:
"""Test the switch conditions are gated by the labs flag."""
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("switch"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_condition_states(
condition="switch.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
*parametrize_condition_states(
condition="switch.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
],
)
async def test_switch_state_condition_behavior_any(
hass: HomeAssistant,
target_lights: list[str],
target_switches: list[str],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the switch state condition with the 'any' behavior."""
other_entity_ids = set(target_switches) - {entity_id}
# Set all switches, including the tested switch, to the initial state
for eid in target_switches:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
condition=condition,
target=condition_target_config,
behavior="any",
)
# Set state for lights to ensure that they don't impact the condition
for state in states:
for eid in target_lights:
set_or_remove_state(hass, eid, state["included"])
await hass.async_block_till_done()
assert condition(hass) is False
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other lights also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("switch"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_condition_states(
condition="switch.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
*parametrize_condition_states(
condition="switch.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
],
)
async def test_switch_state_condition_behavior_all(
hass: HomeAssistant,
target_switches: list[str],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the switch state condition with the 'all' behavior."""
# Set state for two switches to ensure that they don't impact the condition
hass.states.async_set("switch.label_switch_1", STATE_OFF)
hass.states.async_set("switch.label_switch_2", STATE_ON)
other_entity_ids = set(target_switches) - {entity_id}
# Set all switches, including the tested switch, to the initial state
for eid in target_switches:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
condition=condition,
target=condition_target_config,
behavior="all",
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
# The condition passes if all entities are either in a target state or invalid
assert condition(hass) == (
(not state["state_valid"])
or (state["condition_true"] and entities_in_target == 1)
)
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
# The condition passes if all entities are either in a target state or invalid
assert condition(hass) == (
(not state["state_valid"]) or state["condition_true"]
)

View File

@@ -443,6 +443,37 @@ async def test_migrate_from_future_version_fails(hass: HomeAssistant) -> None:
assert entry.version == 3 # Version should remain unchanged
async def test_oauth_implementation_not_available(hass: HomeAssistant) -> None:
"""Test that missing OAuth implementation triggers reauth."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
version=2,
unique_id=UNIQUE_ID,
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": "test_access_token",
"refresh_token": "test_refresh_token",
"expires_at": int(time.time()) + 3600,
},
},
)
mock_entry.add_to_hass(hass)
# Mock the implementation lookup to raise ValueError
with patch(
"homeassistant.components.teslemetry.async_get_config_entry_implementation",
side_effect=ValueError("Implementation not available"),
):
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
entry = hass.config_entries.async_get_entry(mock_entry.entry_id)
assert entry is not None
# Should trigger reauth, not just fail silently
assert entry.state is ConfigEntryState.SETUP_ERROR
RETRY_EXCEPTIONS = [
(RateLimited(data={"after": 5}), 5.0),
(InvalidResponse(), 10.0),

View File

@@ -188,12 +188,10 @@ async def test_caching(hass: HomeAssistant) -> None:
side_effect=icon.build_resources,
) as mock_build:
load1 = await icon.async_get_icons(hass, "entity_component")
# conditions, entity_component, services, triggers
assert len(mock_build.mock_calls) == 4
assert len(mock_build.mock_calls) == 3 # entity_component, services, triggers
load2 = await icon.async_get_icons(hass, "entity_component")
# conditions, entity_component, services, triggers
assert len(mock_build.mock_calls) == 4
assert len(mock_build.mock_calls) == 3 # entity_component, services, triggers
assert load1 == load2