This commit is contained in:
Franck Nijhof 2024-04-08 14:17:03 +02:00 committed by GitHub
commit 04072cb3c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 511 additions and 121 deletions

View File

@ -1249,6 +1249,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/sms/ @ocalvo /homeassistant/components/sms/ @ocalvo
/homeassistant/components/snapcast/ @luar123 /homeassistant/components/snapcast/ @luar123
/tests/components/snapcast/ @luar123 /tests/components/snapcast/ @luar123
/homeassistant/components/snmp/ @nmaggioni
/tests/components/snmp/ @nmaggioni
/homeassistant/components/snooz/ @AustinBrunkhorst /homeassistant/components/snooz/ @AustinBrunkhorst
/tests/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst
/homeassistant/components/solaredge/ @frenck /homeassistant/components/solaredge/ @frenck

View File

@ -26,7 +26,7 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["axis"], "loggers": ["axis"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["axis==60"], "requirements": ["axis==61"],
"ssdp": [ "ssdp": [
{ {
"manufacturer": "AXIS" "manufacturer": "AXIS"

View File

@ -8,7 +8,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["brother==4.0.2"], "requirements": ["brother==4.1.0"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_printer._tcp.local.", "type": "_printer._tcp.local.",

View File

@ -8,5 +8,5 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["hass_nabucasa"], "loggers": ["hass_nabucasa"],
"requirements": ["hass-nabucasa==0.79.0"] "requirements": ["hass-nabucasa==0.78.0"]
} }

View File

@ -121,6 +121,7 @@ async def async_setup_entry(
Platform.COVER, Platform.COVER,
Platform.LIGHT, Platform.LIGHT,
Platform.LOCK, Platform.LOCK,
Platform.SENSOR,
Platform.SWITCH, Platform.SWITCH,
) )
for device in controller.fibaro_devices[platform] for device in controller.fibaro_devices[platform]

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/fyta", "documentation": "https://www.home-assistant.io/integrations/fyta",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["fyta_cli==0.3.3"] "requirements": ["fyta_cli==0.3.5"]
} }

View File

@ -46,35 +46,35 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [
translation_key="plant_status", translation_key="plant_status",
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=PLANT_STATUS_LIST, options=PLANT_STATUS_LIST,
value_fn=lambda value: PLANT_STATUS[value], value_fn=PLANT_STATUS.get,
), ),
FytaSensorEntityDescription( FytaSensorEntityDescription(
key="temperature_status", key="temperature_status",
translation_key="temperature_status", translation_key="temperature_status",
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=PLANT_STATUS_LIST, options=PLANT_STATUS_LIST,
value_fn=lambda value: PLANT_STATUS[value], value_fn=PLANT_STATUS.get,
), ),
FytaSensorEntityDescription( FytaSensorEntityDescription(
key="light_status", key="light_status",
translation_key="light_status", translation_key="light_status",
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=PLANT_STATUS_LIST, options=PLANT_STATUS_LIST,
value_fn=lambda value: PLANT_STATUS[value], value_fn=PLANT_STATUS.get,
), ),
FytaSensorEntityDescription( FytaSensorEntityDescription(
key="moisture_status", key="moisture_status",
translation_key="moisture_status", translation_key="moisture_status",
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=PLANT_STATUS_LIST, options=PLANT_STATUS_LIST,
value_fn=lambda value: PLANT_STATUS[value], value_fn=PLANT_STATUS.get,
), ),
FytaSensorEntityDescription( FytaSensorEntityDescription(
key="salinity_status", key="salinity_status",
translation_key="salinity_status", translation_key="salinity_status",
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=PLANT_STATUS_LIST, options=PLANT_STATUS_LIST,
value_fn=lambda value: PLANT_STATUS[value], value_fn=PLANT_STATUS.get,
), ),
FytaSensorEntityDescription( FytaSensorEntityDescription(
key="temperature", key="temperature",

View File

@ -113,7 +113,11 @@ class HMThermostat(HMDevice, ClimateEntity):
@property @property
def preset_modes(self): def preset_modes(self):
"""Return a list of available preset modes.""" """Return a list of available preset modes."""
return [HM_PRESET_MAP[mode] for mode in self._hmdevice.ACTIONNODE] return [
HM_PRESET_MAP[mode]
for mode in self._hmdevice.ACTIONNODE
if mode in HM_PRESET_MAP
]
@property @property
def current_humidity(self): def current_humidity(self):

View File

@ -12,7 +12,7 @@
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": [ "requirements": [
"xknx==2.12.2", "xknx==2.12.2",
"xknxproject==3.7.0", "xknxproject==3.7.1",
"knx-frontend==2024.1.20.105944" "knx-frontend==2024.1.20.105944"
], ],
"single_config_entry": true "single_config_entry": true

View File

@ -12,5 +12,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pylitterbot"], "loggers": ["pylitterbot"],
"requirements": ["pylitterbot==2023.4.9"] "requirements": ["pylitterbot==2023.4.11"]
} }

View File

@ -35,6 +35,7 @@ LITTER_BOX_STATUS_STATE_MAP = {
LitterBoxStatus.CLEAN_CYCLE: STATE_CLEANING, LitterBoxStatus.CLEAN_CYCLE: STATE_CLEANING,
LitterBoxStatus.EMPTY_CYCLE: STATE_CLEANING, LitterBoxStatus.EMPTY_CYCLE: STATE_CLEANING,
LitterBoxStatus.CLEAN_CYCLE_COMPLETE: STATE_DOCKED, LitterBoxStatus.CLEAN_CYCLE_COMPLETE: STATE_DOCKED,
LitterBoxStatus.CAT_DETECTED: STATE_DOCKED,
LitterBoxStatus.CAT_SENSOR_TIMING: STATE_DOCKED, LitterBoxStatus.CAT_SENSOR_TIMING: STATE_DOCKED,
LitterBoxStatus.DRAWER_FULL_1: STATE_DOCKED, LitterBoxStatus.DRAWER_FULL_1: STATE_DOCKED,
LitterBoxStatus.DRAWER_FULL_2: STATE_DOCKED, LitterBoxStatus.DRAWER_FULL_2: STATE_DOCKED,

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/nobo_hub", "documentation": "https://www.home-assistant.io/integrations/nobo_hub",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["pynobo==1.8.0"] "requirements": ["pynobo==1.8.1"]
} }

View File

@ -258,7 +258,7 @@ class PrometheusMetrics:
self, entity_id: str, friendly_name: str | None = None self, entity_id: str, friendly_name: str | None = None
) -> None: ) -> None:
"""Remove labelsets matching the given entity id from all metrics.""" """Remove labelsets matching the given entity id from all metrics."""
for metric in self._metrics.values(): for metric in list(self._metrics.values()):
for sample in cast(list[prometheus_client.Metric], metric.collect())[ for sample in cast(list[prometheus_client.Metric], metric.collect())[
0 0
].samples: ].samples:

View File

@ -45,7 +45,7 @@ class SnapcastConfigFlow(ConfigFlow, domain=DOMAIN):
except OSError: except OSError:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
else: else:
await client.stop() client.stop()
return self.async_create_entry(title=DEFAULT_TITLE, data=user_input) return self.async_create_entry(title=DEFAULT_TITLE, data=user_input)
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=SNAPCAST_SCHEMA, errors=errors step_id="user", data_schema=SNAPCAST_SCHEMA, errors=errors

View File

@ -5,8 +5,19 @@ from __future__ import annotations
import binascii import binascii
import logging import logging
from pysnmp.entity import config as cfg from pysnmp.error import PySnmpError
from pysnmp.entity.rfc3413.oneliner import cmdgen from pysnmp.hlapi.asyncio import (
CommunityData,
ContextData,
ObjectIdentity,
ObjectType,
SnmpEngine,
Udp6TransportTarget,
UdpTransportTarget,
UsmUserData,
bulkWalkCmd,
isEndOfMib,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.components.device_tracker import ( from homeassistant.components.device_tracker import (
@ -24,7 +35,13 @@ from .const import (
CONF_BASEOID, CONF_BASEOID,
CONF_COMMUNITY, CONF_COMMUNITY,
CONF_PRIV_KEY, CONF_PRIV_KEY,
DEFAULT_AUTH_PROTOCOL,
DEFAULT_COMMUNITY, DEFAULT_COMMUNITY,
DEFAULT_PORT,
DEFAULT_PRIV_PROTOCOL,
DEFAULT_TIMEOUT,
DEFAULT_VERSION,
SNMP_VERSIONS,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -40,9 +57,12 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend(
) )
def get_scanner(hass: HomeAssistant, config: ConfigType) -> SnmpScanner | None: async def async_get_scanner(
hass: HomeAssistant, config: ConfigType
) -> SnmpScanner | None:
"""Validate the configuration and return an SNMP scanner.""" """Validate the configuration and return an SNMP scanner."""
scanner = SnmpScanner(config[DOMAIN]) scanner = SnmpScanner(config[DOMAIN])
await scanner.async_init()
return scanner if scanner.success_init else None return scanner if scanner.success_init else None
@ -51,39 +71,75 @@ class SnmpScanner(DeviceScanner):
"""Queries any SNMP capable Access Point for connected devices.""" """Queries any SNMP capable Access Point for connected devices."""
def __init__(self, config): def __init__(self, config):
"""Initialize the scanner.""" """Initialize the scanner and test the target device."""
host = config[CONF_HOST]
community = config[CONF_COMMUNITY]
baseoid = config[CONF_BASEOID]
authkey = config.get(CONF_AUTH_KEY)
authproto = DEFAULT_AUTH_PROTOCOL
privkey = config.get(CONF_PRIV_KEY)
privproto = DEFAULT_PRIV_PROTOCOL
self.snmp = cmdgen.CommandGenerator() try:
# Try IPv4 first.
target = UdpTransportTarget((host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT)
except PySnmpError:
# Then try IPv6.
try:
target = Udp6TransportTarget(
(host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT
)
except PySnmpError as err:
_LOGGER.error("Invalid SNMP host: %s", err)
return
self.host = cmdgen.UdpTransportTarget((config[CONF_HOST], 161)) if authkey is not None or privkey is not None:
if CONF_AUTH_KEY not in config or CONF_PRIV_KEY not in config: if not authkey:
self.auth = cmdgen.CommunityData(config[CONF_COMMUNITY]) authproto = "none"
if not privkey:
privproto = "none"
request_args = [
SnmpEngine(),
UsmUserData(
community,
authKey=authkey or None,
privKey=privkey or None,
authProtocol=authproto,
privProtocol=privproto,
),
target,
ContextData(),
]
else: else:
self.auth = cmdgen.UsmUserData( request_args = [
config[CONF_COMMUNITY], SnmpEngine(),
config[CONF_AUTH_KEY], CommunityData(community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION]),
config[CONF_PRIV_KEY], target,
authProtocol=cfg.usmHMACSHAAuthProtocol, ContextData(),
privProtocol=cfg.usmAesCfb128Protocol, ]
)
self.baseoid = cmdgen.MibVariable(config[CONF_BASEOID])
self.last_results = []
# Test the router is accessible self.request_args = request_args
data = self.get_snmp_data() self.baseoid = baseoid
self.last_results = []
self.success_init = False
async def async_init(self):
"""Make a one-off read to check if the target device is reachable and readable."""
data = await self.async_get_snmp_data()
self.success_init = data is not None self.success_init = data is not None
def scan_devices(self): async def async_scan_devices(self):
"""Scan for new devices and return a list with found device IDs.""" """Scan for new devices and return a list with found device IDs."""
self._update_info() await self._async_update_info()
return [client["mac"] for client in self.last_results if client.get("mac")] return [client["mac"] for client in self.last_results if client.get("mac")]
def get_device_name(self, device): async def async_get_device_name(self, device):
"""Return the name of the given device or None if we don't know.""" """Return the name of the given device or None if we don't know."""
# We have no names # We have no names
return None return None
def _update_info(self): async def _async_update_info(self):
"""Ensure the information from the device is up to date. """Ensure the information from the device is up to date.
Return boolean if scanning successful. Return boolean if scanning successful.
@ -91,38 +147,42 @@ class SnmpScanner(DeviceScanner):
if not self.success_init: if not self.success_init:
return False return False
if not (data := self.get_snmp_data()): if not (data := await self.async_get_snmp_data()):
return False return False
self.last_results = data self.last_results = data
return True return True
def get_snmp_data(self): async def async_get_snmp_data(self):
"""Fetch MAC addresses from access point via SNMP.""" """Fetch MAC addresses from access point via SNMP."""
devices = [] devices = []
errindication, errstatus, errindex, restable = self.snmp.nextCmd( walker = bulkWalkCmd(
self.auth, self.host, self.baseoid *self.request_args,
0,
50,
ObjectType(ObjectIdentity(self.baseoid)),
lexicographicMode=False,
) )
async for errindication, errstatus, errindex, res in walker:
if errindication:
_LOGGER.error("SNMPLIB error: %s", errindication)
return
if errstatus:
_LOGGER.error(
"SNMP error: %s at %s",
errstatus.prettyPrint(),
errindex and res[int(errindex) - 1][0] or "?",
)
return
if errindication: for _oid, value in res:
_LOGGER.error("SNMPLIB error: %s", errindication) if not isEndOfMib(res):
return try:
if errstatus: mac = binascii.hexlify(value.asOctets()).decode("utf-8")
_LOGGER.error( except AttributeError:
"SNMP error: %s at %s", continue
errstatus.prettyPrint(), _LOGGER.debug("Found MAC address: %s", mac)
errindex and restable[int(errindex) - 1][0] or "?", mac = ":".join([mac[i : i + 2] for i in range(0, len(mac), 2)])
) devices.append({"mac": mac})
return
for resrow in restable:
for _, val in resrow:
try:
mac = binascii.hexlify(val.asOctets()).decode("utf-8")
except AttributeError:
continue
_LOGGER.debug("Found MAC address: %s", mac)
mac = ":".join([mac[i : i + 2] for i in range(0, len(mac), 2)])
devices.append({"mac": mac})
return devices return devices

View File

@ -1,7 +1,7 @@
{ {
"domain": "snmp", "domain": "snmp",
"name": "SNMP", "name": "SNMP",
"codeowners": [], "codeowners": ["@nmaggioni"],
"documentation": "https://www.home-assistant.io/integrations/snmp", "documentation": "https://www.home-assistant.io/integrations/snmp",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyasn1", "pysmi", "pysnmp"], "loggers": ["pyasn1", "pysmi", "pysnmp"],

View File

@ -116,7 +116,7 @@ class SynoDSMSecurityBinarySensor(SynoDSMBinarySensor):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if entity is available.""" """Return True if entity is available."""
return bool(self._api.security) return bool(self._api.security) and super().available
@property @property
def extra_state_attributes(self) -> dict[str, str]: def extra_state_attributes(self) -> dict[str, str]:

View File

@ -108,7 +108,7 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return the availability of the camera.""" """Return the availability of the camera."""
return self.camera_data.is_enabled and self.coordinator.last_update_success return self.camera_data.is_enabled and super().available
@property @property
def is_recording(self) -> bool: def is_recording(self) -> bool:

View File

@ -286,18 +286,7 @@ class SynoApi:
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update function for updating API information.""" """Update function for updating API information."""
try: await self._update()
await self._update()
except SYNOLOGY_CONNECTION_EXCEPTIONS as err:
LOGGER.debug(
"Connection error during update of '%s' with exception: %s",
self._entry.unique_id,
err,
)
LOGGER.warning(
"Connection error during update, fallback by reloading the entry"
)
await self._hass.config_entries.async_reload(self._entry.entry_id)
async def _update(self) -> None: async def _update(self) -> None:
"""Update function for updating API information.""" """Update function for updating API information."""

View File

@ -366,7 +366,7 @@ class SynoDSMUtilSensor(SynoDSMSensor):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if entity is available.""" """Return True if entity is available."""
return bool(self._api.utilisation) return bool(self._api.utilisation) and super().available
class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor):

View File

@ -98,7 +98,7 @@ class SynoDSMSurveillanceHomeModeToggle(
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if entity is available.""" """Return True if entity is available."""
return bool(self._api.surveillance_station) return bool(self._api.surveillance_station) and super().available
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:

View File

@ -59,7 +59,7 @@ class SynoDSMUpdateEntity(
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if entity is available.""" """Return True if entity is available."""
return bool(self._api.upgrade) return bool(self._api.upgrade) and super().available
@property @property
def installed_version(self) -> str | None: def installed_version(self) -> str | None:

View File

@ -325,12 +325,12 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self._end = start + self._remaining self._end = start + self._remaining
self.async_write_ha_state()
self.hass.bus.async_fire(event, {ATTR_ENTITY_ID: self.entity_id}) self.hass.bus.async_fire(event, {ATTR_ENTITY_ID: self.entity_id})
self._listener = async_track_point_in_utc_time( self._listener = async_track_point_in_utc_time(
self.hass, self._async_finished, self._end self.hass, self._async_finished, self._end
) )
self.async_write_ha_state()
@callback @callback
def async_change(self, duration: timedelta) -> None: def async_change(self, duration: timedelta) -> None:
@ -351,11 +351,11 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self._listener() self._listener()
self._end += duration self._end += duration
self._remaining = self._end - dt_util.utcnow().replace(microsecond=0) self._remaining = self._end - dt_util.utcnow().replace(microsecond=0)
self.async_write_ha_state()
self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id}) self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id})
self._listener = async_track_point_in_utc_time( self._listener = async_track_point_in_utc_time(
self.hass, self._async_finished, self._end self.hass, self._async_finished, self._end
) )
self.async_write_ha_state()
@callback @callback
def async_pause(self) -> None: def async_pause(self) -> None:
@ -368,8 +368,8 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self._remaining = self._end - dt_util.utcnow().replace(microsecond=0) self._remaining = self._end - dt_util.utcnow().replace(microsecond=0)
self._state = STATUS_PAUSED self._state = STATUS_PAUSED
self._end = None self._end = None
self.hass.bus.async_fire(EVENT_TIMER_PAUSED, {ATTR_ENTITY_ID: self.entity_id})
self.async_write_ha_state() self.async_write_ha_state()
self.hass.bus.async_fire(EVENT_TIMER_PAUSED, {ATTR_ENTITY_ID: self.entity_id})
@callback @callback
def async_cancel(self) -> None: def async_cancel(self) -> None:
@ -381,10 +381,10 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self._end = None self._end = None
self._remaining = None self._remaining = None
self._running_duration = self._configured_duration self._running_duration = self._configured_duration
self.async_write_ha_state()
self.hass.bus.async_fire( self.hass.bus.async_fire(
EVENT_TIMER_CANCELLED, {ATTR_ENTITY_ID: self.entity_id} EVENT_TIMER_CANCELLED, {ATTR_ENTITY_ID: self.entity_id}
) )
self.async_write_ha_state()
@callback @callback
def async_finish(self) -> None: def async_finish(self) -> None:
@ -400,11 +400,11 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self._end = None self._end = None
self._remaining = None self._remaining = None
self._running_duration = self._configured_duration self._running_duration = self._configured_duration
self.async_write_ha_state()
self.hass.bus.async_fire( self.hass.bus.async_fire(
EVENT_TIMER_FINISHED, EVENT_TIMER_FINISHED,
{ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()},
) )
self.async_write_ha_state()
@callback @callback
def _async_finished(self, time: datetime) -> None: def _async_finished(self, time: datetime) -> None:
@ -418,11 +418,11 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self._end = None self._end = None
self._remaining = None self._remaining = None
self._running_duration = self._configured_duration self._running_duration = self._configured_duration
self.async_write_ha_state()
self.hass.bus.async_fire( self.hass.bus.async_fire(
EVENT_TIMER_FINISHED, EVENT_TIMER_FINISHED,
{ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()},
) )
self.async_write_ha_state()
async def async_update_config(self, config: ConfigType) -> None: async def async_update_config(self, config: ConfigType) -> None:
"""Handle when the config is updated.""" """Handle when the config is updated."""

View File

@ -578,7 +578,13 @@ class UtilityMeterSensor(RestoreSensor):
async def async_reset_meter(self, entity_id): async def async_reset_meter(self, entity_id):
"""Reset meter.""" """Reset meter."""
if self._tariff is not None and self._tariff_entity != entity_id: if self._tariff_entity is not None and self._tariff_entity != entity_id:
return
if (
self._tariff_entity is None
and entity_id is not None
and self.entity_id != entity_id
):
return return
_LOGGER.debug("Reset utility meter <%s>", self.entity_id) _LOGGER.debug("Reset utility meter <%s>", self.entity_id)
self._last_reset = dt_util.utcnow() self._last_reset = dt_util.utcnow()

View File

@ -13,7 +13,7 @@
"velbus-packet", "velbus-packet",
"velbus-protocol" "velbus-protocol"
], ],
"requirements": ["velbus-aio==2024.4.0"], "requirements": ["velbus-aio==2024.4.1"],
"usb": [ "usb": [
{ {
"vid": "10CF", "vid": "10CF",

View File

@ -6,5 +6,5 @@
"dependencies": ["auth", "application_credentials"], "dependencies": ["auth", "application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/yolink", "documentation": "https://www.home-assistant.io/integrations/yolink",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"requirements": ["yolink-api==0.4.1"] "requirements": ["yolink-api==0.4.2"]
} }

View File

@ -18,7 +18,7 @@ from .util.signal_type import SignalType
APPLICATION_NAME: Final = "HomeAssistant" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024 MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 4 MINOR_VERSION: Final = 4
PATCH_VERSION: Final = "1" PATCH_VERSION: Final = "2"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)

View File

@ -631,7 +631,16 @@ class EntityPlatform:
if ( if (
(self.config_entry and self.config_entry.pref_disable_polling) (self.config_entry and self.config_entry.pref_disable_polling)
or self._async_unsub_polling is not None or self._async_unsub_polling is not None
or not any(entity.should_poll for entity in entities) or not any(
# Entity may have failed to add or called `add_to_platform_abort`
# so we check if the entity is in self.entities before
# checking `entity.should_poll` since `should_poll` may need to
# check `self.hass` which will be `None` if the entity did not add
entity.entity_id
and entity.entity_id in self.entities
and entity.should_poll
for entity in entities
)
): ):
return return

View File

@ -286,6 +286,9 @@ STATIC_VALIDATION_ACTION_TYPES = (
cv.SCRIPT_ACTION_WAIT_TEMPLATE, cv.SCRIPT_ACTION_WAIT_TEMPLATE,
) )
REPEAT_WARN_ITERATIONS = 5000
REPEAT_TERMINATE_ITERATIONS = 10000
async def async_validate_actions_config( async def async_validate_actions_config(
hass: HomeAssistant, actions: list[ConfigType] hass: HomeAssistant, actions: list[ConfigType]
@ -846,6 +849,7 @@ class _ScriptRun:
# pylint: disable-next=protected-access # pylint: disable-next=protected-access
script = self._script._get_repeat_script(self._step) script = self._script._get_repeat_script(self._step)
warned_too_many_loops = False
async def async_run_sequence(iteration, extra_msg=""): async def async_run_sequence(iteration, extra_msg=""):
self._log("Repeating %s: Iteration %i%s", description, iteration, extra_msg) self._log("Repeating %s: Iteration %i%s", description, iteration, extra_msg)
@ -916,6 +920,36 @@ class _ScriptRun:
_LOGGER.warning("Error in 'while' evaluation:\n%s", ex) _LOGGER.warning("Error in 'while' evaluation:\n%s", ex)
break break
if iteration > 1:
if iteration > REPEAT_WARN_ITERATIONS:
if not warned_too_many_loops:
warned_too_many_loops = True
_LOGGER.warning(
"While condition %s in script `%s` looped %s times",
repeat[CONF_WHILE],
self._script.name,
REPEAT_WARN_ITERATIONS,
)
if iteration > REPEAT_TERMINATE_ITERATIONS:
_LOGGER.critical(
"While condition %s in script `%s` "
"terminated because it looped %s times",
repeat[CONF_WHILE],
self._script.name,
REPEAT_TERMINATE_ITERATIONS,
)
raise _AbortScript(
f"While condition {repeat[CONF_WHILE]} "
"terminated because it looped "
f" {REPEAT_TERMINATE_ITERATIONS} times"
)
# If the user creates a script with a tight loop,
# yield to the event loop so the system stays
# responsive while all the cpu time is consumed.
await asyncio.sleep(0)
await async_run_sequence(iteration) await async_run_sequence(iteration)
elif CONF_UNTIL in repeat: elif CONF_UNTIL in repeat:
@ -934,6 +968,35 @@ class _ScriptRun:
_LOGGER.warning("Error in 'until' evaluation:\n%s", ex) _LOGGER.warning("Error in 'until' evaluation:\n%s", ex)
break break
if iteration >= REPEAT_WARN_ITERATIONS:
if not warned_too_many_loops:
warned_too_many_loops = True
_LOGGER.warning(
"Until condition %s in script `%s` looped %s times",
repeat[CONF_UNTIL],
self._script.name,
REPEAT_WARN_ITERATIONS,
)
if iteration >= REPEAT_TERMINATE_ITERATIONS:
_LOGGER.critical(
"Until condition %s in script `%s` "
"terminated because it looped %s times",
repeat[CONF_UNTIL],
self._script.name,
REPEAT_TERMINATE_ITERATIONS,
)
raise _AbortScript(
f"Until condition {repeat[CONF_UNTIL]} "
"terminated because it looped "
f"{REPEAT_TERMINATE_ITERATIONS} times"
)
# If the user creates a script with a tight loop,
# yield to the event loop so the system stays responsive
# while all the cpu time is consumed.
await asyncio.sleep(0)
if saved_repeat_vars: if saved_repeat_vars:
self._variables["repeat"] = saved_repeat_vars self._variables["repeat"] = saved_repeat_vars
else: else:

View File

@ -27,7 +27,7 @@ fnv-hash-fast==0.5.0
ha-av==10.1.1 ha-av==10.1.1
ha-ffmpeg==3.2.0 ha-ffmpeg==3.2.0
habluetooth==2.4.2 habluetooth==2.4.2
hass-nabucasa==0.79.0 hass-nabucasa==0.78.0
hassil==1.6.1 hassil==1.6.1
home-assistant-bluetooth==1.12.0 home-assistant-bluetooth==1.12.0
home-assistant-frontend==20240404.1 home-assistant-frontend==20240404.1

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "homeassistant" name = "homeassistant"
version = "2024.4.1" version = "2024.4.2"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3." description = "Open-source home automation platform running on Python 3."
readme = "README.rst" readme = "README.rst"
@ -38,7 +38,7 @@ dependencies = [
"fnv-hash-fast==0.5.0", "fnv-hash-fast==0.5.0",
# hass-nabucasa is imported by helpers which don't depend on the cloud # hass-nabucasa is imported by helpers which don't depend on the cloud
# integration # integration
"hass-nabucasa==0.79.0", "hass-nabucasa==0.78.0",
# When bumping httpx, please check the version pins of # When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all # httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.27.0", "httpx==0.27.0",

View File

@ -16,7 +16,7 @@ bcrypt==4.1.2
certifi>=2021.5.30 certifi>=2021.5.30
ciso8601==2.3.1 ciso8601==2.3.1
fnv-hash-fast==0.5.0 fnv-hash-fast==0.5.0
hass-nabucasa==0.79.0 hass-nabucasa==0.78.0
httpx==0.27.0 httpx==0.27.0
home-assistant-bluetooth==1.12.0 home-assistant-bluetooth==1.12.0
ifaddr==0.2.0 ifaddr==0.2.0

View File

@ -514,7 +514,7 @@ aurorapy==0.2.7
# avion==0.10 # avion==0.10
# homeassistant.components.axis # homeassistant.components.axis
axis==60 axis==61
# homeassistant.components.azure_event_hub # homeassistant.components.azure_event_hub
azure-eventhub==5.11.1 azure-eventhub==5.11.1
@ -609,7 +609,7 @@ bring-api==0.5.7
broadlink==0.18.3 broadlink==0.18.3
# homeassistant.components.brother # homeassistant.components.brother
brother==4.0.2 brother==4.1.0
# homeassistant.components.brottsplatskartan # homeassistant.components.brottsplatskartan
brottsplatskartan==1.0.5 brottsplatskartan==1.0.5
@ -899,7 +899,7 @@ freesms==0.2.0
fritzconnection[qr]==1.13.2 fritzconnection[qr]==1.13.2
# homeassistant.components.fyta # homeassistant.components.fyta
fyta_cli==0.3.3 fyta_cli==0.3.5
# homeassistant.components.google_translate # homeassistant.components.google_translate
gTTS==2.2.4 gTTS==2.2.4
@ -1037,7 +1037,7 @@ habitipy==0.2.0
habluetooth==2.4.2 habluetooth==2.4.2
# homeassistant.components.cloud # homeassistant.components.cloud
hass-nabucasa==0.79.0 hass-nabucasa==0.78.0
# homeassistant.components.splunk # homeassistant.components.splunk
hass-splunk==0.1.1 hass-splunk==0.1.1
@ -1943,7 +1943,7 @@ pylibrespot-java==0.1.1
pylitejet==0.6.2 pylitejet==0.6.2
# homeassistant.components.litterrobot # homeassistant.components.litterrobot
pylitterbot==2023.4.9 pylitterbot==2023.4.11
# homeassistant.components.lutron_caseta # homeassistant.components.lutron_caseta
pylutron-caseta==0.20.0 pylutron-caseta==0.20.0
@ -1991,7 +1991,7 @@ pynetgear==0.10.10
pynetio==0.1.9.1 pynetio==0.1.9.1
# homeassistant.components.nobo_hub # homeassistant.components.nobo_hub
pynobo==1.8.0 pynobo==1.8.1
# homeassistant.components.nuki # homeassistant.components.nuki
pynuki==1.6.3 pynuki==1.6.3
@ -2798,7 +2798,7 @@ vallox-websocket-api==5.1.1
vehicle==2.2.1 vehicle==2.2.1
# homeassistant.components.velbus # homeassistant.components.velbus
velbus-aio==2024.4.0 velbus-aio==2024.4.1
# homeassistant.components.venstar # homeassistant.components.venstar
venstarcolortouch==0.19 venstarcolortouch==0.19
@ -2880,7 +2880,7 @@ xiaomi-ble==0.28.0
xknx==2.12.2 xknx==2.12.2
# homeassistant.components.knx # homeassistant.components.knx
xknxproject==3.7.0 xknxproject==3.7.1
# homeassistant.components.bluesound # homeassistant.components.bluesound
# homeassistant.components.fritz # homeassistant.components.fritz
@ -2910,7 +2910,7 @@ yeelight==0.7.14
yeelightsunflower==0.0.10 yeelightsunflower==0.0.10
# homeassistant.components.yolink # homeassistant.components.yolink
yolink-api==0.4.1 yolink-api==0.4.2
# homeassistant.components.youless # homeassistant.components.youless
youless-api==1.0.1 youless-api==1.0.1

View File

@ -454,7 +454,7 @@ auroranoaa==0.0.3
aurorapy==0.2.7 aurorapy==0.2.7
# homeassistant.components.axis # homeassistant.components.axis
axis==60 axis==61
# homeassistant.components.azure_event_hub # homeassistant.components.azure_event_hub
azure-eventhub==5.11.1 azure-eventhub==5.11.1
@ -520,7 +520,7 @@ bring-api==0.5.7
broadlink==0.18.3 broadlink==0.18.3
# homeassistant.components.brother # homeassistant.components.brother
brother==4.0.2 brother==4.1.0
# homeassistant.components.brottsplatskartan # homeassistant.components.brottsplatskartan
brottsplatskartan==1.0.5 brottsplatskartan==1.0.5
@ -731,7 +731,7 @@ freebox-api==1.1.0
fritzconnection[qr]==1.13.2 fritzconnection[qr]==1.13.2
# homeassistant.components.fyta # homeassistant.components.fyta
fyta_cli==0.3.3 fyta_cli==0.3.5
# homeassistant.components.google_translate # homeassistant.components.google_translate
gTTS==2.2.4 gTTS==2.2.4
@ -848,7 +848,7 @@ habitipy==0.2.0
habluetooth==2.4.2 habluetooth==2.4.2
# homeassistant.components.cloud # homeassistant.components.cloud
hass-nabucasa==0.79.0 hass-nabucasa==0.78.0
# homeassistant.components.conversation # homeassistant.components.conversation
hassil==1.6.1 hassil==1.6.1
@ -1509,7 +1509,7 @@ pylibrespot-java==0.1.1
pylitejet==0.6.2 pylitejet==0.6.2
# homeassistant.components.litterrobot # homeassistant.components.litterrobot
pylitterbot==2023.4.9 pylitterbot==2023.4.11
# homeassistant.components.lutron_caseta # homeassistant.components.lutron_caseta
pylutron-caseta==0.20.0 pylutron-caseta==0.20.0
@ -1545,7 +1545,7 @@ pymysensors==0.24.0
pynetgear==0.10.10 pynetgear==0.10.10
# homeassistant.components.nobo_hub # homeassistant.components.nobo_hub
pynobo==1.8.0 pynobo==1.8.1
# homeassistant.components.nuki # homeassistant.components.nuki
pynuki==1.6.3 pynuki==1.6.3
@ -2154,7 +2154,7 @@ vallox-websocket-api==5.1.1
vehicle==2.2.1 vehicle==2.2.1
# homeassistant.components.velbus # homeassistant.components.velbus
velbus-aio==2024.4.0 velbus-aio==2024.4.1
# homeassistant.components.venstar # homeassistant.components.venstar
venstarcolortouch==0.19 venstarcolortouch==0.19
@ -2224,7 +2224,7 @@ xiaomi-ble==0.28.0
xknx==2.12.2 xknx==2.12.2
# homeassistant.components.knx # homeassistant.components.knx
xknxproject==3.7.0 xknxproject==3.7.1
# homeassistant.components.bluesound # homeassistant.components.bluesound
# homeassistant.components.fritz # homeassistant.components.fritz
@ -2248,7 +2248,7 @@ yalexs==2.0.0
yeelight==0.7.14 yeelight==0.7.14
# homeassistant.components.yolink # homeassistant.components.yolink
yolink-api==0.4.1 yolink-api==0.4.2
# homeassistant.components.youless # homeassistant.components.youless
youless-api==1.0.1 youless-api==1.0.1

View File

@ -39,6 +39,8 @@ def run_download_docker():
CORE_PROJECT_ID, CORE_PROJECT_ID,
"--original-filenames=false", "--original-filenames=false",
"--replace-breaks=false", "--replace-breaks=false",
"--filter-data",
"nonfuzzy",
"--export-empty-as", "--export-empty-as",
"skip", "skip",
"--format", "--format",

View File

@ -33,6 +33,7 @@ ROBOT_4_DATA = {
"wifiRssi": -53.0, "wifiRssi": -53.0,
"unitPowerType": "AC", "unitPowerType": "AC",
"catWeight": 12.0, "catWeight": 12.0,
"displayCode": "DC_MODE_IDLE",
"unitTimezone": "America/New_York", "unitTimezone": "America/New_York",
"unitTime": None, "unitTime": None,
"cleanCycleWaitTime": 15, "cleanCycleWaitTime": 15,
@ -66,7 +67,7 @@ ROBOT_4_DATA = {
"isDFIResetPending": False, "isDFIResetPending": False,
"DFINumberOfCycles": 104, "DFINumberOfCycles": 104,
"DFILevelPercent": 76, "DFILevelPercent": 76,
"isDFIFull": True, "isDFIFull": False,
"DFIFullCounter": 3, "DFIFullCounter": 3,
"DFITriggerCount": 42, "DFITriggerCount": 42,
"litterLevel": 460, "litterLevel": 460,

View File

@ -86,7 +86,7 @@ async def test_litter_robot_sensor(
assert sensor.state == "2022-09-17T12:06:37+00:00" assert sensor.state == "2022-09-17T12:06:37+00:00"
assert sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP assert sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP
sensor = hass.states.get("sensor.test_status_code") sensor = hass.states.get("sensor.test_status_code")
assert sensor.state == "dfs" assert sensor.state == "rdy"
assert sensor.attributes["device_class"] == SensorDeviceClass.ENUM assert sensor.attributes["device_class"] == SensorDeviceClass.ENUM
sensor = hass.states.get("sensor.test_litter_level") sensor = hass.states.get("sensor.test_litter_level")
assert sensor.state == "70.0" assert sensor.state == "70.0"

View File

@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from unittest.mock import MagicMock from unittest.mock import MagicMock
from pylitterbot import Robot
import pytest import pytest
from homeassistant.components.litterrobot import DOMAIN from homeassistant.components.litterrobot import DOMAIN
@ -16,6 +17,7 @@ from homeassistant.components.vacuum import (
SERVICE_STOP, SERVICE_STOP,
STATE_DOCKED, STATE_DOCKED,
STATE_ERROR, STATE_ERROR,
STATE_PAUSED,
) )
from homeassistant.const import ATTR_ENTITY_ID from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -96,6 +98,30 @@ async def test_vacuum_with_error(
assert vacuum.state == STATE_ERROR assert vacuum.state == STATE_ERROR
@pytest.mark.parametrize(
("robot_data", "expected_state"),
[
({"displayCode": "DC_CAT_DETECT"}, STATE_DOCKED),
({"isDFIFull": True}, STATE_ERROR),
({"robotCycleState": "CYCLE_STATE_CAT_DETECT"}, STATE_PAUSED),
],
)
async def test_vacuum_states(
hass: HomeAssistant,
mock_account_with_litterrobot_4: MagicMock,
robot_data: dict[str, str | bool],
expected_state: str,
) -> None:
"""Test sending commands to the switch."""
await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN)
robot: Robot = mock_account_with_litterrobot_4.robots[0]
robot._update_data(robot_data, partial=True)
vacuum = hass.states.get(VACUUM_ENTITY_ID)
assert vacuum
assert vacuum.state == expected_state
@pytest.mark.parametrize( @pytest.mark.parametrize(
("service", "command", "extra"), ("service", "command", "extra"),
[ [

View File

@ -45,7 +45,7 @@ from homeassistant.const import (
EVENT_STATE_CHANGED, EVENT_STATE_CHANGED,
SERVICE_RELOAD, SERVICE_RELOAD,
) )
from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.core import Context, CoreState, Event, HomeAssistant, State, callback
from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.exceptions import HomeAssistantError, Unauthorized
from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.restore_state import StoredState, async_get from homeassistant.helpers.restore_state import StoredState, async_get
@ -156,11 +156,12 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
assert state assert state
assert state.state == STATUS_IDLE assert state.state == STATUS_IDLE
results = [] results: list[tuple[Event, str]] = []
def fake_event_listener(event): @callback
def fake_event_listener(event: Event):
"""Fake event listener for trigger.""" """Fake event listener for trigger."""
results.append(event) results.append((event, hass.states.get("timer.test1").state))
hass.bus.async_listen(EVENT_TIMER_STARTED, fake_event_listener) hass.bus.async_listen(EVENT_TIMER_STARTED, fake_event_listener)
hass.bus.async_listen(EVENT_TIMER_RESTARTED, fake_event_listener) hass.bus.async_listen(EVENT_TIMER_RESTARTED, fake_event_listener)
@ -262,7 +263,10 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
if step["event"] is not None: if step["event"] is not None:
expected_events += 1 expected_events += 1
assert results[-1].event_type == step["event"] last_result = results[-1]
event, state = last_result
assert event.event_type == step["event"]
assert state == step["state"]
assert len(results) == expected_events assert len(results) == expected_events
@ -404,6 +408,7 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
results = [] results = []
@callback
def fake_event_listener(event): def fake_event_listener(event):
"""Fake event listener for trigger.""" """Fake event listener for trigger."""
results.append(event) results.append(event)
@ -580,6 +585,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
results = [] results = []
@callback
def fake_event_listener(event): def fake_event_listener(event):
"""Fake event listener for trigger.""" """Fake event listener for trigger."""
results.append(event) results.append(event)
@ -647,6 +653,7 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
results = [] results = []
@callback
def fake_event_listener(event): def fake_event_listener(event):
"""Fake event listener for trigger.""" """Fake event listener for trigger."""
results.append(event) results.append(event)

View File

@ -983,6 +983,139 @@ async def test_service_reset_no_tariffs(
assert state.attributes.get("last_period") == "3" assert state.attributes.get("last_period") == "3"
@pytest.mark.parametrize(
("yaml_config", "config_entry_configs"),
[
(
{
"utility_meter": {
"energy_bill": {
"source": "sensor.energy",
},
"water_bill": {
"source": "sensor.water",
},
},
},
None,
),
(
None,
[
{
"cycle": "none",
"delta_values": False,
"name": "Energy bill",
"net_consumption": False,
"offset": 0,
"periodically_resetting": True,
"source": "sensor.energy",
"tariffs": [],
},
{
"cycle": "none",
"delta_values": False,
"name": "Water bill",
"net_consumption": False,
"offset": 0,
"periodically_resetting": True,
"source": "sensor.water",
"tariffs": [],
},
],
),
],
)
async def test_service_reset_no_tariffs_correct_with_multi(
hass: HomeAssistant, yaml_config, config_entry_configs
) -> None:
"""Test complex utility sensor service reset for multiple sensors with no tarrifs.
See GitHub issue #114864: Service "utility_meter.reset" affects all meters.
"""
# Home assistant is not runnit yet
hass.state = CoreState.not_running
last_reset = "2023-10-01T00:00:00+00:00"
mock_restore_cache_with_extra_data(
hass,
[
(
State(
"sensor.energy_bill",
"3",
attributes={
ATTR_LAST_RESET: last_reset,
},
),
{},
),
(
State(
"sensor.water_bill",
"6",
attributes={
ATTR_LAST_RESET: last_reset,
},
),
{},
),
],
)
if yaml_config:
assert await async_setup_component(hass, DOMAIN, yaml_config)
await hass.async_block_till_done()
else:
for entry in config_entry_configs:
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options=entry,
title=entry["name"],
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("sensor.energy_bill")
assert state
assert state.state == "3"
assert state.attributes.get("last_reset") == last_reset
assert state.attributes.get("last_period") == "0"
state = hass.states.get("sensor.water_bill")
assert state
assert state.state == "6"
assert state.attributes.get("last_reset") == last_reset
assert state.attributes.get("last_period") == "0"
now = dt_util.utcnow()
with freeze_time(now):
await hass.services.async_call(
domain=DOMAIN,
service=SERVICE_RESET,
service_data={},
target={"entity_id": "sensor.energy_bill"},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get("sensor.energy_bill")
assert state
assert state.state == "0"
assert state.attributes.get("last_reset") == now.isoformat()
assert state.attributes.get("last_period") == "3"
state = hass.states.get("sensor.water_bill")
assert state
assert state.state == "6"
assert state.attributes.get("last_reset") == last_reset
assert state.attributes.get("last_period") == "0"
@pytest.mark.parametrize( @pytest.mark.parametrize(
("yaml_config", "config_entry_config"), ("yaml_config", "config_entry_config"),
[ [

View File

@ -5,7 +5,7 @@ from collections.abc import Iterable
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
from unittest.mock import ANY, Mock, patch from unittest.mock import ANY, AsyncMock, Mock, patch
import pytest import pytest
@ -78,6 +78,40 @@ async def test_polling_only_updates_entities_it_should_poll(
assert poll_ent.async_update.called assert poll_ent.async_update.called
async def test_polling_check_works_if_entity_add_fails(
hass: HomeAssistant,
) -> None:
"""Test the polling check works if an entity add fails."""
component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20))
await component.async_setup({})
class MockEntityNeedsSelfHassInShouldPoll(MockEntity):
"""Mock entity that needs self.hass in should_poll."""
@property
def should_poll(self) -> bool:
"""Return True if entity has to be polled."""
return self.hass.data is not None
working_poll_ent = MockEntityNeedsSelfHassInShouldPoll(should_poll=True)
working_poll_ent.async_update = AsyncMock()
broken_poll_ent = MockEntityNeedsSelfHassInShouldPoll(should_poll=True)
broken_poll_ent.async_update = AsyncMock(side_effect=Exception("Broken"))
await component.async_add_entities(
[broken_poll_ent, working_poll_ent], update_before_add=True
)
working_poll_ent.async_update.reset_mock()
broken_poll_ent.async_update.reset_mock()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20))
await hass.async_block_till_done(wait_background_tasks=True)
assert not broken_poll_ent.async_update.called
assert working_poll_ent.async_update.called
async def test_polling_disabled_by_config_entry(hass: HomeAssistant) -> None: async def test_polling_disabled_by_config_entry(hass: HomeAssistant) -> None:
"""Test the polling of only updated entities.""" """Test the polling of only updated entities."""
entity_platform = MockEntityPlatform(hass) entity_platform = MockEntityPlatform(hass)

View File

@ -2837,6 +2837,58 @@ async def test_repeat_nested(
assert_action_trace(expected_trace) assert_action_trace(expected_trace)
@pytest.mark.parametrize(
("condition", "check"), [("while", "above"), ("until", "below")]
)
async def test_repeat_limits(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str, check: str
) -> None:
"""Test limits on repeats prevent the system from hanging."""
event = "test_event"
events = async_capture_events(hass, event)
hass.states.async_set("sensor.test", "0.5")
sequence = {
"repeat": {
"sequence": [
{
"event": event,
},
],
}
}
sequence["repeat"][condition] = {
"condition": "numeric_state",
"entity_id": "sensor.test",
check: "0",
}
with (
patch.object(script, "REPEAT_WARN_ITERATIONS", 5),
patch.object(script, "REPEAT_TERMINATE_ITERATIONS", 10),
):
script_obj = script.Script(
hass, cv.SCRIPT_SCHEMA(sequence), f"Test {condition}", "test_domain"
)
caplog.clear()
caplog.set_level(logging.WARNING)
hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(hass.async_block_till_done(), 1)
title_condition = condition.title()
assert f"{title_condition} condition" in caplog.text
assert f"in script `Test {condition}` looped 5 times" in caplog.text
assert (
f"script `Test {condition}` terminated because it looped 10 times"
in caplog.text
)
assert len(events) == 10
async def test_choose_warning( async def test_choose_warning(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None: ) -> None: