Compare commits

..

52 Commits

Author SHA1 Message Date
Franck Nijhof 03fb4b099c Bump version to 2026.7.0b2 2026-06-27 11:31:01 +00:00
Manu 46a6048f60 Remove ATEN Rack PDU integration (#174940) 2026-06-27 11:29:22 +00:00
Manu c598d2c10e Remove Logentries (#174939) 2026-06-27 11:29:20 +00:00
Raphael Hehl 67bcd7550c Bump uiprotect to 15.3.0 (#174938) 2026-06-27 11:29:18 +00:00
Manu e44e822cec Remove Dovado integration (#174933) 2026-06-27 11:29:16 +00:00
Manu daff150276 Remove Greenwave Reality (#174929) 2026-06-27 11:29:15 +00:00
Michael 1f33859297 Check for supported fan speed modes in Synology DSM (#174925) 2026-06-27 11:29:13 +00:00
Simone Chemelli 512fe8c022 Bump aioamazondevices to 14.1.8 (#174924) 2026-06-27 11:29:11 +00:00
Raphael Hehl 6f038bb5b2 Bump uiprotect to 15.2.0 (#174922) 2026-06-27 11:29:09 +00:00
Allen Porter d0b5162507 Refactor Roborock time platform to use library property APIs (#174921) 2026-06-27 11:29:08 +00:00
Allen Porter 9e9978b6cb Bump voluptuous-openapi to 0.4.1 (#174912) 2026-06-27 11:29:06 +00:00
Jordan Harvey 76feb821f4 Bump pyanglianwater to 3.2.3 (#174902) 2026-06-27 11:29:04 +00:00
Ronald van der Meer cd41529a89 Fix Duco ventilation state select not being created for valve nodes (#174901) 2026-06-27 11:29:02 +00:00
starkillerOG 8a1434332d Bump reolink_aio to 0.21.3 (#174879) 2026-06-27 11:29:00 +00:00
starkillerOG 36b714b513 Add Reolink push command IDs (#174876) 2026-06-27 11:28:59 +00:00
Paulus Schoutsen 46f1e4c957 Fix Roborock time entity crash when timer value is missing (#174873)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 11:28:57 +00:00
Paulus Schoutsen 431dcda092 Fix Roborock number entity crash when volume is None (#174872)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 11:28:55 +00:00
Mick Vleeshouwer c575ef51b9 Set RTS command duration for Overkiz Rexel client (#174863) 2026-06-27 11:28:54 +00:00
Simone Chemelli 87690d2000 Handle all login exceptions in Vodafone Station (#174852)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-27 11:28:52 +00:00
Manu 7afb26b1c0 Remove Mycroft integration (#174849) 2026-06-27 11:28:50 +00:00
Simone Chemelli b6d5af0480 Bump aioamazondevices to 14.1.6 (#174848) 2026-06-27 11:28:48 +00:00
Raphael Hehl f56098df5f Bump uiprotect to 15.1.0 (#174846) 2026-06-27 11:28:46 +00:00
Manu 427dd028f5 Remove ThermoWorks Smoke (#174845) 2026-06-27 11:28:44 +00:00
Ludovic BOUÉ cef9461610 Bump roborock dependencies to 5.21.0 (#174841) 2026-06-27 11:28:42 +00:00
epenet ace5398012 Bump tuya-device-handlers to 0.0.24 (#174840)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-06-27 11:28:41 +00:00
Bram Kragten 3791c83b95 Update frontend to 20260624.1 (#174831) 2026-06-27 11:28:39 +00:00
epenet 4bdfa5c25b Bump tuya-device-sharing-sdk to 0.2.10 (#174827) 2026-06-27 11:28:37 +00:00
Nicolas Mowen 5a60771a14 Handle case where GetLiveContext includes an entity with StrEnum key (#174822) 2026-06-27 11:28:35 +00:00
Erik Montnemery 70aba68326 Fix exception in legacy sun condition (#174811) 2026-06-27 11:28:33 +00:00
Paul Bottein f20f86a067 Fix missing translated names for Xiaomi Miio select entities (#174810) 2026-06-27 11:28:32 +00:00
Erik Montnemery ee0c98e450 Improve tests of sun conditions and triggers (#174805)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-27 11:28:30 +00:00
Rafa PA 907a5c3c6c [aemet] Increase weather update interval to 20 minutes (#174803) 2026-06-27 11:28:28 +00:00
Erik Montnemery a1e1b400f3 Catch errors when evaluating automation conditions (#174799) 2026-06-27 11:28:27 +00:00
Erik Montnemery 1544ae83dd Improve tests of entity limits (#174793) 2026-06-27 11:28:25 +00:00
Raphael Hehl 145c490816 Bump uiprotect to 15.0.0 (#174709) 2026-06-27 11:28:23 +00:00
Samuel Xiao bb7a756f84 Bump switchbot-api to 2.12.0 (#174705) 2026-06-27 11:28:21 +00:00
J. Nick Koston 183e6af8c2 Bump habluetooth to 6.25.1 (#174700) 2026-06-27 11:28:19 +00:00
Franck Nijhof 2b66d045ff Add missing unit of measurement to Home Connect battery sensor (#174694)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-27 11:28:17 +00:00
Michael Hansen 4841329814 Bump intents and fix broken tests (#174689) 2026-06-27 11:28:16 +00:00
J. Nick Koston e710fc8782 Bump habluetooth to 6.24.0 (#174688) 2026-06-27 11:28:14 +00:00
Franck Nijhof 99e18dcdd8 Add delegated charging mode to Renault integration (#174687) 2026-06-27 11:28:12 +00:00
Simone Chemelli eeedf28b6f Fix async_get_entity_id() params for Alexa Devices (#174641) 2026-06-27 11:28:09 +00:00
Arie Catsman 5cca9328d6 Update enphase_envoy diagnostics for pyenphase lib v3.0.0 (#174524) 2026-06-27 11:28:08 +00:00
Erik Montnemery ebf3de3073 Add WS command recorder/entity_options/get (#174134) 2026-06-27 11:28:06 +00:00
Stefan Agner 41e79927d0 Fix hassio job subscribe returning None instead of unsubscribe callback (#174063)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-27 11:28:04 +00:00
Franck Nijhof 4022eb93de Bump version to 2026.7.0b1 2026-06-24 21:29:59 +00:00
Christian Lackas 28171dfe90 Bump homematicip to 2.13.2 (#174673) 2026-06-24 21:29:51 +00:00
Erwin Douna e9af932fbe Vera core group executor job (#174669) 2026-06-24 21:29:49 +00:00
Erwin Douna 3212e0f051 Tami4 group executor job (#174668) 2026-06-24 21:29:47 +00:00
TheJulianJES 87f0720450 Bump zha-quirks to 2.1.0 (#174662) 2026-06-24 21:29:45 +00:00
Brandon Rothweiler 534ff3f3dc Add missing scope and authorize param to Dropbox OAuth (#174587) 2026-06-24 21:29:43 +00:00
Franck Nijhof 1096c8af13 Bump version to 2026.7.0b0 2026-06-24 16:36:39 +00:00
104 changed files with 1402 additions and 1440 deletions
Generated
-1
View File
@@ -181,7 +181,6 @@ CLAUDE.md @home-assistant/core
/tests/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi
/homeassistant/components/atag/ @MatsNL
/tests/components/atag/ @MatsNL
/homeassistant/components/aten_pe/ @mtdcr
/homeassistant/components/atome/ @baqs
/homeassistant/components/august/ @bdraco
/tests/components/august/ @bdraco
@@ -27,7 +27,7 @@ from .const import CONDITIONS_MAP, DOMAIN, FORECAST_MAP
_LOGGER = logging.getLogger(__name__)
API_TIMEOUT: Final[int] = 120
WEATHER_UPDATE_INTERVAL = timedelta(minutes=10)
WEATHER_UPDATE_INTERVAL = timedelta(minutes=20)
type AemetConfigEntry = ConfigEntry[AemetData]
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.1.3"]
"requirements": ["aioamazondevices==14.1.8"]
}
@@ -28,7 +28,7 @@ async def async_update_unique_id(
for serial_num in coordinator.data:
unique_id = f"{serial_num}-{old_key}"
if entity_id := entity_registry.async_get_entity_id(
DOMAIN, platform, unique_id
platform, DOMAIN, unique_id
):
_LOGGER.debug("Updating unique_id for %s", entity_id)
new_unique_id = unique_id.replace(old_key, new_key)
@@ -48,7 +48,7 @@ async def async_remove_entity_from_virtual_group(
for serial_num in coordinator.data:
unique_id = f"{serial_num}-{key}"
entity_id = entity_registry.async_get_entity_id(DOMAIN, platform, unique_id)
entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id)
is_group = coordinator.data[serial_num].device_family == SPEAKER_GROUP_FAMILY
if entity_id and is_group:
entity_registry.async_remove(entity_id)
@@ -70,7 +70,7 @@ async def async_remove_unsupported_notification_sensors(
):
unique_id = f"{serial_num}-{notification_key}"
entity_id = entity_registry.async_get_entity_id(
DOMAIN, SENSOR_DOMAIN, unique_id=unique_id
SENSOR_DOMAIN, DOMAIN, unique_id=unique_id
)
is_unsupported = not coordinator.data[serial_num].notifications_supported
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["pyanglianwater"],
"quality_scale": "bronze",
"requirements": ["pyanglianwater==3.2.2"]
"requirements": ["pyanglianwater==3.2.3"]
}
@@ -1 +0,0 @@
"""The ATEN PE component."""
@@ -1,9 +0,0 @@
{
"domain": "aten_pe",
"name": "ATEN Rack PDU",
"codeowners": ["@mtdcr"],
"documentation": "https://www.home-assistant.io/integrations/aten_pe",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["atenpdu==0.3.6"]
}
-119
View File
@@ -1,119 +0,0 @@
"""The ATEN PE switch component."""
import logging
from typing import Any, override
from atenpdu import AtenPE, AtenPEError
import voluptuous as vol
from homeassistant.components.switch import (
PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA,
SwitchDeviceClass,
SwitchEntity,
)
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
CONF_AUTH_KEY = "auth_key"
CONF_COMMUNITY = "community"
CONF_PRIV_KEY = "priv_key"
DEFAULT_COMMUNITY = "private"
DEFAULT_PORT = "161"
DEFAULT_USERNAME = "administrator"
PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): cv.string,
vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
vol.Optional(CONF_AUTH_KEY): cv.string,
vol.Optional(CONF_PRIV_KEY): cv.string,
}
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the ATEN PE switch."""
node = config[CONF_HOST]
serv = config[CONF_PORT]
dev = AtenPE(
node=node,
serv=serv,
community=config[CONF_COMMUNITY],
username=config[CONF_USERNAME],
authkey=config.get(CONF_AUTH_KEY),
privkey=config.get(CONF_PRIV_KEY),
)
try:
await hass.async_add_executor_job(dev.initialize)
mac = await dev.deviceMAC()
outlets = dev.outlets()
name = await dev.deviceName()
model = await dev.modelName()
sw_version = await dev.deviceFWversion()
except AtenPEError as exc:
_LOGGER.error("Failed to initialize %s:%s: %s", node, serv, str(exc))
raise PlatformNotReady from exc
info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, mac)},
manufacturer="ATEN",
model=model,
name=name,
sw_version=sw_version,
)
async_add_entities(
(AtenSwitch(dev, info, mac, outlet.id, outlet.name) for outlet in outlets), True
)
class AtenSwitch(SwitchEntity):
"""Represents an ATEN PE switch."""
_attr_device_class = SwitchDeviceClass.OUTLET
def __init__(
self, device: AtenPE, info: DeviceInfo, mac: str, outlet: str, name: str
) -> None:
"""Initialize an ATEN PE switch."""
self._device = device
self._outlet = outlet
self._attr_device_info = info
self._attr_unique_id = f"{mac}-{outlet}"
self._attr_name = name or f"Outlet {outlet}"
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._device.setOutletStatus(self._outlet, "on")
self._attr_is_on = True
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._device.setOutletStatus(self._outlet, "off")
self._attr_is_on = False
async def async_update(self) -> None:
"""Process update from entity."""
status = await self._device.displayOutletStatus(self._outlet)
if status == "on":
self._attr_is_on = True
elif status == "off":
self._attr_is_on = False
+29 -12
View File
@@ -731,17 +731,32 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
trace_element = TraceElement(variables, trigger_path)
trace_append_element(trace_element)
if (
not skip_condition
and self._condition is not None
and not self._condition.async_check(variables=variables)
):
self._logger.debug(
"Conditions not met, aborting automation. Condition summary: %s",
trace_get(clear=False),
)
script_execution_set("failed_conditions")
return None
if not skip_condition and self._condition is not None:
try:
conditions_pass = self._condition.async_check(variables=variables)
except (vol.Invalid, HomeAssistantError) as err:
self._logger.error(
"Error while checking conditions of automation %s: %s",
self.entity_id,
err,
)
automation_trace.set_error(err)
return None
except Exception as err:
self._logger.exception(
"Unexpected error while checking conditions of automation %s",
self.entity_id,
)
automation_trace.set_error(err)
return None
if not conditions_pass:
self._logger.debug(
"Conditions not met, aborting automation. Condition summary: %s",
trace_get(clear=False),
)
script_execution_set("failed_conditions")
return None
self.async_set_context(trigger_context)
event_data = {
@@ -794,7 +809,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
)
automation_trace.set_error(err)
except Exception as err:
self._logger.exception("While executing automation %s", self.entity_id)
self._logger.exception(
"Unexpected error while executing automation %s", self.entity_id
)
automation_trace.set_error(err)
return None
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.22",
"habluetooth==6.23.1"
"habluetooth==6.25.1"
]
}
@@ -805,6 +805,10 @@ class DefaultAgent(ConversationEntity):
else:
num_unmatched_entities += 1
# Literal text matched is the dominant signal
same_text_matched = (maybe_result is not None) and (
result.text_chunks_matched == maybe_result.text_chunks_matched
)
if (
(maybe_result is None) # first result
or (
@@ -813,22 +817,25 @@ class DefaultAgent(ConversationEntity):
)
or (
# More entities matched
num_matched_entities > best_num_matched_entities
same_text_matched
and (num_matched_entities > best_num_matched_entities)
)
or (
# Fewer unmatched entities
(num_matched_entities == best_num_matched_entities)
same_text_matched
and (num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities < best_num_unmatched_entities)
)
or (
# Prefer unmatched ranges
(num_matched_entities == best_num_matched_entities)
same_text_matched
and (num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges > best_num_unmatched_ranges)
)
or (
# Prefer match failures with entities
(result.text_chunks_matched == maybe_result.text_chunks_matched)
same_text_matched
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and (
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.8.0", "home-assistant-intents==2026.6.1"]
"requirements": ["hassil==3.8.0", "home-assistant-intents==2026.6.24"]
}
@@ -1,87 +0,0 @@
"""Support for Dovado router."""
# mypy: ignore-errors
from datetime import timedelta
import logging
# import dovado
import voluptuous as vol
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
DEVICE_DEFAULT_NAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
_LOGGER = logging.getLogger(__name__)
DOMAIN = "dovado"
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_HOST): cv.string,
vol.Optional(CONF_PORT): cv.port,
}
)
},
extra=vol.ALLOW_EXTRA,
)
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Dovado component."""
hass.data[DOMAIN] = DovadoData(
dovado.Dovado(
config[DOMAIN][CONF_USERNAME],
config[DOMAIN][CONF_PASSWORD],
config[DOMAIN].get(CONF_HOST),
config[DOMAIN].get(CONF_PORT),
)
)
return True
class DovadoData:
"""Maintain a connection to the router."""
def __init__(self, client):
"""Set up a new Dovado connection."""
self._client = client
self.state = {}
@property
def name(self):
"""Name of the router."""
return self.state.get("product name", DEVICE_DEFAULT_NAME)
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Update device state."""
try:
self.state = self._client.state or {}
if not self.state:
return False
self.state.update(connected=self.state.get("modem status") == "CONNECTED")
except OSError as error:
_LOGGER.warning("Could not contact the router: %s", error)
return None
_LOGGER.debug("Received: %s", self.state)
return True
@property
def client(self):
"""Dovado client instance."""
return self._client
@@ -1,10 +0,0 @@
{
"domain": "dovado",
"name": "Dovado",
"codeowners": [],
"disabled": "This integration is disabled because it uses non-open source code to operate.",
"documentation": "https://www.home-assistant.io/integrations/dovado",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["dovado==0.4.1"]
}
-38
View File
@@ -1,38 +0,0 @@
"""Support for SMS notifications from the Dovado router."""
import logging
from typing import Any, override
from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN
_LOGGER = logging.getLogger(__name__)
def get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
) -> DovadoSMSNotificationService:
"""Get the Dovado Router SMS notification service."""
return DovadoSMSNotificationService(hass.data[DOMAIN].client)
class DovadoSMSNotificationService(BaseNotificationService):
"""Implement the notification service for the Dovado SMS component."""
def __init__(self, client):
"""Initialize the service."""
self._client = client
@override
def send_message(self, message: str, **kwargs: Any) -> None:
"""Send SMS to the specified target phone number."""
if not (target := kwargs.get(ATTR_TARGET)):
_LOGGER.error("One target is required")
return
self._client.send_sms(target, message)
@@ -1,5 +0,0 @@
extend = "../../../pyproject.toml"
lint.extend-ignore = [
"F821"
]
-143
View File
@@ -1,143 +0,0 @@
"""Support for sensors from the Dovado router."""
from dataclasses import dataclass
from datetime import timedelta
import re
from typing import Any, override
import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import CONF_SENSORS, PERCENTAGE, UnitOfInformation
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
SENSOR_UPLOAD = "upload"
SENSOR_DOWNLOAD = "download"
SENSOR_SIGNAL = "signal"
SENSOR_NETWORK = "network"
SENSOR_SMS_UNREAD = "sms"
@dataclass(frozen=True, kw_only=True)
class DovadoSensorEntityDescription(SensorEntityDescription):
"""Describes Dovado sensor entity."""
identifier: str
SENSOR_TYPES: tuple[DovadoSensorEntityDescription, ...] = (
DovadoSensorEntityDescription(
identifier=SENSOR_NETWORK,
key="signal strength",
name="Network",
icon="mdi:access-point-network",
),
DovadoSensorEntityDescription(
identifier=SENSOR_SIGNAL,
key="signal strength",
name="Signal Strength",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:signal",
),
DovadoSensorEntityDescription(
identifier=SENSOR_SMS_UNREAD,
key="sms unread",
name="SMS unread",
icon="mdi:message-text-outline",
),
DovadoSensorEntityDescription(
identifier=SENSOR_UPLOAD,
key="traffic modem tx",
name="Sent",
native_unit_of_measurement=UnitOfInformation.GIGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
icon="mdi:cloud-upload",
),
DovadoSensorEntityDescription(
identifier=SENSOR_DOWNLOAD,
key="traffic modem rx",
name="Received",
native_unit_of_measurement=UnitOfInformation.GIGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
icon="mdi:cloud-download",
),
)
SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES]
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{vol.Required(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSOR_KEYS)])}
)
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Dovado sensor platform."""
dovado = hass.data[DOMAIN]
sensors = config[CONF_SENSORS]
entities = [
DovadoSensor(dovado, description)
for description in SENSOR_TYPES
if description.key in sensors
]
add_entities(entities)
class DovadoSensor(SensorEntity):
"""Representation of a Dovado sensor."""
entity_description: DovadoSensorEntityDescription
def __init__(self, data, description: DovadoSensorEntityDescription) -> None:
"""Initialize the sensor."""
self.entity_description = description
self._data = data
self._attr_name = f"{data.name} {description.name}"
self._attr_native_value = self._compute_state()
def _compute_state(self):
"""Compute the state of the sensor."""
state = self._data.state.get(self.entity_description.key)
sensor_identifier = self.entity_description.identifier
if sensor_identifier == SENSOR_NETWORK:
match = re.search(r"\((.+)\)", state)
return match.group(1) if match else None
if sensor_identifier == SENSOR_SIGNAL:
try:
return int(state.split()[0])
except ValueError:
return None
if sensor_identifier == SENSOR_SMS_UNREAD:
return int(state)
if sensor_identifier in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]:
return round(float(state) / 1e6, 1)
return state
def update(self) -> None:
"""Update sensor values."""
self._data.update()
self._attr_native_value = self._compute_state()
@property
@override
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
return {k: v for k, v in self._data.state.items() if k not in ["date", "time"]}
+14 -1
View File
@@ -17,7 +17,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
)
from .auth import DropboxConfigEntryAuth
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN, OAUTH2_SCOPES
type DropboxConfigEntry = ConfigEntry[DropboxAPIClient]
@@ -31,6 +31,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> b
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
token = entry.data["token"]
if not set(token.get("scope", "").split()).issuperset(OAUTH2_SCOPES):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="missing_scopes",
)
if "refresh_token" not in token:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="missing_refresh_token",
)
oauth2_session = OAuth2Session(hass, entry, oauth2_implementation)
auth = DropboxConfigEntryAuth(
@@ -1,7 +1,5 @@
"""Application credentials platform for the Dropbox integration."""
from typing import override
from homeassistant.components.application_credentials import ClientCredential
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
@@ -9,14 +7,14 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
LocalOAuth2ImplementationWithPkce,
)
from .const import OAUTH2_AUTHORIZE, OAUTH2_SCOPES, OAUTH2_TOKEN
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
async def async_get_auth_implementation(
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
) -> AbstractOAuth2Implementation:
"""Return custom auth implementation."""
return DropboxOAuth2Implementation(
"""Return auth implementation."""
return LocalOAuth2ImplementationWithPkce(
hass,
auth_domain,
credential.client_id,
@@ -24,21 +22,3 @@ async def async_get_auth_implementation(
OAUTH2_TOKEN,
credential.client_secret,
)
class DropboxOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
"""Custom Dropbox OAuth2 implementation.
Adds the necessary authorize url parameters.
"""
@property
@override
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
data: dict = {
"token_access_type": "offline",
"scope": " ".join(OAUTH2_SCOPES),
}
data.update(super().extra_authorize_data)
return data
@@ -12,7 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .auth import DropboxConfigFlowAuth
from .const import DOMAIN
from .const import DOMAIN, OAUTH2_SCOPES
class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
@@ -26,6 +26,15 @@ class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Return logger."""
return logging.getLogger(__name__)
@property
@override
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
return {
"token_access_type": "offline",
"scope": " ".join(OAUTH2_SCOPES),
}
@override
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for the flow, or update existing entry."""
@@ -51,6 +60,9 @@ class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
token = entry_data[CONF_TOKEN]
if not set(token.get("scope", "").split()).issuperset(OAUTH2_SCOPES):
return await self.async_step_reauth_permissions()
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
@@ -60,3 +72,11 @@ class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
async def async_step_reauth_permissions(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that additional permissions are required."""
if user_input is None:
return self.async_show_form(step_id="reauth_permissions")
return await self.async_step_user()
@@ -12,6 +12,7 @@ OAUTH2_SCOPES = [
"account_info.read",
"files.content.read",
"files.content.write",
"files.metadata.read",
]
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
@@ -24,10 +24,20 @@
"reauth_confirm": {
"description": "The Dropbox integration needs to re-authenticate your account.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"reauth_permissions": {
"description": "The Dropbox integration requires additional permissions to function correctly.",
"title": "[%key:common::config_flow::title::reauth%]"
}
}
},
"exceptions": {
"missing_refresh_token": {
"message": "[%key:component::dropbox::config::step::reauth_confirm::description%]"
},
"missing_scopes": {
"message": "[%key:component::dropbox::config::step::reauth_permissions::description%]"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
+16 -1
View File
@@ -27,6 +27,19 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
SUPPORTED_SELECT_NODE_TYPES = {
NodeType.BOX,
NodeType.VLV,
NodeType.VLVRH,
NodeType.VLVVOC,
NodeType.VLVCO2,
NodeType.VLVCO2RH,
NodeType.EAV,
NodeType.EAVRH,
NodeType.EAVVOC,
NodeType.EAVCO2,
}
def _get_ventilation_options(action: ActionItem) -> tuple[str, ...] | None:
"""Return ventilation options advertised by a node action."""
@@ -71,7 +84,9 @@ async def async_setup_entry(
if node.node_id in known_nodes:
continue
if node.general.node_type is not NodeType.BOX:
# Duco advertises SetVentilationState broadly, so keep the select
# limited to the box and known valve node families.
if node.general.node_type not in SUPPORTED_SELECT_NODE_TYPES:
continue
options = options_by_node.get(node.node_id)
@@ -65,6 +65,14 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]:
"/ivp/meters/readings",
"/ivp/pdm/device_data",
"/home",
"/inventory.json?deleted=1",
"/admin/lib/acb_config",
"/ivp/sc/sched",
"/admin/lib/network_display",
"/admin/lib/wireless_display",
"/ivp/ensemble/relay",
"/ivp/livedata/status",
"/ivp/pdm/energy",
]
for end_point in end_points:
@@ -134,16 +142,15 @@ async def async_get_config_entry_diagnostics(
"encharge_power": envoy_data.encharge_power,
"encharge_aggregate": envoy_data.encharge_aggregate,
"enpower": envoy_data.enpower,
"acb_power": envoy_data.acb_power,
"acb_inventory": envoy_data.acb_inventory,
"battery_aggregate": envoy_data.battery_aggregate,
"collar": envoy_data.collar,
"c6cc": envoy_data.c6cc,
"system_consumption": envoy_data.system_consumption,
"system_production": envoy_data.system_production,
"system_consumption_phases": envoy_data.system_consumption_phases,
"system_production_phases": envoy_data.system_production_phases,
"ctmeter_production": envoy_data.ctmeter_production,
"ctmeter_consumption": envoy_data.ctmeter_consumption,
"ctmeter_storage": envoy_data.ctmeter_storage,
"ctmeter_production_phases": envoy_data.ctmeter_production_phases,
"ctmeter_consumption_phases": envoy_data.ctmeter_consumption_phases,
"ctmeter_storage_phases": envoy_data.ctmeter_storage_phases,
"ctmeters": envoy_data.ctmeters,
"ctmeters_phases": envoy_data.ctmeters_phases,
"dry_contact_status": envoy_data.dry_contact_status,
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260624.0"]
"requirements": ["home-assistant-frontend==20260624.1"]
}
@@ -1 +0,0 @@
"""The greenwave component."""
-123
View File
@@ -1,123 +0,0 @@
"""Support for Greenwave Reality (TCP Connected) lights."""
from datetime import timedelta
import logging
import os
from typing import Any, override
import greenwavereality as greenwave
import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
ColorMode,
LightEntity,
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
_LOGGER = logging.getLogger(__name__)
CONF_VERSION = "version"
PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend(
{vol.Required(CONF_HOST): cv.string, vol.Required(CONF_VERSION): cv.positive_int}
)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Greenwave Reality Platform."""
host = config.get(CONF_HOST)
tokenfilename = hass.config.path(".greenwave")
if config.get(CONF_VERSION) == 3:
if os.path.exists(tokenfilename):
with open(tokenfilename, encoding="utf8") as tokenfile:
token = tokenfile.read()
else:
try:
token = greenwave.grab_token(host, "hass", "homeassistant")
except PermissionError:
_LOGGER.error("The Gateway Is Not In Sync Mode")
raise
with open(tokenfilename, "w+", encoding="utf8") as tokenfile:
tokenfile.write(token)
else:
token = None
bulbs = greenwave.grab_bulbs(host, token)
add_entities(
GreenwaveLight(device, host, token, GatewayData(host, token))
for device in bulbs.values()
)
class GreenwaveLight(LightEntity):
"""Representation of an Greenwave Reality Light."""
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
def __init__(self, light, host, token, gatewaydata):
"""Initialize a Greenwave Reality Light."""
self._did = int(light["did"])
self._attr_name = light["name"]
self._attr_is_on = bool(int(light["state"]))
self._attr_brightness = greenwave.hass_brightness(light)
self._host = host
self._attr_available = greenwave.check_online(light)
self._token = token
self._gatewaydata = gatewaydata
@override
def turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
temp_brightness = int((kwargs.get(ATTR_BRIGHTNESS, 255) / 255) * 100)
greenwave.set_brightness(self._host, self._did, temp_brightness, self._token)
greenwave.turn_on(self._host, self._did, self._token)
@override
def turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
greenwave.turn_off(self._host, self._did, self._token)
def update(self) -> None:
"""Fetch new state data for this light."""
self._gatewaydata.update()
bulbs = self._gatewaydata.greenwave
self._attr_is_on = bool(int(bulbs[self._did]["state"]))
self._attr_brightness = greenwave.hass_brightness(bulbs[self._did])
self._attr_available = greenwave.check_online(bulbs[self._did])
self._attr_name = bulbs[self._did]["name"]
class GatewayData:
"""Handle Gateway data and limit updates."""
def __init__(self, host, token):
"""Initialize the data object."""
self._host = host
self._token = token
self._greenwave = greenwave.grab_bulbs(host, token)
@property
def greenwave(self):
"""Return Gateway API object."""
return self._greenwave
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Get the latest data from the gateway."""
self._greenwave = greenwave.grab_bulbs(self._host, self._token)
return self._greenwave
@@ -1,10 +0,0 @@
{
"domain": "greenwave",
"name": "Greenwave Reality",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/greenwave",
"iot_class": "local_polling",
"loggers": ["greenwavereality"],
"quality_scale": "legacy",
"requirements": ["greenwavereality==0.5.1"]
}
+1 -1
View File
@@ -92,7 +92,7 @@ class SupervisorJobs:
# We catch all errors to prevent an error in one from stopping the others
for match in [job for job in self._jobs.values() if subscription.matches(job)]:
try:
return subscription.event_callback(match)
subscription.event_callback(match)
except Exception as err: # noqa: BLE001
_LOGGER.error(
"Error encountered processing Supervisor Job (%s %s %s) - %s",
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume
from homeassistant.const import EntityCategory, UnitOfRatio, UnitOfVolume
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util, slugify
@@ -67,7 +67,7 @@ BSH_PROGRAM_SENSORS = (
),
HomeConnectSensorEntityDescription(
key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS,
native_unit_of_measurement=PERCENTAGE,
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
translation_key="program_progress",
appliance_types=APPLIANCES_WITH_PROGRAMS,
),
@@ -158,6 +158,7 @@ SENSORS = (
HomeConnectSensorEntityDescription(
key=StatusKey.BSH_COMMON_BATTERY_LEVEL,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
),
HomeConnectSensorEntityDescription(
key=StatusKey.BSH_COMMON_VIDEO_CAMERA_STATE,
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.13.1"]
"requirements": ["homematicip==2.13.2"]
}
@@ -1,56 +0,0 @@
"""Support for sending data to Logentries webhook endpoint."""
import json
import logging
import requests
import voluptuous as vol
from homeassistant.const import CONF_TOKEN, EVENT_STATE_CHANGED
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, state as state_helper
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
DOMAIN = "logentries"
DEFAULT_HOST = "https://webhook.logentries.com/noformat/logs/"
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({vol.Required(CONF_TOKEN): cv.string})}, extra=vol.ALLOW_EXTRA
)
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Logentries component."""
conf = config[DOMAIN]
token = conf.get(CONF_TOKEN)
le_wh = f"{DEFAULT_HOST}{token}"
def logentries_event_listener(event):
"""Listen for new messages on the bus and sends them to Logentries."""
if (state := event.data.get("new_state")) is None:
return
try:
_state = state_helper.state_as_number(state)
except ValueError:
_state = state.state
json_body = [
{
"domain": state.domain,
"entity_id": state.object_id,
"attributes": dict(state.attributes),
"time": str(event.time_fired),
"value": _state,
}
]
try:
payload = {"host": le_wh, "event": json_body}
requests.post(le_wh, data=json.dumps(payload), timeout=10)
except requests.exceptions.RequestException:
_LOGGER.exception("Error sending to Logentries")
hass.bus.listen(EVENT_STATE_CHANGED, logentries_event_listener)
return True
@@ -1,8 +0,0 @@
{
"domain": "logentries",
"name": "Logentries",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/logentries",
"iot_class": "cloud_push",
"quality_scale": "legacy"
}
@@ -1,21 +0,0 @@
"""Support for Mycroft AI."""
import voluptuous as vol
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
DOMAIN = "mycroft"
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({vol.Required(CONF_HOST): cv.string})}, extra=vol.ALLOW_EXTRA
)
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Mycroft component."""
hass.data[DOMAIN] = config[DOMAIN][CONF_HOST]
discovery.load_platform(hass, Platform.NOTIFY, DOMAIN, {}, config)
return True
@@ -1,11 +0,0 @@
{
"domain": "mycroft",
"name": "Mycroft",
"codeowners": [],
"disabled": "Dependencies not compatible with the new pip resolver",
"documentation": "https://www.home-assistant.io/integrations/mycroft",
"iot_class": "local_push",
"loggers": ["mycroftapi"],
"quality_scale": "legacy",
"requirements": ["mycroftapi==2.0"]
}
@@ -1,42 +0,0 @@
"""Mycroft AI notification platform."""
import logging
from typing import Any, override
from mycroftapi import MycroftAPI
from homeassistant.components.notify import BaseNotificationService
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN
_LOGGER = logging.getLogger(__name__)
def get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
) -> MycroftNotificationService:
"""Get the Mycroft notification service."""
return MycroftNotificationService(hass.data[DOMAIN])
class MycroftNotificationService(BaseNotificationService):
"""The Mycroft Notification Service."""
def __init__(self, mycroft_ip: str) -> None:
"""Initialize the service."""
self.mycroft_ip = mycroft_ip
@override
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message mycroft to speak on instance."""
text = message
mycroft = MycroftAPI(self.mycroft_ip)
if mycroft is not None:
mycroft.speak_text(text)
else:
_LOGGER.warning("Could not reach this instance of mycroft")
+3 -1
View File
@@ -365,5 +365,7 @@ async def create_rexel_client(
gateway_id=entry.data[CONF_GATEWAY_ID],
),
session=async_create_clientsession(hass),
settings=OverkizClientSettings(action_queue=ActionQueueSettings()),
settings=OverkizClientSettings(
action_queue=ActionQueueSettings(), default_rts_command_duration=0
),
)
@@ -30,6 +30,7 @@ from homeassistant.util.event_type import EventType
# startup
from . import (
backup, # noqa: F401
entity_options,
entity_registry,
websocket_api,
)
@@ -42,6 +43,7 @@ from .const import ( # noqa: F401
SupportedDialect,
)
from .core import Recorder
from .entity_options import is_entity_recorded # noqa: F401
from .services import async_setup_services
from .tasks import AddRecorderPlatformTask
from .util import get_instance
@@ -125,15 +127,6 @@ CONFIG_SCHEMA = vol.Schema(
)
def is_entity_recorded(hass: HomeAssistant, entity_id: str) -> bool:
"""Check if an entity is being recorded.
Async friendly.
"""
instance = get_instance(hass)
return instance.entity_filter is None or instance.entity_filter(entity_id)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the recorder."""
conf = config[DOMAIN]
@@ -167,6 +160,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
get_instance.cache_clear()
entity_registry.async_setup(hass)
entity_options.async_setup(hass)
instance.async_initialize()
instance.async_register()
instance.start()
@@ -0,0 +1,68 @@
"""Control recorder entity options."""
import dataclasses
from enum import StrEnum
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from .util import get_instance
def is_entity_recorded(hass: HomeAssistant, entity_id: str) -> bool:
"""Check if an entity is being recorded.
Async friendly.
"""
instance = get_instance(hass)
return instance.entity_filter is None or instance.entity_filter(entity_id)
@callback
def async_setup(hass: HomeAssistant) -> None:
"""Set up the recorder entity options."""
websocket_api.async_register_command(hass, ws_get_entity_options)
class EntityRecordingDisabler(StrEnum):
"""What disabled recording of an entity."""
USER = "user"
@dataclasses.dataclass(frozen=True)
class RecorderEntityOptions:
"""Recorder options for an entity."""
recording_disabled_by: EntityRecordingDisabler | None = None
def to_json(self) -> dict[str, Any]:
"""Return a JSON serializable representation for storage."""
return {
"recording_disabled_by": self.recording_disabled_by,
}
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "recorder/entity_options/get",
vol.Required("entity_id"): cv.strict_entity_id,
}
)
@callback
def ws_get_entity_options(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Get recorder settings for a single entity."""
entity_id: str = msg["entity_id"]
recording_disabled = (
None if is_entity_recorded(hass, entity_id) else EntityRecordingDisabler.USER
)
options = RecorderEntityOptions(recording_disabled_by=recording_disabled)
connection.send_result(msg["id"], options.to_json())
@@ -321,6 +321,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = (
options=[
"always",
"delayed",
"delegated",
"scheduled",
],
value_lambda=_get_charging_settings_mode_formatted,
@@ -157,6 +157,7 @@
"state": {
"always": "Always",
"delayed": "Delayed",
"delegated": "Delegated",
"scheduled": "Scheduled"
}
},
+6 -2
View File
@@ -286,7 +286,10 @@ async def register_callbacks(
return async_camera_wake
host.api.baichuan.register_callback(
"privacy_mode_change", async_privacy_mode_change, 623
"privacy_mode_change_623", async_privacy_mode_change, 623
)
host.api.baichuan.register_callback(
"privacy_mode_change_574", async_privacy_mode_change, 574
)
for channel in host.api.channels:
if host.api.supported(channel, "battery"):
@@ -306,7 +309,8 @@ async def async_unload_entry(
await host.stop()
host.api.baichuan.unregister_callback("privacy_mode_change")
host.api.baichuan.unregister_callback("privacy_mode_change_623")
host.api.baichuan.unregister_callback("privacy_mode_change_574")
for channel in host.api.channels:
if host.api.supported(channel, "battery"):
host.api.baichuan.unregister_callback(f"camera_{channel}_wake")
@@ -75,6 +75,7 @@ LIGHT_ENTITIES = (
ReolinkLightEntityDescription(
key="status_led",
cmd_key="GetPowerLed",
cmd_id=208,
translation_key="status_led",
entity_category=EntityCategory.CONFIG,
supported=lambda api, ch: api.supported(ch, "power_led"),
@@ -20,5 +20,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.21.2"]
"requirements": ["reolink-aio==0.21.3"]
}
@@ -195,6 +195,7 @@ NUMBER_ENTITIES = (
key="volume",
cmd_key="GetAudioCfg",
translation_key="volume",
cmd_id=264,
entity_category=EntityCategory.CONFIG,
native_step=1,
native_min_value=0,
@@ -206,6 +207,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="volume_speak",
cmd_key="GetAudioCfg",
cmd_id=264,
translation_key="volume_speak",
entity_category=EntityCategory.CONFIG,
native_step=1,
@@ -218,6 +220,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="volume_doorbell",
cmd_key="GetAudioCfg",
cmd_id=264,
translation_key="volume_doorbell",
entity_category=EntityCategory.CONFIG,
native_step=1,
@@ -269,6 +272,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="pir_sensitivity",
cmd_key="GetPirInfo",
cmd_id=212,
translation_key="pir_sensitivity",
entity_category=EntityCategory.CONFIG,
native_step=1,
@@ -281,6 +285,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="pir_interval",
cmd_key="GetPirInfo",
cmd_id=212,
translation_key="pir_interval",
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.DURATION,
@@ -296,6 +301,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="ai_face_sensititvity",
cmd_key="GetAiAlarm",
cmd_id=342,
translation_key="ai_face_sensitivity",
entity_category=EntityCategory.CONFIG,
native_step=1,
@@ -310,6 +316,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="ai_person_sensititvity",
cmd_key="GetAiAlarm",
cmd_id=342,
translation_key="ai_person_sensitivity",
entity_category=EntityCategory.CONFIG,
native_step=1,
@@ -324,6 +331,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="ai_vehicle_sensititvity",
cmd_key="GetAiAlarm",
cmd_id=342,
translation_key="ai_vehicle_sensitivity",
entity_category=EntityCategory.CONFIG,
native_step=1,
@@ -338,6 +346,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="ai_non_motor_vehicle_sensitivity",
cmd_key="GetAiAlarm",
cmd_id=342,
translation_key="ai_non_motor_vehicle_sensitivity",
entity_category=EntityCategory.CONFIG,
native_step=1,
@@ -355,6 +364,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="ai_package_sensititvity",
cmd_key="GetAiAlarm",
cmd_id=342,
translation_key="ai_package_sensitivity",
entity_category=EntityCategory.CONFIG,
native_step=1,
@@ -369,6 +379,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="ai_pet_sensititvity",
cmd_key="GetAiAlarm",
cmd_id=342,
translation_key="ai_pet_sensitivity",
entity_category=EntityCategory.CONFIG,
native_step=1,
@@ -385,6 +396,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="ai_pet_sensititvity",
cmd_key="GetAiAlarm",
cmd_id=342,
translation_key="ai_animal_sensitivity",
entity_category=EntityCategory.CONFIG,
native_step=1,
@@ -411,6 +423,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="ai_face_delay",
cmd_key="GetAiAlarm",
cmd_id=342,
translation_key="ai_face_delay",
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.DURATION,
@@ -428,6 +441,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="ai_person_delay",
cmd_key="GetAiAlarm",
cmd_id=342,
translation_key="ai_person_delay",
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.DURATION,
@@ -445,6 +459,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="ai_non_motor_vehicle_delay",
cmd_key="GetAiAlarm",
cmd_id=342,
translation_key="ai_non_motor_vehicle_delay",
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.DURATION,
@@ -464,6 +479,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="ai_vehicle_delay",
cmd_key="GetAiAlarm",
cmd_id=342,
translation_key="ai_vehicle_delay",
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.DURATION,
@@ -481,6 +497,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="ai_package_delay",
cmd_key="GetAiAlarm",
cmd_id=342,
translation_key="ai_package_delay",
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.DURATION,
@@ -498,6 +515,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="ai_pet_delay",
cmd_key="GetAiAlarm",
cmd_id=342,
translation_key="ai_pet_delay",
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.DURATION,
@@ -517,6 +535,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="ai_pet_delay",
cmd_key="GetAiAlarm",
cmd_id=342,
translation_key="ai_animal_delay",
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.DURATION,
@@ -185,6 +185,7 @@ SELECT_ENTITIES = (
ReolinkSelectEntityDescription(
key="status_led",
cmd_key="GetPowerLed",
cmd_id=208,
translation_key="doorbell_led",
entity_category=EntityCategory.CONFIG,
get_options=lambda api, ch: api.doorbell_led_list(ch),
@@ -232,6 +233,7 @@ SELECT_ENTITIES = (
ReolinkSelectEntityDescription(
key="main_frame_rate",
cmd_key="GetEnc",
cmd_id=56,
translation_key="main_frame_rate",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -244,6 +246,7 @@ SELECT_ENTITIES = (
ReolinkSelectEntityDescription(
key="sub_frame_rate",
cmd_key="GetEnc",
cmd_id=56,
translation_key="sub_frame_rate",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -256,6 +259,7 @@ SELECT_ENTITIES = (
ReolinkSelectEntityDescription(
key="main_bit_rate",
cmd_key="GetEnc",
cmd_id=56,
translation_key="main_bit_rate",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -268,6 +272,7 @@ SELECT_ENTITIES = (
ReolinkSelectEntityDescription(
key="sub_bit_rate",
cmd_key="GetEnc",
cmd_id=56,
translation_key="sub_bit_rate",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -280,6 +285,7 @@ SELECT_ENTITIES = (
ReolinkSelectEntityDescription(
key="main_encoding",
cmd_key="GetEnc",
cmd_id=56,
translation_key="main_encoding",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -291,6 +297,7 @@ SELECT_ENTITIES = (
ReolinkSelectEntityDescription(
key="sub_encoding",
cmd_key="GetEnc",
cmd_id=56,
translation_key="sub_encoding",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -316,6 +323,7 @@ SELECT_ENTITIES = (
ReolinkSelectEntityDescription(
key="post_rec_time",
cmd_key="GetRec",
cmd_id=54,
translation_key="post_rec_time",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -340,6 +348,7 @@ HOST_SELECT_ENTITIES = (
ReolinkHostSelectEntityDescription(
key="packing_time",
cmd_key="GetRec",
cmd_id=54,
translation_key="packing_time",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -74,6 +74,7 @@ SWITCH_ENTITIES = (
ReolinkSwitchEntityDescription(
key="ir_lights",
cmd_key="GetIrLights",
cmd_id=208,
translation_key="ir_lights",
entity_category=EntityCategory.CONFIG,
supported=lambda api, ch: api.supported(ch, "ir_lights"),
@@ -83,6 +84,7 @@ SWITCH_ENTITIES = (
ReolinkSwitchEntityDescription(
key="record_audio",
cmd_key="GetEnc",
cmd_id=56,
translation_key="record_audio",
entity_category=EntityCategory.CONFIG,
supported=lambda api, ch: api.supported(ch, "audio"),
@@ -92,6 +94,7 @@ SWITCH_ENTITIES = (
ReolinkSwitchEntityDescription(
key="siren_on_event",
cmd_key="GetAudioAlarm",
cmd_id=232,
translation_key="siren_on_event",
entity_category=EntityCategory.CONFIG,
supported=lambda api, ch: api.supported(ch, "siren"),
@@ -136,6 +139,7 @@ SWITCH_ENTITIES = (
ReolinkSwitchEntityDescription(
key="email",
cmd_key="GetEmail",
cmd_id=217,
translation_key="email",
entity_category=EntityCategory.CONFIG,
supported=lambda api, ch: api.supported(ch, "email") and api.is_nvr,
@@ -145,6 +149,7 @@ SWITCH_ENTITIES = (
ReolinkSwitchEntityDescription(
key="ftp_upload",
cmd_key="GetFtp",
cmd_id=70,
translation_key="ftp_upload",
entity_category=EntityCategory.CONFIG,
supported=lambda api, ch: api.supported(ch, "ftp") and api.is_nvr,
@@ -163,6 +168,7 @@ SWITCH_ENTITIES = (
ReolinkSwitchEntityDescription(
key="record",
cmd_key="GetRec",
cmd_id=81,
translation_key="record",
entity_category=EntityCategory.CONFIG,
supported=lambda api, ch: api.supported(ch, "rec_enable") and api.is_nvr,
@@ -200,6 +206,7 @@ SWITCH_ENTITIES = (
ReolinkSwitchEntityDescription(
key="doorbell_button_sound",
cmd_key="GetAudioCfg",
cmd_id=264,
translation_key="doorbell_button_sound",
entity_category=EntityCategory.CONFIG,
supported=lambda api, ch: api.supported(ch, "doorbell_button_sound"),
@@ -209,6 +216,7 @@ SWITCH_ENTITIES = (
ReolinkSwitchEntityDescription(
key="pir_enabled",
cmd_key="GetPirInfo",
cmd_id=212,
translation_key="pir_enabled",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -219,6 +227,7 @@ SWITCH_ENTITIES = (
ReolinkSwitchEntityDescription(
key="pir_reduce_alarm",
cmd_key="GetPirInfo",
cmd_id=212,
translation_key="pir_reduce_alarm",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
@@ -260,6 +269,7 @@ HOST_SWITCH_ENTITIES = (
ReolinkHostSwitchEntityDescription(
key="email",
cmd_key="GetEmail",
cmd_id=217,
translation_key="email",
entity_category=EntityCategory.CONFIG,
supported=lambda api: api.supported(None, "email") and not api.is_hub,
@@ -269,6 +279,7 @@ HOST_SWITCH_ENTITIES = (
ReolinkHostSwitchEntityDescription(
key="ftp_upload",
cmd_key="GetFtp",
cmd_id=70,
translation_key="ftp_upload",
entity_category=EntityCategory.CONFIG,
supported=lambda api: api.supported(None, "ftp") and not api.is_hub,
@@ -287,6 +298,7 @@ HOST_SWITCH_ENTITIES = (
ReolinkHostSwitchEntityDescription(
key="record",
cmd_key="GetRec",
cmd_id=81,
translation_key="record",
entity_category=EntityCategory.CONFIG,
supported=lambda api: api.supported(None, "rec_enable") and not api.is_hub,
@@ -20,7 +20,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==5.14.2",
"python-roborock==5.21.0",
"vacuum-map-parser-roborock==0.1.5"
]
}
+4 -2
View File
@@ -35,7 +35,7 @@ class RoborockNumberDescription(NumberEntityDescription):
trait: Callable[[PropertiesApi], Any | None]
"""Function to determine if number entity is supported by the device."""
get_value: Callable[[Any], float]
get_value: Callable[[Any], float | None]
"""Function to get the value from the trait."""
set_value: Callable[[Any, float], Coroutine[Any, Any, None]]
@@ -51,7 +51,9 @@ NUMBER_DESCRIPTIONS: list[RoborockNumberDescription] = [
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.CONFIG,
trait=lambda api: api.sound_volume,
get_value=lambda trait: float(trait.volume),
get_value=lambda trait: (
float(trait.volume) if trait.volume is not None else None
),
set_value=lambda trait, value: trait.set_volume(int(value)),
)
]
+5 -13
View File
@@ -37,7 +37,7 @@ class RoborockTimeDescription(TimeEntityDescription):
trait: Callable[[Any], Any | None]
"""Function to determine if time entity is supported by the device."""
get_value: Callable[[Any], datetime.time]
get_value: Callable[[Any], datetime.time | None]
"""Function to get the value from the trait."""
update_value: Callable[[Any, datetime.time], Coroutine[Any, Any, None]]
@@ -58,9 +58,7 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [
end_minute=trait.end_minute,
)
),
get_value=lambda trait: datetime.time(
hour=trait.start_hour, minute=trait.start_minute
),
get_value=lambda trait: trait.start_time,
entity_category=EntityCategory.CONFIG,
),
RoborockTimeDescription(
@@ -76,9 +74,7 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [
end_minute=desired_time.minute,
)
),
get_value=lambda trait: datetime.time(
hour=trait.end_hour, minute=trait.end_minute
),
get_value=lambda trait: trait.end_time,
entity_category=EntityCategory.CONFIG,
),
RoborockTimeDescription(
@@ -94,9 +90,7 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [
end_minute=trait.end_minute,
)
),
get_value=lambda trait: datetime.time(
hour=trait.start_hour, minute=trait.start_minute
),
get_value=lambda trait: trait.start_time,
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
),
@@ -113,9 +107,7 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [
end_minute=desired_time.minute,
)
),
get_value=lambda trait: datetime.time(
hour=trait.end_hour, minute=trait.end_minute
),
get_value=lambda trait: trait.end_time,
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
),
@@ -1,6 +1,6 @@
"""Config flow to configure StarLine component."""
from typing import TYPE_CHECKING, override
from typing import override
from starline import StarlineAuth
import voluptuous as vol
@@ -192,18 +192,13 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Authenticate application."""
try:
def _get_app_token() -> str:
if TYPE_CHECKING:
assert self._app_id is not None
assert self._app_secret is not None
app_code = self._auth.get_app_code(self._app_id, self._app_secret)
return self._auth.get_app_token(
self._app_id, self._app_secret, app_code
)
self._app_token = await self.hass.async_add_executor_job(_get_app_token)
self._app_code = await self.hass.async_add_executor_job(
self._auth.get_app_code, self._app_id, self._app_secret
)
# pylint: disable-next=home-assistant-sequential-executor-jobs
self._app_token = await self.hass.async_add_executor_job(
self._auth.get_app_token, self._app_id, self._app_secret, self._app_code
)
return self._async_form_auth_user(error)
except Exception as err: # noqa: BLE001
_LOGGER.error("Error auth StarLine: %s", err)
+14 -20
View File
@@ -85,34 +85,18 @@ def sun(
has_sunrise_condition = SUN_EVENT_SUNRISE in (before, after)
has_sunset_condition = SUN_EVENT_SUNSET in (before, after)
after_sunrise = today > dt_util.as_local(cast(datetime, sunrise)).date()
after_sunrise = sunrise is not None and today > dt_util.as_local(sunrise).date()
if after_sunrise and has_sunrise_condition:
tomorrow = today + timedelta(days=1)
sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, tomorrow)
after_sunset = today > dt_util.as_local(cast(datetime, sunset)).date()
after_sunset = sunset is not None and today > dt_util.as_local(sunset).date()
if after_sunset and has_sunset_condition:
tomorrow = today + timedelta(days=1)
sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, tomorrow)
# Special case: before sunrise OR after sunset
# This will handle the very rare case in the polar region when the sun rises/sets
# but does not set/rise.
# However this entire condition does not handle those full days of darkness
# or light, the following should be used instead:
#
# condition:
# condition: state
# entity_id: sun.sun
# state: 'above_horizon' (or 'below_horizon')
#
if before == SUN_EVENT_SUNRISE and after == SUN_EVENT_SUNSET:
wanted_time_before = cast(datetime, sunrise) + before_offset
condition_trace_update_result(wanted_time_before=wanted_time_before)
wanted_time_after = cast(datetime, sunset) + after_offset
condition_trace_update_result(wanted_time_after=wanted_time_after)
return utcnow < wanted_time_before or utcnow > wanted_time_after
# A missing sunrise/sunset means the sun doesn't rise/set on this day, which
# happens in polar regions.
if sunrise is None and has_sunrise_condition:
# There is no sunrise today
condition_trace_set_result(False, message="no sunrise today")
@@ -123,6 +107,16 @@ def sun(
condition_trace_set_result(False, message="no sunset today")
return False
# "before: sunrise" combined with "after: sunset" describes the dark period
# around midnight, so it is evaluated as an OR (true before sunrise or after
# sunset) rather than the usual AND of the two bounds.
if before == SUN_EVENT_SUNRISE and after == SUN_EVENT_SUNSET:
wanted_time_before = cast(datetime, sunrise) + before_offset
condition_trace_update_result(wanted_time_before=wanted_time_before)
wanted_time_after = cast(datetime, sunset) + after_offset
condition_trace_update_result(wanted_time_after=wanted_time_after)
return utcnow < wanted_time_before or utcnow > wanted_time_after
if before == SUN_EVENT_SUNRISE:
wanted_time_before = cast(datetime, sunrise) + before_offset
condition_trace_update_result(wanted_time_before=wanted_time_before)
@@ -14,5 +14,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["switchbot_api"],
"requirements": ["switchbot-api==2.11.1"]
"requirements": ["switchbot-api==2.12.0"]
}
@@ -11,6 +11,7 @@
"state": {
"cool": "mdi:fan-speed-2",
"full_speed": "mdi:fan-speed-3",
"low_power": "mdi:fan-chevron-down",
"quiet": "mdi:fan-speed-1"
}
}
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["synology_dsm"],
"requirements": ["py-synologydsm-api==2.10.0"],
"requirements": ["py-synologydsm-api==2.10.1"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:Basic:1",
@@ -15,6 +15,7 @@ from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigE
from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription
FAN_SPEED_MAP = {
FanSpeed.QUIET_STOP: "low_power",
FanSpeed.QUIET: "quiet",
FanSpeed.COOL: "cool",
FanSpeed.FULL: "full_speed",
@@ -47,7 +48,6 @@ class SynologyDSMFanSpeedMode(
):
"""Represent a Synology DSM fan speed mode select entity."""
_attr_options = list(FAN_SPEED_MAP.values())
entity_description: SynologyDSMSelectEntityDescription
def __init__(
@@ -62,6 +62,11 @@ class SynologyDSMFanSpeedMode(
translation_key="fan_speed_mode",
entity_category=EntityCategory.CONFIG,
)
self._attr_options = [
val
for fs, val in FAN_SPEED_MAP.items()
if fs in api.dsm.hardware.supported_fan_speeds
]
super().__init__(api, coordinator, description)
@property
@@ -87,6 +87,7 @@
"state": {
"cool": "Cool mode",
"full_speed": "Full-speed mode",
"low_power": "Low-Power mode",
"quiet": "Quiet mode"
}
}
@@ -67,12 +67,13 @@ class Tami4ConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
otp = user_input["otp"]
try:
refresh_token = await self.hass.async_add_executor_job(
Tami4EdgeAPI.submit_otp, self.phone, otp
)
# pylint: disable-next=home-assistant-sequential-executor-jobs
api = await self.hass.async_add_executor_job(
Tami4EdgeAPI, refresh_token
def _submit_otp_and_create_api() -> tuple[str, Tami4EdgeAPI]:
refresh_token = Tami4EdgeAPI.submit_otp(self.phone, otp)
return refresh_token, Tami4EdgeAPI(refresh_token)
refresh_token, api = await self.hass.async_add_executor_job(
_submit_otp_and_create_api
)
except exceptions.OTPFailedException:
errors["base"] = "invalid_auth"
@@ -1 +0,0 @@
"""The thermoworks_smoke component."""
@@ -1,11 +0,0 @@
{
"domain": "thermoworks_smoke",
"name": "ThermoWorks Smoke",
"codeowners": [],
"disabled": "This integration is disabled because it creates an unresolvable dependency conflict.",
"documentation": "https://www.home-assistant.io/integrations/thermoworks_smoke",
"iot_class": "cloud_polling",
"loggers": ["thermoworks_smoke"],
"quality_scale": "legacy",
"requirements": ["stringcase==1.2.0", "thermoworks-smoke==0.1.8"]
}
@@ -1,166 +0,0 @@
"""Support for getting the state of a Thermoworks Smoke Thermometer.
Requires Smoke Gateway Wifi with an internet connection.
"""
import logging
from requests import RequestException
from requests.exceptions import HTTPError
from stringcase import camelcase
import thermoworks_smoke
import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
)
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
CONF_EMAIL,
CONF_EXCLUDE,
CONF_MONITORED_CONDITIONS,
CONF_PASSWORD,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import snakecase
_LOGGER = logging.getLogger(__name__)
PROBE_1 = "probe1"
PROBE_2 = "probe2"
PROBE_1_MIN = "probe1_min"
PROBE_1_MAX = "probe1_max"
PROBE_2_MIN = "probe2_min"
PROBE_2_MAX = "probe2_max"
BATTERY_LEVEL = "battery"
FIRMWARE = "firmware"
SERIAL_REGEX = "^(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$"
# map types to labels
SENSOR_TYPES = {
PROBE_1: "Probe 1",
PROBE_2: "Probe 2",
PROBE_1_MIN: "Probe 1 Min",
PROBE_1_MAX: "Probe 1 Max",
PROBE_2_MIN: "Probe 2 Min",
PROBE_2_MAX: "Probe 2 Max",
}
# exclude these keys from thermoworks data
EXCLUDE_KEYS = [FIRMWARE]
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_EMAIL): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_MONITORED_CONDITIONS, default=[PROBE_1, PROBE_2]): vol.All(
cv.ensure_list, [vol.In(SENSOR_TYPES)]
),
vol.Optional(CONF_EXCLUDE, default=[]): vol.All(
cv.ensure_list, [cv.matches_regex(SERIAL_REGEX)]
),
}
)
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the thermoworks sensor."""
email = config[CONF_EMAIL]
password = config[CONF_PASSWORD]
monitored_variables = config[CONF_MONITORED_CONDITIONS]
excluded = config[CONF_EXCLUDE]
try:
mgr = thermoworks_smoke.initialize_app(email, password, True, excluded)
except HTTPError as error:
msg = f"{error.strerror}"
if "EMAIL_NOT_FOUND" in msg or "INVALID_PASSWORD" in msg:
_LOGGER.error("Invalid email and password combination")
else:
_LOGGER.error(msg)
else:
add_entities(
(
ThermoworksSmokeSensor(variable, serial, mgr)
for serial in mgr.serials()
for variable in monitored_variables
),
True,
)
class ThermoworksSmokeSensor(SensorEntity):
"""Implementation of a thermoworks smoke sensor."""
def __init__(self, sensor_type, serial, mgr):
"""Initialize the sensor."""
self.type = sensor_type
self.serial = serial
self.mgr = mgr
self._attr_name = f"{mgr.name(serial)} {SENSOR_TYPES[sensor_type]}"
self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT
self._attr_unique_id = f"{serial}-{sensor_type}"
self._attr_device_class = SensorDeviceClass.TEMPERATURE
self.update_unit()
def update_unit(self):
"""Set the units from the data."""
if PROBE_2 in self.type:
self._attr_native_unit_of_measurement = self.mgr.units(self.serial, PROBE_2)
else:
self._attr_native_unit_of_measurement = self.mgr.units(self.serial, PROBE_1)
def update(self) -> None:
"""Get the monitored data from firebase."""
try:
values = self.mgr.data(self.serial)
# set state from data based on type of sensor
self._attr_native_value = values.get(camelcase(self.type))
# set units
self.update_unit()
# set basic attributes for all sensors
self._attr_extra_state_attributes = {
"time": values["time"],
"localtime": values["localtime"],
}
# set extended attributes for main probe sensors
if self.type in (PROBE_1, PROBE_2):
for key, val in values.items():
# add all attributes that don't contain any probe name
# or contain a matching probe name
if (self.type == PROBE_1 and key.find(PROBE_2) == -1) or (
self.type == PROBE_2 and key.find(PROBE_1) == -1
):
if key == BATTERY_LEVEL:
key = ATTR_BATTERY_LEVEL
else:
# strip probe label and convert to snake_case
key = snakecase(key.replace(self.type, ""))
# add to attrs
if key and key not in EXCLUDE_KEYS:
self._attr_extra_state_attributes[key] = val
# store actual unit because attributes are not converted
self._attr_extra_state_attributes["unit_of_min_max"] = (
self._attr_native_unit_of_measurement
)
except RequestException, ValueError, KeyError:
_LOGGER.warning("Could not update status for %s", self.name)
+2 -2
View File
@@ -44,7 +44,7 @@
"iot_class": "cloud_push",
"loggers": ["tuya_sharing"],
"requirements": [
"tuya-device-handlers==0.0.22",
"tuya-device-sharing-sdk==0.2.8"
"tuya-device-handlers==0.0.24",
"tuya-device-sharing-sdk==0.2.10"
]
}
@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["uiprotect"],
"quality_scale": "platinum",
"requirements": ["uiprotect==14.0.0"]
"requirements": ["uiprotect==15.3.0"]
}
+7 -3
View File
@@ -62,10 +62,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeraConfigEntry) -> bool
controller = veraApi.VeraController(base_url, subscription_registry)
try:
all_devices = await hass.async_add_executor_job(controller.get_devices)
# pylint: disable-next=home-assistant-sequential-executor-jobs
all_scenes = await hass.async_add_executor_job(controller.get_scenes)
def _get_devices_and_scenes():
"""Get devices and scenes from the Vera controller."""
return controller.get_devices(), controller.get_scenes()
all_devices, all_scenes = await hass.async_add_executor_job(
_get_devices_and_scenes
)
except RequestException as exception:
# There was a network related error connecting to the Vera controller.
_LOGGER.exception("Error communicating with Vera API")
@@ -145,9 +145,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
translation_placeholders={"error": repr(err)},
) from err
except (
exceptions.CannotConnect,
exceptions.AlreadyLogged,
exceptions.GenericLoginError,
exceptions.VodafoneError,
JSONDecodeError,
) as err:
if isinstance(err, JSONDecodeError):
@@ -149,7 +149,6 @@ SELECTOR_TYPES = (
XiaomiMiioSelectDescription(
key=ATTR_DISPLAY_ORIENTATION,
attr_name=ATTR_DISPLAY_ORIENTATION,
name="Display Orientation",
options_map={
"Portrait": "Forward",
"LandscapeLeft": "Left",
@@ -165,7 +164,6 @@ SELECTOR_TYPES = (
XiaomiMiioSelectDescription(
key=ATTR_MODE,
attr_name=ATTR_MODE,
name="Mode",
set_method="set_mode",
set_method_error_message="Setting the mode of the fan failed.",
icon="mdi:fan",
@@ -176,7 +174,6 @@ SELECTOR_TYPES = (
XiaomiMiioSelectDescription(
key=ATTR_LED_BRIGHTNESS,
attr_name=ATTR_LED_BRIGHTNESS,
name="Led Brightness",
set_method="set_led_brightness",
set_method_error_message="Setting the led brightness failed.",
icon="mdi:brightness-6",
@@ -187,7 +184,6 @@ SELECTOR_TYPES = (
XiaomiMiioSelectDescription(
key=ATTR_PTC_LEVEL,
attr_name=ATTR_PTC_LEVEL,
name="Auxiliary Heat Level",
set_method="set_ptc_level",
set_method_error_message="Setting the ptc level failed.",
icon="mdi:fire-circle",
@@ -138,6 +138,7 @@
},
"select": {
"airpurifier_mode": {
"name": "Mode",
"state": {
"auto": "[%key:common::state::auto%]",
"favorite": "Favorite",
@@ -145,6 +146,7 @@
}
},
"display_orientation": {
"name": "Display orientation",
"state": {
"forward": "Forward",
"left": "Left",
@@ -152,6 +154,7 @@
}
},
"led_brightness": {
"name": "LED brightness",
"state": {
"bright": "Bright",
"dim": "Dim",
@@ -159,6 +162,7 @@
}
},
"ptc_level": {
"name": "Auxiliary heat level",
"state": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
+1 -1
View File
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 7
PATCH_VERSION: Final = "0.dev0"
PATCH_VERSION: Final = "0b2"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2)
-36
View File
@@ -590,12 +590,6 @@
"config_flow": true,
"iot_class": "local_polling"
},
"aten_pe": {
"name": "ATEN Rack PDU",
"integration_type": "hub",
"config_flow": false,
"iot_class": "local_polling"
},
"atlanticcityelectric": {
"name": "Atlantic City Electric",
"integration_type": "virtual",
@@ -1489,12 +1483,6 @@
"config_flow": true,
"iot_class": "local_polling"
},
"dovado": {
"name": "Dovado",
"integration_type": "hub",
"config_flow": false,
"iot_class": "local_polling"
},
"downloader": {
"name": "Downloader",
"integration_type": "service",
@@ -2730,12 +2718,6 @@
"config_flow": false,
"iot_class": "local_push"
},
"greenwave": {
"name": "Greenwave Reality",
"integration_type": "hub",
"config_flow": false,
"iot_class": "local_polling"
},
"growatt_server": {
"integration_type": "hub",
"config_flow": true,
@@ -3947,12 +3929,6 @@
"config_flow": true,
"iot_class": "local_push"
},
"logentries": {
"name": "Logentries",
"integration_type": "hub",
"config_flow": false,
"iot_class": "cloud_push"
},
"logitech": {
"name": "Logitech",
"integrations": {
@@ -4587,12 +4563,6 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
"mycroft": {
"name": "Mycroft",
"integration_type": "hub",
"config_flow": false,
"iot_class": "local_push"
},
"myneomitis": {
"name": "MyNeomitis",
"integration_type": "hub",
@@ -7207,12 +7177,6 @@
"config_flow": true,
"iot_class": "local_push"
},
"thermoworks_smoke": {
"name": "ThermoWorks Smoke",
"integration_type": "hub",
"config_flow": false,
"iot_class": "cloud_polling"
},
"thethingsnetwork": {
"name": "The Things Network",
"integration_type": "hub",
+1 -1
View File
@@ -740,7 +740,7 @@ def _get_exposed_entities(
if include_state and (
attributes := {
attr_name: (
str(attr_name): (
str(attr_value)
if isinstance(attr_value, (Enum, Decimal, int))
else attr_value
+4 -4
View File
@@ -35,12 +35,12 @@ file-read-backwards==2.0.0
fnv-hash-fast==2.0.3
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.23.1
habluetooth==6.25.1
hass-nabucasa==2.2.0
hassil==3.8.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260624.0
home-assistant-intents==2026.6.1
home-assistant-frontend==20260624.1
home-assistant-intents==2026.6.24
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6
@@ -71,7 +71,7 @@ typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.9
urllib3>=2.0
uv==0.11.21
voluptuous-openapi==0.3.0
voluptuous-openapi==0.4.1
voluptuous-serialize==2.7.0
voluptuous==0.15.2
webrtc-models==0.3.0
+1 -1
View File
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
from typing import Final
FRONTEND_VERSION: Final[str] = "20260624.0"
FRONTEND_VERSION: Final[str] = "20260624.1"
MDI_ICONS: Final[set[str]] = {
"ab-testing",
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.7.0.dev0"
version = "2026.7.0b2"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
@@ -77,7 +77,7 @@ dependencies = [
"uv==0.11.21",
"voluptuous==0.15.2",
"voluptuous-serialize==2.7.0",
"voluptuous-openapi==0.3.0",
"voluptuous-openapi==0.4.1",
"yarl==1.24.2",
"webrtc-models==0.3.0",
"zeroconf==0.150.0",
+2 -2
View File
@@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2
hass-nabucasa==2.2.0
hassil==3.8.0
home-assistant-bluetooth==2.0.0
home-assistant-intents==2026.6.1
home-assistant-intents==2026.6.24
httpx==0.28.1
ifaddr==0.2.0
infrared-protocols==6.3.0
@@ -56,7 +56,7 @@ typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.9
urllib3>=2.0
uv==0.11.21
voluptuous-openapi==0.3.0
voluptuous-openapi==0.4.1
voluptuous-serialize==2.7.0
voluptuous==0.15.2
webrtc-models==0.3.0
+13 -19
View File
@@ -193,7 +193,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==14.1.3
aioamazondevices==14.1.8
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -583,9 +583,6 @@ asyncsleepiq==1.7.1
# homeassistant.components.sftp_storage
asyncssh==2.21.0
# homeassistant.components.aten_pe
# atenpdu==0.3.6
# homeassistant.components.aurora
auroranoaa==0.0.5
@@ -1179,9 +1176,6 @@ greeneye_monitor==3.0.3
# homeassistant.components.green_planet_energy
greenplanet-energy-api==0.1.10
# homeassistant.components.greenwave
greenwavereality==0.5.1
# homeassistant.components.pure_energie
gridnet==5.0.1
@@ -1219,7 +1213,7 @@ ha-xthings-cloud==1.0.5
habiticalib==0.4.7
# homeassistant.components.bluetooth
habluetooth==6.23.1
habluetooth==6.25.1
# homeassistant.components.hanna
hanna-cloud==0.0.7
@@ -1272,10 +1266,10 @@ hole==0.9.2
holidays==0.99
# homeassistant.components.frontend
home-assistant-frontend==20260624.0
home-assistant-frontend==20260624.1
# homeassistant.components.conversation
home-assistant-intents==2026.6.1
home-assistant-intents==2026.6.24
# homeassistant.components.homekit
homekit-audio-proxy==1.2.1
@@ -1284,7 +1278,7 @@ homekit-audio-proxy==1.2.1
homelink-integration-api==0.0.5
# homeassistant.components.homematicip_cloud
homematicip==2.13.1
homematicip==2.13.2
# homeassistant.components.homevolt
homevolt==0.5.0
@@ -1948,7 +1942,7 @@ py-schluter==0.1.7
py-sucks==0.9.11
# homeassistant.components.synology_dsm
py-synologydsm-api==2.10.0
py-synologydsm-api==2.10.1
# homeassistant.components.unifi_access
py-unifi-access==1.3.0
@@ -2018,7 +2012,7 @@ pyairobotrest==0.3.0
pyairvisual==2023.08.1
# homeassistant.components.anglian_water
pyanglianwater==3.2.2
pyanglianwater==3.2.3
# homeassistant.components.aprilaire
pyaprilaire==0.9.1
@@ -2730,7 +2724,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
python-roborock==5.14.2
python-roborock==5.21.0
# homeassistant.components.smarttub
python-smarttub==0.0.47
@@ -2899,7 +2893,7 @@ renault-api==0.5.12
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.21.2
reolink-aio==0.21.3
# homeassistant.components.radio_frequency
rf-protocols==4.3.0
@@ -3114,7 +3108,7 @@ surepy==0.9.0
swisshydrodata==0.1.0
# homeassistant.components.switchbot_cloud
switchbot-api==2.11.1
switchbot-api==2.12.0
# homeassistant.components.synology_srm
synology-srm==0.2.0
@@ -3221,10 +3215,10 @@ ttls==1.8.3
ttn_client==1.3.0
# homeassistant.components.tuya
tuya-device-handlers==0.0.22
tuya-device-handlers==0.0.24
# homeassistant.components.tuya
tuya-device-sharing-sdk==0.2.8
tuya-device-sharing-sdk==0.2.10
# homeassistant.components.twentemilieu
twentemilieu==3.0.0
@@ -3245,7 +3239,7 @@ uasiren==0.0.1
uhooapi==1.2.8
# homeassistant.components.unifiprotect
uiprotect==14.0.0
uiprotect==15.3.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.6.1
-12
View File
@@ -165,7 +165,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"asterisk_mbox",
"asuswrt",
"atag",
"aten_pe",
"atome",
"august",
"aurora",
@@ -274,7 +273,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"doods",
"doorbird",
"dormakaba_dkey",
"dovado",
"downloader",
"dremel_3d_printer",
"drop_connect",
@@ -420,7 +418,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"graphite",
"gree",
"greeneye_monitor",
"greenwave",
"group",
"gtfs",
"guardian",
@@ -548,7 +545,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"local_todo",
"location",
"locative",
"logentries",
"logi_circle",
"london_air",
"london_underground",
@@ -622,7 +618,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"mullvad",
"mutesync",
"mvglive",
"mycroft",
"myq",
"mysensors",
"mystrom",
@@ -914,7 +909,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"tesla_wall_connector",
"thermobeacon",
"thermopro",
"thermoworks_smoke",
"thethingsnetwork",
"thingspeak",
"thinkingcleaner",
@@ -1114,7 +1108,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"asterisk_mbox",
"asuswrt",
"atag",
"aten_pe",
"atome",
"august",
"aurora",
@@ -1226,7 +1219,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"doods",
"doorbird",
"dormakaba_dkey",
"dovado",
"downloader",
"dremel_3d_printer",
"drop_connect",
@@ -1379,7 +1371,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"graphite",
"gree",
"greeneye_monitor",
"greenwave",
"group",
"gtfs",
"guardian",
@@ -1514,7 +1505,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"local_todo",
"location",
"locative",
"logentries",
"logi_circle",
"london_air",
"london_underground",
@@ -1589,7 +1579,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"mullvad",
"mutesync",
"mvglive",
"mycroft",
"myq",
"mysensors",
"mystrom",
@@ -1898,7 +1887,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"tesla_wall_connector",
"thermobeacon",
"thermopro",
"thermoworks_smoke",
"thethingsnetwork",
"thingspeak",
"thinkingcleaner",
+3 -3
View File
@@ -107,8 +107,8 @@ async def test_alexa_unique_id_migration(
)
entity = entity_registry.async_get_or_create(
DOMAIN,
SWITCH_DOMAIN,
DOMAIN,
unique_id=f"{TEST_DEVICE_1_SN}-do_not_disturb",
device_id=device.id,
config_entry=mock_config_entry,
@@ -145,8 +145,8 @@ async def test_alexa_dnd_group_removal(
)
entity = entity_registry.async_get_or_create(
DOMAIN,
SWITCH_DOMAIN,
DOMAIN,
unique_id=f"{TEST_DEVICE_1_SN}-do_not_disturb",
device_id=device.id,
config_entry=mock_config_entry,
@@ -184,8 +184,8 @@ async def test_alexa_unsupported_notification_sensor_removal(
)
entity = entity_registry.async_get_or_create(
DOMAIN,
SENSOR_DOMAIN,
DOMAIN,
unique_id=f"{TEST_DEVICE_1_SN}-Timer",
device_id=device.id,
config_entry=mock_config_entry,
@@ -853,7 +853,7 @@
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Sorry, I am not aware of any area called Are the',
'speech': 'Sorry, I am not aware of any device called Are the',
}),
}),
}),
@@ -902,7 +902,7 @@
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Sorry, I am not aware of any area called Are the',
'speech': 'Sorry, I am not aware of any device called Are the',
}),
}),
}),
+182
View File
@@ -7,6 +7,7 @@ from typing import Any
from unittest.mock import ANY, Mock, patch
import pytest
import voluptuous as vol
from homeassistant.components import automation, input_boolean, script
from homeassistant.components.automation import (
@@ -1930,6 +1931,187 @@ async def test_automation_with_error_in_script_2(
assert "string value is None" in caplog.text
@pytest.mark.parametrize(
("side_effect", "expected_error", "expect_traceback"),
[
(
HomeAssistantError("boom"),
"Error while executing automation automation.hello: boom",
False,
),
(
vol.Invalid("not valid"),
"Error while executing automation automation.hello: not valid",
False,
),
(
ValueError("unexpected"),
"Unexpected error while executing automation automation.hello",
True,
),
],
ids=["home_assistant_error", "voluptuous_invalid", "unexpected_exception"],
)
async def test_automation_with_error_in_action_script(
hass: HomeAssistant,
calls: list[ServiceCall],
caplog: pytest.LogCaptureFixture,
hass_ws_client: WebSocketGenerator,
side_effect: Exception,
expected_error: str,
expect_traceback: bool,
) -> None:
"""Test errors raised while running the action script are handled and traced."""
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"id": "hello",
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
"action": {"action": "test.automation"},
}
},
)
with patch(
"homeassistant.helpers.script.Script.async_run",
side_effect=side_effect,
):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(calls) == 0
assert expected_error in caplog.text
# A HomeAssistantError/voluptuous error is logged without a traceback, an
# unexpected error is logged with a traceback.
assert ("Traceback" in caplog.text) is expect_traceback
# The error is recorded on the automation trace.
client = await hass_ws_client()
await client.send_json_auto_id(
{"type": "trace/list", "domain": "automation", "item_id": "hello"}
)
response = await client.receive_json()
assert response["success"]
traces = response["result"]
assert len(traces) == 1
assert traces[0]["error"] == str(side_effect)
@pytest.mark.parametrize(
("side_effect", "expected_error", "expect_traceback"),
[
(
HomeAssistantError("boom"),
"Error while checking conditions of automation automation.hello: boom",
False,
),
(
vol.Invalid("not valid"),
"Error while checking conditions of automation automation.hello: not valid",
False,
),
(
ValueError("unexpected"),
"Unexpected error while checking conditions of automation automation.hello",
True,
),
],
ids=["home_assistant_error", "voluptuous_invalid", "unexpected_exception"],
)
async def test_automation_with_error_in_condition(
hass: HomeAssistant,
calls: list[ServiceCall],
caplog: pytest.LogCaptureFixture,
hass_ws_client: WebSocketGenerator,
side_effect: Exception,
expected_error: str,
expect_traceback: bool,
) -> None:
"""Test errors raised while checking conditions are handled and traced."""
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"id": "hello",
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
"condition": {
"condition": "state",
"entity_id": "test.entity",
"state": "on",
},
"action": {"action": "test.automation"},
}
},
)
with patch(
"homeassistant.helpers.condition.ConditionsChecker.async_check",
side_effect=side_effect,
):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
# The action must not run when the condition check raises.
assert len(calls) == 0
assert expected_error in caplog.text
# A HomeAssistantError/voluptuous error is logged without a traceback, an
# unexpected error is logged with a traceback.
assert ("Traceback" in caplog.text) is expect_traceback
# The error is recorded on the automation trace.
client = await hass_ws_client()
await client.send_json_auto_id(
{"type": "trace/list", "domain": "automation", "item_id": "hello"}
)
response = await client.receive_json()
assert response["success"]
traces = response["result"]
assert len(traces) == 1
assert traces[0]["error"] == str(side_effect)
async def test_automation_with_error_in_condition_continues_after_recovery(
hass: HomeAssistant,
calls: list[ServiceCall],
) -> None:
"""Test the automation still runs once the condition stops raising."""
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"alias": "hello",
"trigger": {"platform": "event", "event_type": "test_event"},
"condition": {
"condition": "state",
"entity_id": "test.entity",
"state": "on",
},
"action": {"action": "test.automation"},
}
},
)
hass.states.async_set("test.entity", "on")
with patch(
"homeassistant.helpers.condition.ConditionsChecker.async_check",
side_effect=HomeAssistantError("boom"),
):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(calls) == 0
# Without the error, the condition passes and the action runs.
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(calls) == 1
async def test_automation_restore_last_triggered_with_initial_state(
hass: HomeAssistant,
) -> None:
@@ -302,7 +302,7 @@
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Sorry, I am not aware of any area called late added',
'speech': 'Sorry, I am not aware of any device called late added',
}),
}),
}),
@@ -373,7 +373,7 @@
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Sorry, I am not aware of any area called kitchen',
'speech': 'Sorry, I am not aware of any device called kitchen',
}),
}),
}),
@@ -423,7 +423,7 @@
'speech': dict({
'plain': dict({
'extra_data': None,
'speech': 'Sorry, I am not aware of any area called renamed',
'speech': 'Sorry, I am not aware of any device called renamed',
}),
}),
}),
@@ -467,10 +467,11 @@
'name': 'HassTurnOn',
}),
'match': True,
'sentence_template': '<turn> on (<area> <name>|<name> [in <area>])',
'sentence_template': '<turn> on [<the>] {name}',
'slots': dict({
'name': 'my cool light',
}),
'source': 'builtin',
'targets': dict({
'light.kitchen': dict({
'matched': True,
@@ -491,10 +492,11 @@
'name': 'HassTurnOff',
}),
'match': True,
'sentence_template': '[<turn>] (<area> <name>|<name> [in <area>]) [to] off',
'sentence_template': '[<turn>] [<the>] {name} [to] off',
'slots': dict({
'name': 'my cool light',
}),
'source': 'builtin',
'targets': dict({
'light.kitchen': dict({
'matched': True,
@@ -520,11 +522,12 @@
'name': 'HassTurnOn',
}),
'match': True,
'sentence_template': '<turn> on [(<all>|<the>)] <light> <in> <area>',
'sentence_template': '<turn> on [(<all>|<the>)] <light> <in> [<the>] {area}',
'slots': dict({
'area': 'kitchen',
'domain': 'light',
}),
'source': 'builtin',
'targets': dict({
'light.kitchen': dict({
'matched': True,
@@ -542,7 +545,7 @@
}),
'domain': dict({
'name': 'domain',
'text': 'lights',
'text': '',
'value': 'light',
}),
'state': dict({
@@ -555,12 +558,13 @@
'name': 'HassGetState',
}),
'match': True,
'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} [<in_area_floor>]',
'sentence_template': '<how_many> <light> <is> {on_off_states:state} [<in>] [<the>] {area}',
'slots': dict({
'area': 'kitchen',
'domain': 'lights',
'domain': 'light',
'state': 'on',
}),
'source': 'builtin',
'targets': dict({
'light.kitchen': dict({
'matched': False,
@@ -629,11 +633,12 @@
'name': 'HassLightSet',
}),
'match': True,
'sentence_template': '[<numeric_value_set>] <name> brightness [to] <brightness>',
'sentence_template': '[<numeric_value_set>] [<the>] {name} brightness [to] {brightness}[([ ]%)| percent]',
'slots': dict({
'brightness': '100',
'name': 'test light',
}),
'source': 'builtin',
'targets': dict({
'light.demo_1234': dict({
'matched': True,
@@ -660,10 +665,11 @@
'name': 'HassLightSet',
}),
'match': False,
'sentence_template': '[<numeric_value_set>] <name> brightness [to] <brightness>',
'sentence_template': '[<numeric_value_set>] [<the>] {name} brightness [to] {brightness}[([ ]%)| percent]',
'slots': dict({
'name': 'test light',
}),
'source': 'builtin',
'targets': dict({
}),
'unmatched_slots': dict({
@@ -729,19 +729,6 @@ async def test_satellite_area_context(
}
turn_off_calls.clear()
# Turn on/off all lights also works
for command in ("on", "off"):
result = await conversation.async_converse(
hass, f"turn {command} all lights", None, Context(), None
)
await hass.async_block_till_done()
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
# All lights should have been targeted
assert {s.entity_id for s in result.response.matched_states} == {
e.entity_id for e in all_lights
}
@pytest.mark.usefixtures("init_components")
async def test_error_no_device(hass: HomeAssistant) -> None:
@@ -841,7 +828,7 @@ async def test_error_no_device_on_floor(
assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS
assert (
result.response.speech["plain"]["speech"]
== "Sorry, I am not aware of any device called missing entity on ground floor"
== "Sorry, I am not aware of any device called missing entity in the ground floor"
)
@@ -1128,7 +1115,7 @@ async def test_error_no_domain_on_floor_exposed(
await hass.async_block_till_done()
result = await conversation.async_converse(
hass, "turn on all lights on the ground floor", None, Context(), None
hass, "turn on all lights in the ground floor", None, Context(), None
)
assert result.response.response_type is intent.IntentResponseType.ERROR
@@ -1493,21 +1480,6 @@ async def test_error_duplicate_names_same_area(
f" {name} in the {area_kitchen.name} area"
)
# question
result = await conversation.async_converse(
hass, f"is {name} on in the {area_kitchen.name}?", None, Context(), None
)
assert result.response.response_type is intent.IntentResponseType.ERROR
assert (
result.response.error_code
== intent.IntentResponseErrorCode.NO_VALID_TARGETS
)
assert (
result.response.speech["plain"]["speech"]
== f"Sorry, there are multiple devices called"
f" {name} in the {area_kitchen.name} area"
)
@pytest.mark.usefixtures("init_components")
async def test_duplicate_names_same_area_but_one_is_exposed(
@@ -2855,9 +2827,9 @@ async def test_config_sentences_priority(
{
"conversation": {
"intents": {
"CustomIntent": ["turn on <name>"],
"CustomIntent": ["turn on [the] {name}"],
"WorseCustomIntent": ["turn on the lamp"],
"FakeCustomIntent": ["turn on <name>"],
"FakeCustomIntent": ["turn on [the] {name}"],
}
}
},
@@ -127,29 +127,39 @@ async def test_cover_set_position(
async def test_cover_device_class(
hass: HomeAssistant,
init_components,
area_registry: ar.AreaRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test the open position for covers by device class."""
await cover_intent.async_setup_intents(hass)
entity_id = f"{cover.DOMAIN}.front"
hass.states.async_set(
entity_id, STATE_CLOSED, attributes={"device_class": "garage"}
area_kitchen = area_registry.async_get_or_create("kitchen_id")
area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen")
kitchen_window = entity_registry.async_get_or_create(
"cover", "demo", "kitchen_window"
)
async_expose_entity(hass, conversation.DOMAIN, entity_id, True)
kitchen_window = entity_registry.async_update_entity(
kitchen_window.entity_id, area_id=area_kitchen.id
)
hass.states.async_set(
kitchen_window.entity_id, STATE_CLOSED, attributes={"device_class": "window"}
)
async_expose_entity(hass, conversation.DOMAIN, kitchen_window.entity_id, True)
# Open service
calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER)
result = await conversation.async_converse(
hass, "open the garage door", None, Context(), None
hass, "open the window in the kitchen", None, Context(), None, device_id=None
)
await hass.async_block_till_done()
response = result.response
assert response.response_type is intent.IntentResponseType.ACTION_DONE
assert response.speech["plain"]["speech"] == "Opening the garage"
assert response.speech["plain"]["speech"] == "Opening the window"
assert len(calls) == 1
call = calls[0]
assert call.data == {"entity_id": entity_id}
assert call.data == {"entity_id": kitchen_window.entity_id}
async def test_valve_intents(
@@ -128,6 +128,54 @@ async def test_already_configured(
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("current_request_with_host")
@pytest.mark.parametrize(
("token", "expected_step"),
[
(
{
"access_token": "mock-access-token",
"expires_at": 9_999_999_999,
"scope": " ".join(OAUTH2_SCOPES),
},
"reauth_confirm",
),
(
{
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"expires_at": 9_999_999_999,
"scope": "account_info.read files.content.read files.content.write",
},
"reauth_permissions",
),
],
ids=["missing_refresh_token", "missing_scope"],
)
async def test_reauth_confirm_step(
hass: HomeAssistant,
mock_config_entry,
token: dict[str, object],
expected_step: str,
) -> None:
"""Test reauth shows the correct confirmation step for the broken token."""
mock_config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(
mock_config_entry, data={**mock_config_entry.data, "token": token}
)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == expected_step
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.EXTERNAL_STEP
assert result["step_id"] == "auth"
@pytest.mark.usefixtures("current_request_with_host")
@pytest.mark.parametrize(
(
+43 -1
View File
@@ -1,11 +1,13 @@
"""Test the Dropbox integration setup."""
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from python_dropbox_api import DropboxAuthException, DropboxUnknownException
from homeassistant.config_entries import ConfigEntryState
from homeassistant.components.dropbox.const import DOMAIN, OAUTH2_SCOPES
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
@@ -80,6 +82,46 @@ async def test_setup_entry_implementation_unavailable(
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("mock_dropbox_client")
@pytest.mark.parametrize(
"token",
[
{
"access_token": "mock-access-token",
"expires_at": 9_999_999_999,
"scope": " ".join(OAUTH2_SCOPES),
},
{
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"expires_at": 9_999_999_999,
"scope": "account_info.read files.content.read files.content.write",
},
],
ids=["missing_refresh_token", "missing_scope"],
)
async def test_setup_entry_triggers_reauth(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
token: dict[str, Any],
) -> None:
"""Test that a broken token triggers a reauth flow during setup."""
mock_config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(
mock_config_entry, data={**mock_config_entry.data, "token": token}
)
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
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert len(flows) == 1
assert flows[0]["context"]["source"] == SOURCE_REAUTH
assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id
@pytest.mark.usefixtures("mock_dropbox_client")
async def test_unload_entry(
hass: HomeAssistant,
+40 -3
View File
@@ -13,6 +13,7 @@ from duco_connectivity import (
Node,
NodeActionItemList,
NodeListActionItemList,
NodeType,
VentilationState,
)
import pytest
@@ -32,6 +33,7 @@ from . import setup_platform_integration
from tests.common import MockConfigEntry
_SELECT_ENTITY = "select.living_ventilation_state"
_VALVE_SELECT_ENTITY = "select.bedroom_valve_ventilation_state"
_UNSUPPORTED_SELECT_ENTITY = "select.office_co2_ventilation_state"
@@ -120,20 +122,55 @@ async def test_select_entity_created_with_dynamic_options(
assert hass.states.get(_UNSUPPORTED_SELECT_ENTITY) is None
async def test_select_ignores_non_box_nodes_even_when_actions_exist(
@pytest.mark.parametrize(
"valve_node_type",
[
pytest.param(NodeType.VLV, id="vlv"),
pytest.param(NodeType.VLVRH, id="vlvrh"),
pytest.param(NodeType.VLVVOC, id="vlvvoc"),
pytest.param(NodeType.VLVCO2, id="vlvco2"),
pytest.param(NodeType.VLVCO2RH, id="vlvco2rh"),
pytest.param(NodeType.EAV, id="eav"),
pytest.param(NodeType.EAVRH, id="eavrh"),
pytest.param(NodeType.EAVVOC, id="eavvoc"),
pytest.param(NodeType.EAVCO2, id="eavco2"),
],
)
async def test_select_creates_entities_for_controllable_valve_nodes(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
mock_sensor_nodes: list[Node],
valve_node_type: NodeType,
) -> None:
"""Test select discovery ignores non-box nodes that expose the same action."""
"""Test select discovery includes valve nodes when they advertise control."""
mock_nodes = [
replace(
mock_sensor_nodes[0],
general=replace(mock_sensor_nodes[0].general, node_type=valve_node_type),
),
*mock_sensor_nodes[1:],
]
mock_duco_client.async_get_nodes.return_value = mock_nodes
mock_duco_client.async_get_node_actions.return_value = _build_multi_node_actions(
[1, 2, 50, 113],
[node.node_id for node in mock_nodes],
options=["AUTO", "CNT1", "CNT2", "CNT3", "MAN1", "MAN2", "MAN3"],
)
await setup_platform_integration(hass, mock_config_entry, [Platform.SELECT])
assert hass.states.get(_SELECT_ENTITY) is not None
valve_state = hass.states.get(_VALVE_SELECT_ENTITY)
assert valve_state is not None
assert valve_state.attributes[ATTR_OPTIONS] == [
"AUTO",
"CNT1",
"CNT2",
"CNT3",
"MAN1",
"MAN2",
"MAN3",
]
assert hass.states.get(_UNSUPPORTED_SELECT_ENTITY) is None
@@ -853,12 +853,11 @@
}),
]),
'envoy_model_data': dict({
'ctmeter_consumption': None,
'ctmeter_consumption_phases': None,
'ctmeter_production': None,
'ctmeter_production_phases': None,
'ctmeter_storage': None,
'ctmeter_storage_phases': None,
'acb_inventory': None,
'acb_power': None,
'battery_aggregate': None,
'c6cc': None,
'collar': None,
'ctmeters': dict({
}),
'ctmeters_phases': dict({
@@ -1768,12 +1767,11 @@
}),
]),
'envoy_model_data': dict({
'ctmeter_consumption': None,
'ctmeter_consumption_phases': None,
'ctmeter_production': None,
'ctmeter_production_phases': None,
'ctmeter_storage': None,
'ctmeter_storage_phases': None,
'acb_inventory': None,
'acb_power': None,
'battery_aggregate': None,
'c6cc': None,
'collar': None,
'ctmeters': dict({
}),
'ctmeters_phases': dict({
@@ -1822,8 +1820,14 @@
]),
}),
'fixtures': dict({
'/admin/lib/acb_config': 'Testing request replies.',
'/admin/lib/acb_config_log': '{"headers":{"Hello":"World"},"code":200}',
'/admin/lib/network_display': 'Testing request replies.',
'/admin/lib/network_display_log': '{"headers":{"Hello":"World"},"code":200}',
'/admin/lib/tariff': 'Testing request replies.',
'/admin/lib/tariff_log': '{"headers":{"Hello":"World"},"code":200}',
'/admin/lib/wireless_display': 'Testing request replies.',
'/admin/lib/wireless_display_log': '{"headers":{"Hello":"World"},"code":200}',
'/api/v1/production': 'Testing request replies.',
'/api/v1/production/inverters': 'Testing request replies.',
'/api/v1/production/inverters_log': '{"headers":{"Hello":"World"},"code":200}',
@@ -1832,6 +1836,8 @@
'/home_log': '{"headers":{"Hello":"World"},"code":200}',
'/info': 'Testing request replies.',
'/info_log': '{"headers":{"Hello":"World"},"code":200}',
'/inventory.json?deleted=1': 'Testing request replies.',
'/inventory.json?deleted=1_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/ensemble/dry_contacts': 'Testing request replies.',
'/ivp/ensemble/dry_contacts_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/ensemble/generator': 'Testing request replies.',
@@ -1840,18 +1846,26 @@
'/ivp/ensemble/inventory_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/ensemble/power': 'Testing request replies.',
'/ivp/ensemble/power_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/ensemble/relay': 'Testing request replies.',
'/ivp/ensemble/relay_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/ensemble/secctrl': 'Testing request replies.',
'/ivp/ensemble/secctrl_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/ensemble/status': 'Testing request replies.',
'/ivp/ensemble/status_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/livedata/status': 'Testing request replies.',
'/ivp/livedata/status_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/meters': 'Testing request replies.',
'/ivp/meters/readings': 'Testing request replies.',
'/ivp/meters/readings_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/meters_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/pdm/device_data': 'Testing request replies.',
'/ivp/pdm/device_data_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/pdm/energy': 'Testing request replies.',
'/ivp/pdm/energy_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/sc/pvlimit': 'Testing request replies.',
'/ivp/sc/pvlimit_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/sc/sched': 'Testing request replies.',
'/ivp/sc/sched_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/ss/dry_contact_settings': 'Testing request replies.',
'/ivp/ss/dry_contact_settings_log': '{"headers":{"Hello":"World"},"code":200}',
'/ivp/ss/gen_config': 'Testing request replies.',
@@ -2727,12 +2741,11 @@
}),
]),
'envoy_model_data': dict({
'ctmeter_consumption': None,
'ctmeter_consumption_phases': None,
'ctmeter_production': None,
'ctmeter_production_phases': None,
'ctmeter_storage': None,
'ctmeter_storage_phases': None,
'acb_inventory': None,
'acb_power': None,
'battery_aggregate': None,
'c6cc': None,
'collar': None,
'ctmeters': dict({
}),
'ctmeters_phases': dict({
@@ -2781,9 +2794,18 @@
]),
}),
'fixtures': dict({
'/admin/lib/acb_config_log': dict({
'Error': "EnvoyError('Test')",
}),
'/admin/lib/network_display_log': dict({
'Error': "EnvoyError('Test')",
}),
'/admin/lib/tariff_log': dict({
'Error': "EnvoyError('Test')",
}),
'/admin/lib/wireless_display_log': dict({
'Error': "EnvoyError('Test')",
}),
'/api/v1/production/inverters_log': dict({
'Error': "EnvoyError('Test')",
}),
@@ -2796,6 +2818,9 @@
'/info_log': dict({
'Error': "EnvoyError('Test')",
}),
'/inventory.json?deleted=1_log': dict({
'Error': "EnvoyError('Test')",
}),
'/ivp/ensemble/dry_contacts_log': dict({
'Error': "EnvoyError('Test')",
}),
@@ -2808,12 +2833,18 @@
'/ivp/ensemble/power_log': dict({
'Error': "EnvoyError('Test')",
}),
'/ivp/ensemble/relay_log': dict({
'Error': "EnvoyError('Test')",
}),
'/ivp/ensemble/secctrl_log': dict({
'Error': "EnvoyError('Test')",
}),
'/ivp/ensemble/status_log': dict({
'Error': "EnvoyError('Test')",
}),
'/ivp/livedata/status_log': dict({
'Error': "EnvoyError('Test')",
}),
'/ivp/meters/readings_log': dict({
'Error': "EnvoyError('Test')",
}),
@@ -2823,9 +2854,15 @@
'/ivp/pdm/device_data_log': dict({
'Error': "EnvoyError('Test')",
}),
'/ivp/pdm/energy_log': dict({
'Error': "EnvoyError('Test')",
}),
'/ivp/sc/pvlimit_log': dict({
'Error': "EnvoyError('Test')",
}),
'/ivp/sc/sched_log': dict({
'Error': "EnvoyError('Test')",
}),
'/ivp/ss/dry_contact_settings_log': dict({
'Error': "EnvoyError('Test')",
}),
@@ -3711,12 +3748,11 @@
}),
]),
'envoy_model_data': dict({
'ctmeter_consumption': None,
'ctmeter_consumption_phases': None,
'ctmeter_production': None,
'ctmeter_production_phases': None,
'ctmeter_storage': None,
'ctmeter_storage_phases': None,
'acb_inventory': None,
'acb_power': None,
'battery_aggregate': None,
'c6cc': None,
'collar': None,
'ctmeters': dict({
}),
'ctmeters_phases': dict({
@@ -19745,59 +19781,16 @@
}),
]),
'envoy_model_data': dict({
'ctmeter_consumption': dict({
'__type': "<class 'pyenphase.models.meters.EnvoyMeterData'>",
'repr': "EnvoyMeterData(eid='100000020', timestamp=1708006120, energy_delivered=21234, energy_received=22345, active_power=101, power_factor=0.21, voltage=112, current=0.3, frequency=50.2, state='enabled', measurement_type='net-consumption', metering_status='normal', status_flags=[])",
'acb_inventory': None,
'acb_power': None,
'battery_aggregate': None,
'c6cc': dict({
'__type': "<class 'pyenphase.models.c6combiner.EnvoyC6CC'>",
'repr': "EnvoyC6CC(admin_state=82, admin_state_str='ENCMN_C6_CC_READY', firmware_loaded_date=1752945451, firmware_version='0.1.20-D1', installed_date=1752945451, last_report_date=1752945451, communicating=True, part_number='800-02403-r08', serial_number='482523040549', dmir_version='0.1.20-D1')",
}),
'ctmeter_consumption_phases': dict({
'L1': dict({
'__type': "<class 'pyenphase.models.meters.EnvoyMeterData'>",
'repr': "EnvoyMeterData(eid='100000021', timestamp=1708006121, energy_delivered=212341, energy_received=223451, active_power=21, power_factor=0.22, voltage=112, current=0.3, frequency=50.2, state='enabled', measurement_type='net-consumption', metering_status='normal', status_flags=[])",
}),
'L2': dict({
'__type': "<class 'pyenphase.models.meters.EnvoyMeterData'>",
'repr': "EnvoyMeterData(eid='100000022', timestamp=1708006122, energy_delivered=212342, energy_received=223452, active_power=31, power_factor=0.23, voltage=112, current=0.3, frequency=50.2, state='enabled', measurement_type='net-consumption', metering_status='normal', status_flags=[])",
}),
'L3': dict({
'__type': "<class 'pyenphase.models.meters.EnvoyMeterData'>",
'repr': "EnvoyMeterData(eid='100000023', timestamp=1708006123, energy_delivered=212343, energy_received=223453, active_power=51, power_factor=0.24, voltage=112, current=0.3, frequency=50.2, state='enabled', measurement_type='net-consumption', metering_status='normal', status_flags=[])",
}),
}),
'ctmeter_production': dict({
'__type': "<class 'pyenphase.models.meters.EnvoyMeterData'>",
'repr': "EnvoyMeterData(eid='100000010', timestamp=1708006110, energy_delivered=11234, energy_received=12345, active_power=100, power_factor=0.11, voltage=111, current=0.2, frequency=50.1, state='enabled', measurement_type='production', metering_status='normal', status_flags=['production-imbalance', 'power-on-unused-phase'])",
}),
'ctmeter_production_phases': dict({
'L1': dict({
'__type': "<class 'pyenphase.models.meters.EnvoyMeterData'>",
'repr': "EnvoyMeterData(eid='100000011', timestamp=1708006111, energy_delivered=112341, energy_received=123451, active_power=20, power_factor=0.12, voltage=111, current=0.2, frequency=50.1, state='enabled', measurement_type='production', metering_status='normal', status_flags=['production-imbalance'])",
}),
'L2': dict({
'__type': "<class 'pyenphase.models.meters.EnvoyMeterData'>",
'repr': "EnvoyMeterData(eid='100000012', timestamp=1708006112, energy_delivered=112342, energy_received=123452, active_power=30, power_factor=0.13, voltage=111, current=0.2, frequency=50.1, state='enabled', measurement_type='production', metering_status='normal', status_flags=['power-on-unused-phase'])",
}),
'L3': dict({
'__type': "<class 'pyenphase.models.meters.EnvoyMeterData'>",
'repr': "EnvoyMeterData(eid='100000013', timestamp=1708006113, energy_delivered=112343, energy_received=123453, active_power=50, power_factor=0.14, voltage=111, current=0.2, frequency=50.1, state='enabled', measurement_type='production', metering_status='normal', status_flags=[])",
}),
}),
'ctmeter_storage': dict({
'__type': "<class 'pyenphase.models.meters.EnvoyMeterData'>",
'repr': "EnvoyMeterData(eid='100000030', timestamp=1708006120, energy_delivered=31234, energy_received=32345, active_power=103, power_factor=0.23, voltage=113, current=0.4, frequency=50.3, state='enabled', measurement_type='storage', metering_status='normal', status_flags=[])",
}),
'ctmeter_storage_phases': dict({
'L1': dict({
'__type': "<class 'pyenphase.models.meters.EnvoyMeterData'>",
'repr': "EnvoyMeterData(eid='100000031', timestamp=1708006121, energy_delivered=312341, energy_received=323451, active_power=22, power_factor=0.32, voltage=113, current=0.4, frequency=50.3, state='enabled', measurement_type='storage', metering_status='normal', status_flags=[])",
}),
'L2': dict({
'__type': "<class 'pyenphase.models.meters.EnvoyMeterData'>",
'repr': "EnvoyMeterData(eid='100000032', timestamp=1708006122, energy_delivered=312342, energy_received=323452, active_power=33, power_factor=0.23, voltage=112, current=0.3, frequency=50.2, state='enabled', measurement_type='storage', metering_status='normal', status_flags=[])",
}),
'L3': dict({
'__type': "<class 'pyenphase.models.meters.EnvoyMeterData'>",
'repr': "EnvoyMeterData(eid='100000033', timestamp=1708006123, energy_delivered=312343, energy_received=323453, active_power=53, power_factor=0.24, voltage=112, current=0.3, frequency=50.2, state='enabled', measurement_type='storage', metering_status='normal', status_flags=[])",
}),
'collar': dict({
'__type': "<class 'pyenphase.models.collar.EnvoyCollar'>",
'repr': "EnvoyCollar(admin_state=88, admin_state_str='ENCMN_MDE_ON_GRID', firmware_loaded_date=1752939759, firmware_version='3.0.6-D0', installed_date=1752939759, last_report_date=1752939759, communicating=True, mid_state='close', grid_state='on_grid', part_number='865-00400-r22', serial_number='482520020939', temperature=42, temperature_unit='C', control_error=0, collar_state='Installed')",
}),
'ctmeters': dict({
'backfeed': dict({
+79 -4
View File
@@ -107,12 +107,12 @@ async def test_job_manager_ws_updates(
job_data: Job | None = None
@callback
def mock_subcription_callback(job: Job) -> None:
def mock_subscription_callback(job: Job) -> None:
nonlocal job_data
job_data = job
subscription = JobSubscription(
mock_subcription_callback, name="test_job", reference="test"
mock_subscription_callback, name="test_job", reference="test"
)
unsubscribe = data_coordinator.jobs.subscribe(subscription)
@@ -318,11 +318,11 @@ async def test_job_manager_reload_on_supervisor_restart(
job_data: Job | None = None
@callback
def mock_subcription_callback(job: Job) -> None:
def mock_subscription_callback(job: Job) -> None:
nonlocal job_data
job_data = job
subscription = JobSubscription(mock_subcription_callback, name="test_job")
subscription = JobSubscription(mock_subscription_callback, name="test_job")
data_coordinator.jobs.subscribe(subscription)
# Send supervisor restart signal
@@ -347,3 +347,78 @@ async def test_job_manager_reload_on_supervisor_restart(
assert job_data.reference == "test"
assert job_data.done is True
assert not data_coordinator.jobs.current_jobs
@pytest.mark.usefixtures("all_setup_requests")
async def test_subscribe_returns_unsubscribe_when_job_already_matches(
hass: HomeAssistant,
jobs_info: AsyncMock,
hass_supervisor_ws_client: WebSocketGenerator,
) -> None:
"""Test subscribe returns a working unsubscribe even if a job already matches."""
jobs_info.return_value = JobsInfo(
ignore_conditions=[],
jobs=[
Job(
name="test_job",
reference="test",
uuid=uuid4(),
progress=0,
stage=None,
done=False,
errors=[],
created=datetime.now(), # pylint: disable=home-assistant-enforce-naive-now
extra=None,
child_jobs=[],
)
],
)
result = await async_setup_component(hass, DOMAIN, {})
assert result
client = await hass_supervisor_ws_client()
data_coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR]
received: list[Job] = []
@callback
def mock_subscription_callback(job: Job) -> None:
received.append(job)
subscription = JobSubscription(mock_subscription_callback, name="test_job")
unsubscribe = data_coordinator.jobs.subscribe(subscription)
# Existing matching job is delivered immediately, and a callable unsubscribe
# is returned (not the None result of the callback)
assert len(received) == 1
assert received[0].name == "test_job"
assert callable(unsubscribe)
# After unsubscribing, a new matching job update is no longer delivered
unsubscribe()
await client.send_json(
{
"id": 1,
"type": "supervisor/event",
"data": {
"event": "job",
"data": {
"name": "test_job",
"reference": "test",
"uuid": uuid4().hex,
"progress": 50,
"stage": None,
"done": False,
"errors": [],
"created": datetime.now().isoformat(), # pylint: disable=home-assistant-enforce-naive-now
"extra": None,
},
},
}
)
msg = await client.receive_json()
assert msg["success"]
await hass.async_block_till_done()
assert len(received) == 1
-1
View File
@@ -1 +0,0 @@
"""Tests for the logentries component."""
-75
View File
@@ -1,75 +0,0 @@
"""The tests for the Logentries component."""
from unittest.mock import ANY, call, patch
import pytest
from homeassistant.components import logentries
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
async def test_setup_config_full(hass: HomeAssistant) -> None:
"""Test setup with all data."""
config = {"logentries": {"token": "secret"}}
assert await async_setup_component(hass, logentries.DOMAIN, config)
with patch("homeassistant.components.logentries.requests.post") as mock_post:
hass.states.async_set("fake.entity", STATE_ON)
await hass.async_block_till_done()
assert len(mock_post.mock_calls) == 1
async def test_setup_config_defaults(hass: HomeAssistant) -> None:
"""Test setup with defaults."""
config = {"logentries": {"token": "token"}}
assert await async_setup_component(hass, logentries.DOMAIN, config)
with patch("homeassistant.components.logentries.requests.post") as mock_post:
hass.states.async_set("fake.entity", STATE_ON)
await hass.async_block_till_done()
assert len(mock_post.mock_calls) == 1
@pytest.fixture
def mock_dump():
"""Mock json dumps."""
with patch("json.dumps") as mock_dump:
yield mock_dump
@pytest.fixture
def mock_requests():
"""Mock requests."""
with patch.object(logentries, "requests") as mock_requests:
yield mock_requests
async def test_event_listener(hass: HomeAssistant, mock_dump, mock_requests) -> None:
"""Test event listener."""
mock_dump.side_effect = lambda x: x
mock_post = mock_requests.post
mock_requests.exceptions.RequestException = Exception
config = {"logentries": {"token": "token"}}
assert await async_setup_component(hass, logentries.DOMAIN, config)
valid = {"1": 1, "1.0": 1.0, STATE_ON: 1, STATE_OFF: 0, "foo": "foo"}
for in_, out in valid.items():
payload = {
"host": "https://webhook.logentries.com/noformat/logs/token",
"event": [
{
"domain": "fake",
"entity_id": "entity",
"attributes": {},
"time": ANY,
"value": out,
}
],
}
hass.states.async_set("fake.entity", in_)
await hass.async_block_till_done()
assert mock_post.call_count == 1
assert mock_post.call_args == call(payload["host"], data=payload, timeout=10)
mock_post.reset_mock()
@@ -4765,3 +4765,46 @@ async def test_import_statistics_with_last_reset(
},
]
}
async def test_entity_options_ws(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test recorder entity options WS commands."""
client = await hass_ws_client()
await async_setup_recorder_instance(hass, {"exclude": {"domains": "test2"}})
# Test getting a single entity's settings
await client.send_json_auto_id(
{
"type": "recorder/entity_options/get",
"entity_id": "test.recorder",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"recording_disabled_by": None}
await client.send_json_auto_id(
{
"type": "recorder/entity_options/get",
"entity_id": "test2.recorder",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"recording_disabled_by": "user"}
# Test getting settings for an unknown entity
await client.send_json_auto_id(
{
"type": "recorder/entity_options/get",
"entity_id": "unknown.entity",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"recording_disabled_by": None}
@@ -318,6 +318,7 @@
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'always',
'delayed',
'delegated',
'scheduled',
]),
}),
@@ -359,6 +360,7 @@
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'always',
'delayed',
'delegated',
'scheduled',
]),
}),
@@ -1143,6 +1145,7 @@
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'always',
'delayed',
'delegated',
'scheduled',
]),
}),
@@ -1184,6 +1187,7 @@
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'always',
'delayed',
'delegated',
'scheduled',
]),
}),
@@ -2411,6 +2415,7 @@
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'always',
'delayed',
'delegated',
'scheduled',
]),
}),
@@ -2452,6 +2457,7 @@
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'always',
'delayed',
'delegated',
'scheduled',
]),
}),
@@ -3400,6 +3406,7 @@
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'always',
'delayed',
'delegated',
'scheduled',
]),
}),
@@ -3441,6 +3448,7 @@
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'always',
'delayed',
'delegated',
'scheduled',
]),
}),
@@ -4273,6 +4281,7 @@
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'always',
'delayed',
'delegated',
'scheduled',
]),
}),
@@ -4314,6 +4323,7 @@
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'always',
'delayed',
'delegated',
'scheduled',
]),
}),
@@ -5088,6 +5098,7 @@
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'always',
'delayed',
'delegated',
'scheduled',
]),
}),
@@ -5129,6 +5140,7 @@
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'always',
'delayed',
'delegated',
'scheduled',
]),
}),
@@ -5971,6 +5983,7 @@
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'always',
'delayed',
'delegated',
'scheduled',
]),
}),
@@ -6012,6 +6025,7 @@
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'always',
'delayed',
'delegated',
'scheduled',
]),
}),
+1 -1
View File
@@ -1076,7 +1076,7 @@ async def test_privacy_mode_change_callback(
def register_callback(
self, callback_id: str, callback: Callable[[], None], *args, **key_args
) -> None:
if callback_id == "privacy_mode_change":
if callback_id == "privacy_mode_change_623":
self.callback_func = callback
callback_mock = callback_mock_class()
+22 -15
View File
@@ -3,6 +3,7 @@
import asyncio
from collections.abc import Callable, Generator
from copy import deepcopy
from dataclasses import asdict
import logging
import pathlib
import tempfile
@@ -272,12 +273,9 @@ def make_mock_switch(
return trait
def make_dnd_timer(dataclass_template: RoborockBase) -> AsyncMock:
"""Make a function for the fake timer trait that emulates the real behavior."""
dnd_trait = make_mock_switch(
trait_spec=DoNotDisturbTrait,
dataclass_template=dataclass_template,
)
def make_dnd_timer(dataclass_template: RoborockBase) -> DoNotDisturbTrait:
"""Create a DoNotDisturbTrait for testing."""
dnd_trait = DoNotDisturbTrait(**asdict(dataclass_template))
async def set_dnd_timer(timer: DnDTimer) -> None:
dnd_trait.start_hour = timer.start_hour
@@ -286,16 +284,19 @@ def make_dnd_timer(dataclass_template: RoborockBase) -> AsyncMock:
dnd_trait.end_minute = timer.end_minute
dnd_trait.enabled = timer.enabled
dnd_trait.set_dnd_timer = AsyncMock()
dnd_trait.set_dnd_timer.side_effect = set_dnd_timer
dnd_trait.set_dnd_timer = AsyncMock(side_effect=set_dnd_timer)
dnd_trait.enable = AsyncMock(side_effect=lambda: setattr(dnd_trait, "enabled", 1))
dnd_trait.disable = AsyncMock(side_effect=lambda: setattr(dnd_trait, "enabled", 0))
dnd_trait.refresh = AsyncMock()
return dnd_trait
def make_valley_electric_timer(dataclass_template: RoborockBase) -> AsyncMock:
"""Make a function for the fake timer trait that emulates the real behavior."""
valley_electric_timer_trait = make_mock_switch(
trait_spec=ValleyElectricityTimerTrait,
dataclass_template=dataclass_template,
def make_valley_electric_timer(
dataclass_template: RoborockBase,
) -> ValleyElectricityTimerTrait:
"""Create a ValleyElectricityTimerTrait for testing."""
valley_electric_timer_trait = ValleyElectricityTimerTrait(
**asdict(dataclass_template)
)
async def set_timer(timer: ValleyElectricityTimer) -> None:
@@ -305,8 +306,14 @@ def make_valley_electric_timer(dataclass_template: RoborockBase) -> AsyncMock:
valley_electric_timer_trait.end_minute = timer.end_minute
valley_electric_timer_trait.enabled = timer.enabled
valley_electric_timer_trait.set_timer = AsyncMock()
valley_electric_timer_trait.set_timer.side_effect = set_timer
valley_electric_timer_trait.set_timer = AsyncMock(side_effect=set_timer)
valley_electric_timer_trait.enable = AsyncMock(
side_effect=lambda: setattr(valley_electric_timer_trait, "enabled", 1)
)
valley_electric_timer_trait.disable = AsyncMock(
side_effect=lambda: setattr(valley_electric_timer_trait, "enabled", 0)
)
valley_electric_timer_trait.refresh = AsyncMock()
return valley_electric_timer_trait
+18 -1
View File
@@ -4,9 +4,10 @@ import pytest
from roborock.exceptions import RoborockTimeout
from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE
from homeassistant.const import Platform
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_component import async_update_entity
from .conftest import FakeDevice
@@ -49,6 +50,22 @@ async def test_update_sound_volume(
assert state.state == "3.0"
async def test_volume_unknown_value(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_vacuum: FakeDevice,
) -> None:
"""Test the entity reports unknown when the trait value is None."""
assert fake_vacuum.v1_properties is not None
fake_vacuum.v1_properties.sound_volume.volume = None
await async_update_entity(hass, "number.roborock_s7_maxv_volume")
state = hass.states.get("number.roborock_s7_maxv_volume")
assert state is not None
assert state.state == STATE_UNKNOWN
async def test_volume_update_failed(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
+54 -3
View File
@@ -1,21 +1,22 @@
"""Test Roborock Time platform."""
from collections.abc import Callable
from datetime import time
from datetime import time, timedelta
from typing import Any
from freezegun.api import FrozenDateTimeFactory
import pytest
import roborock
from roborock.data import DnDTimer, RoborockBaseTimer, ValleyElectricityTimer
from homeassistant.components.time import SERVICE_SET_VALUE
from homeassistant.const import Platform
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .conftest import FakeDevice
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture
@@ -127,3 +128,53 @@ async def test_update_failure(
target={"entity_id": entity_id},
)
assert fake_vacuum.v1_properties.dnd.set_dnd_timer.call_count == 1
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize(
("entity_id", "trait", "missing_attribute"),
[
(
"time.roborock_s7_maxv_do_not_disturb_begin",
lambda x: x.v1_properties.dnd,
"start_hour",
),
(
"time.roborock_s7_maxv_do_not_disturb_begin",
lambda x: x.v1_properties.dnd,
"start_minute",
),
(
"time.roborock_s7_maxv_do_not_disturb_end",
lambda x: x.v1_properties.dnd,
"end_hour",
),
(
"time.roborock_s7_maxv_off_peak_start",
lambda x: x.v1_properties.valley_electricity_timer,
"start_minute",
),
],
)
async def test_missing_value(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
setup_entry: MockConfigEntry,
fake_vacuum: FakeDevice,
entity_id: str,
trait: Callable[[FakeDevice], Any],
missing_attribute: str,
) -> None:
"""Test that a missing time component reports as unknown instead of raising."""
state = hass.states.get(entity_id)
assert state is not None
assert state.state != STATE_UNKNOWN
setattr(trait(fake_vacuum), missing_attribute, None)
freezer.tick(timedelta(seconds=31))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNKNOWN
+162 -96
View File
@@ -16,8 +16,11 @@ from homeassistant.util import dt as dt_util
from tests.typing import WebSocketGenerator
# San Diego (default test location) and Longyearbyen, Svalbard (deep polar).
# San Diego (default test location), Kotzebue, Alaska (just inside the Arctic
# Circle - brief midnight sun in June) and Longyearbyen, Svalbard (deep polar -
# long polar night in December).
_SAN_DIEGO = (32.87336, -117.22743, "US/Pacific")
_KOTZEBUE = (66.8983, -162.5966, "America/Anchorage")
_SVALBARD = (78.22, 15.65, "Europe/Oslo")
_TWILIGHT_TYPES = ("any", "civil", "nautical", "astronomical")
@@ -38,8 +41,8 @@ def _find_run_id(traces, trace_type, item_id):
return None
async def assert_automation_condition_trace(hass_ws_client, automation_id, expected):
"""Test the result of automation condition."""
async def _get_automation_condition_trace(hass_ws_client, automation_id):
"""Return the condition trace for a given automation."""
msg_id = 1
def next_id():
@@ -71,8 +74,15 @@ async def assert_automation_condition_trace(hass_ws_client, automation_id, expec
assert response["success"]
trace = response["result"]
assert len(trace["trace"]["condition/0"]) == 1
condition_trace = trace["trace"]["condition/0"][0]["result"]
assert condition_trace == expected
return trace["trace"]["condition/0"][0]
async def assert_automation_condition_trace(hass_ws_client, automation_id, expected):
"""Test the result of automation condition."""
condition_trace = await _get_automation_condition_trace(
hass_ws_client, automation_id
)
assert condition_trace["result"] == expected
async def test_if_action_before_sunrise_no_offset(
@@ -937,14 +947,14 @@ async def test_if_action_before_sunrise_no_offset_kotzebue(
) -> None:
"""Test if action was before sunrise.
Local timezone: Alaska time
Location: Kotzebue, which has a very skewed local timezone with sunrise
at 7 AM and sunset at 3AM during summer
After sunrise is true from sunrise until midnight, local time.
Local timezone: Alaska time (America/Anchorage)
Location: Kotzebue, Alaska, whose far-west longitude skews local time by
~3 hours, so in late July sunrise is ~04:48 local. Before sunrise is true
from local midnight until sunrise.
"""
await hass.config.async_set_time_zone("America/Anchorage")
hass.config.latitude = 66.5
hass.config.longitude = 162.4
hass.config.latitude = 66.8983
hass.config.longitude = -162.5966
await async_setup_component(
hass,
automation.DOMAIN,
@@ -961,10 +971,9 @@ async def test_if_action_before_sunrise_no_offset_kotzebue(
},
)
# sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local
# sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC
# sunrise: 2015-07-24 04:48:24 local = 2015-07-24 12:48:24 UTC
# now = sunrise + 1s -> 'before sunrise' not true
now = datetime(2015, 7, 24, 15, 21, 13, tzinfo=dt_util.UTC)
now = datetime(2015, 7, 24, 12, 48, 25, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
@@ -972,11 +981,11 @@ async def test_if_action_before_sunrise_no_offset_kotzebue(
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": False, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"},
{"result": False, "wanted_time_before": "2015-07-24T12:48:24.249497+00:00"},
)
# now = sunrise - 1h -> 'before sunrise' true
now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC)
now = datetime(2015, 7, 24, 11, 48, 24, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
@@ -984,7 +993,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue(
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": True, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"},
{"result": True, "wanted_time_before": "2015-07-24T12:48:24.249497+00:00"},
)
# now = local midnight -> 'before sunrise' true
@@ -996,7 +1005,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue(
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": True, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"},
{"result": True, "wanted_time_before": "2015-07-24T12:48:24.249497+00:00"},
)
# now = local midnight - 1s -> 'before sunrise' not true
@@ -1008,7 +1017,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue(
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": False, "wanted_time_before": "2015-07-23T15:12:19.155123+00:00"},
{"result": False, "wanted_time_before": "2015-07-23T12:43:32.413351+00:00"},
)
@@ -1019,14 +1028,14 @@ async def test_if_action_after_sunrise_no_offset_kotzebue(
) -> None:
"""Test if action was after sunrise.
Local timezone: Alaska time
Location: Kotzebue, which has a very skewed local timezone with sunrise
at 7 AM and sunset at 3AM during summer
Before sunrise is true from midnight until sunrise, local time.
Local timezone: Alaska time (America/Anchorage)
Location: Kotzebue, Alaska, whose far-west longitude skews local time by
~3 hours, so in late July sunrise is ~04:48 local. After sunrise is true
from sunrise until local midnight.
"""
await hass.config.async_set_time_zone("America/Anchorage")
hass.config.latitude = 66.5
hass.config.longitude = 162.4
hass.config.latitude = 66.8983
hass.config.longitude = -162.5966
await async_setup_component(
hass,
automation.DOMAIN,
@@ -1043,10 +1052,9 @@ async def test_if_action_after_sunrise_no_offset_kotzebue(
},
)
# sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local
# sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC
# now = sunrise -> 'after sunrise' true
now = datetime(2015, 7, 24, 15, 21, 12, tzinfo=dt_util.UTC)
# sunrise: 2015-07-24 04:48:24 local = 2015-07-24 12:48:24 UTC
# now = sunrise + 1s -> 'after sunrise' true
now = datetime(2015, 7, 24, 12, 48, 25, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
@@ -1054,11 +1062,11 @@ async def test_if_action_after_sunrise_no_offset_kotzebue(
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": True, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"},
{"result": True, "wanted_time_after": "2015-07-24T12:48:24.249497+00:00"},
)
# now = sunrise - 1h -> 'after sunrise' not true
now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC)
now = datetime(2015, 7, 24, 11, 48, 24, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
@@ -1066,7 +1074,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue(
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": False, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"},
{"result": False, "wanted_time_after": "2015-07-24T12:48:24.249497+00:00"},
)
# now = local midnight -> 'after sunrise' not true
@@ -1078,7 +1086,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue(
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": False, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"},
{"result": False, "wanted_time_after": "2015-07-24T12:48:24.249497+00:00"},
)
# now = local midnight - 1s -> 'after sunrise' true
@@ -1090,7 +1098,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue(
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": True, "wanted_time_after": "2015-07-23T15:12:19.155123+00:00"},
{"result": True, "wanted_time_after": "2015-07-23T12:43:32.413351+00:00"},
)
@@ -1099,16 +1107,17 @@ async def test_if_action_before_sunset_no_offset_kotzebue(
hass_ws_client: WebSocketGenerator,
service_calls: list[ServiceCall],
) -> None:
"""Test if action was before sunrise.
"""Test if action was before sunset on a day with two sunsets.
Local timezone: Alaska time
Location: Kotzebue, which has a very skewed local timezone with sunrise
at 7 AM and sunset at 3AM during summer
Before sunset is true from midnight until sunset, local time.
Local timezone: Alaska time (America/Anchorage)
Location: Kotzebue, Alaska. On 2015-08-07 (local) the sun sets twice - at
00:03 and again at 23:59 - because solar midnight falls near local midnight.
The condition tracks the day's (late) sunset, so 'before sunset' stays true
across the early sunset and only turns false after the late one.
"""
await hass.config.async_set_time_zone("America/Anchorage")
hass.config.latitude = 66.5
hass.config.longitude = 162.4
hass.config.latitude = 66.8983
hass.config.longitude = -162.5966
await async_setup_component(
hass,
automation.DOMAIN,
@@ -1125,22 +1134,9 @@ async def test_if_action_before_sunset_no_offset_kotzebue(
},
)
# sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local
# sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC
# now = sunset + 1s -> 'before sunset' not true
now = datetime(2015, 7, 25, 11, 13, 34, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(service_calls) == 0
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": False, "wanted_time_before": "2015-07-25T11:13:32.501837+00:00"},
)
# now = sunset - 1h-> 'before sunset' true
now = datetime(2015, 7, 25, 10, 13, 33, tzinfo=dt_util.UTC)
# 2015-08-07 local has two sunsets: 00:03 (08:03 UTC) and 23:59 (08-08 07:59 UTC)
# now = local midnight -> 'before sunset' true
now = datetime(2015, 8, 7, 8, 0, 0, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
@@ -1148,11 +1144,11 @@ async def test_if_action_before_sunset_no_offset_kotzebue(
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": True, "wanted_time_before": "2015-07-25T11:13:32.501837+00:00"},
{"result": True, "wanted_time_before": "2015-08-08T07:59:25.982224+00:00"},
)
# now = local midnight -> 'before sunrise' true
now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC)
# now = first (early) sunset + 1s -> still 'before sunset' (tracks the late one)
now = datetime(2015, 8, 7, 8, 3, 43, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
@@ -1160,19 +1156,31 @@ async def test_if_action_before_sunset_no_offset_kotzebue(
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": True, "wanted_time_before": "2015-07-24T11:17:54.446913+00:00"},
{"result": True, "wanted_time_before": "2015-08-08T07:59:25.982224+00:00"},
)
# now = local midnight - 1s -> 'before sunrise' not true
now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC)
# now = late sunset - 1h -> 'before sunset' true
now = datetime(2015, 8, 8, 6, 59, 25, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(service_calls) == 2
assert len(service_calls) == 3
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": False, "wanted_time_before": "2015-07-23T11:22:18.467277+00:00"},
{"result": True, "wanted_time_before": "2015-08-08T07:59:25.982224+00:00"},
)
# now = late sunset + 1s -> 'before sunset' not true
now = datetime(2015, 8, 8, 7, 59, 26, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(service_calls) == 3
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": False, "wanted_time_before": "2015-08-08T07:59:25.982224+00:00"},
)
@@ -1181,16 +1189,17 @@ async def test_if_action_after_sunset_no_offset_kotzebue(
hass_ws_client: WebSocketGenerator,
service_calls: list[ServiceCall],
) -> None:
"""Test if action was after sunrise.
"""Test if action was after sunset on a day with two sunsets.
Local timezone: Alaska time
Location: Kotzebue, which has a very skewed local timezone with sunrise
at 7 AM and sunset at 3AM during summer
After sunset is true from sunset until midnight, local time.
Local timezone: Alaska time (America/Anchorage)
Location: Kotzebue, Alaska. On 2015-08-07 (local) the sun sets twice - at
00:03 and again at 23:59. The condition tracks the day's (late) sunset, so
'after sunset' is false right after the early sunset and only true in the
short window after the late sunset before local midnight.
"""
await hass.config.async_set_time_zone("America/Anchorage")
hass.config.latitude = 66.5
hass.config.longitude = 162.4
hass.config.latitude = 66.8983
hass.config.longitude = -162.5966
await async_setup_component(
hass,
automation.DOMAIN,
@@ -1207,10 +1216,33 @@ async def test_if_action_after_sunset_no_offset_kotzebue(
},
)
# sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local
# sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC
# now = sunset -> 'after sunset' true
now = datetime(2015, 7, 25, 11, 13, 33, tzinfo=dt_util.UTC)
# 2015-08-07 local has two sunsets: 00:03 (08:03 UTC) and 23:59 (08-08 07:59 UTC)
# now = first (early) sunset + 1s -> 'after sunset' not true (tracks the late one)
now = datetime(2015, 8, 7, 8, 4, 0, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(service_calls) == 0
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": False, "wanted_time_after": "2015-08-08T07:59:25.982224+00:00"},
)
# now = late sunset - 1s -> 'after sunset' not true
now = datetime(2015, 8, 8, 7, 59, 25, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(service_calls) == 0
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": False, "wanted_time_after": "2015-08-08T07:59:25.982224+00:00"},
)
# now = late sunset + 1s -> 'after sunset' true
now = datetime(2015, 8, 8, 7, 59, 27, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
@@ -1218,11 +1250,11 @@ async def test_if_action_after_sunset_no_offset_kotzebue(
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": True, "wanted_time_after": "2015-07-25T11:13:32.501837+00:00"},
{"result": True, "wanted_time_after": "2015-08-08T07:59:25.982224+00:00"},
)
# now = sunset - 1s -> 'after sunset' not true
now = datetime(2015, 7, 25, 11, 13, 32, tzinfo=dt_util.UTC)
# now = local midnight (next day) -> 'after sunset' not true
now = datetime(2015, 8, 8, 8, 0, 1, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
@@ -1230,31 +1262,65 @@ async def test_if_action_after_sunset_no_offset_kotzebue(
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": False, "wanted_time_after": "2015-07-25T11:13:32.501837+00:00"},
{"result": False, "wanted_time_after": "2015-08-09T07:55:10.646523+00:00"},
)
@pytest.mark.parametrize(
("location", "now", "event"),
[
# Midnight sun at Kotzebue (early June to early July): the sun neither
# rises nor sets, so neither a sunrise nor a sunset condition can be met.
(_KOTZEBUE, datetime(2015, 6, 15, 12, tzinfo=dt_util.UTC), SUN_EVENT_SUNSET),
(_KOTZEBUE, datetime(2015, 6, 15, 12, tzinfo=dt_util.UTC), SUN_EVENT_SUNRISE),
# Polar night at Svalbard: the sun neither rises nor sets here either.
(_SVALBARD, datetime(2015, 12, 15, 12, tzinfo=dt_util.UTC), SUN_EVENT_SUNSET),
(_SVALBARD, datetime(2015, 12, 15, 12, tzinfo=dt_util.UTC), SUN_EVENT_SUNRISE),
],
)
async def test_if_action_no_sun_event_in_polar_regions(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
service_calls: list[ServiceCall],
location: tuple[float, float, str],
now: datetime,
event: str,
) -> None:
"""Test a sun condition where the requested event never occurs.
During midnight sun and polar night the sun neither rises nor sets, so
``get_astral_event_date`` returns None for the requested event. The
condition cannot be satisfied and reports "no sunrise today" / "no sunset
today" instead of raising.
"""
latitude, longitude, time_zone = location
await hass.config.async_set_time_zone(time_zone)
hass.config.latitude = latitude
hass.config.longitude = longitude
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"id": "sun",
"trigger": {"platform": "event", "event_type": "test_event"},
"condition": {
"condition": "sun",
"options": {"after": event},
},
"action": {"service": "test.automation"},
}
},
)
# now = local midnight -> 'after sunset' not true
now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(service_calls) == 1
assert len(service_calls) == 0
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": False, "wanted_time_after": "2015-07-24T11:17:54.446913+00:00"},
)
# now = local midnight - 1s -> 'after sunset' true
now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC)
with freeze_time(now):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(service_calls) == 2
await assert_automation_condition_trace(
hass_ws_client,
"sun",
{"result": True, "wanted_time_after": "2015-07-23T11:22:18.467277+00:00"},
{"result": False, "message": f"no {event} today"},
)
+41
View File
@@ -346,6 +346,12 @@ async def test_dawn_defaults_to_civil(
_SVALBARD = (78.22, 15.65, "Europe/Oslo")
_KOTZEBUE = (66.8983, -162.5966, "America/Anchorage")
# A two-sunrise day is the mirror of Kotzebue's two-sunset day: it needs solar
# noon (not midnight) near local midnight, which no real location has because
# time zones keep solar noon near local noon. This synthetic location forces it
# with a polar latitude on a deliberately ~12 h-offset time zone.
_TWO_SUNRISE_LOCATION = (66.5, -32.5, "America/Anchorage")
@pytest.mark.parametrize(
("location", "now", "trigger_key", "astral_event", "options", "depression"),
@@ -452,6 +458,41 @@ async def test_two_sunsets_on_one_day_at_kotzebue(
assert len(service_calls) == 2
async def test_two_sunrises_on_one_day(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test both sunrises fire on a calendar day that has two.
The mirror of the two-sunset case: a synthetic polar location on a
deliberately offset time zone puts solar noon near local midnight, so
2015-03-07 (local) has two sunrises ~24 h apart - one just after midnight
and one just before - and the scheduler must fire for both.
"""
latitude, longitude, time_zone = _TWO_SUNRISE_LOCATION
await hass.config.async_set_time_zone(time_zone)
await hass.config.async_update(latitude=latitude, longitude=longitude, elevation=0)
# 2015-03-07 09:02:36 UTC (00:02 local) and 2015-03-08 08:58:43 UTC (23:58 local)
now = datetime(2015, 3, 7, 9, tzinfo=dt_util.UTC)
with freeze_time(now) as freezer:
await _arm_automation(hass, {"platform": "sun.sunrise"}, {})
first = get_astral_event_next(hass, "sunrise", now)
second = get_astral_event_next(hass, "sunrise", first)
# Two sunrises ~24 h apart that share one local calendar day.
assert dt_util.as_local(first).date() == dt_util.as_local(second).date()
assert timedelta(hours=23) < second - first < timedelta(hours=25)
freezer.move_to(first + timedelta(seconds=1))
async_fire_time_changed(hass, first + timedelta(seconds=1))
await hass.async_block_till_done()
assert len(service_calls) == 1
freezer.move_to(second + timedelta(seconds=1))
async_fire_time_changed(hass, second + timedelta(seconds=1))
await hass.async_block_till_done()
assert len(service_calls) == 2
@pytest.mark.parametrize(
("trigger_key", "astral_event", "now", "above_horizon"),
[
+10 -1
View File
@@ -12,7 +12,16 @@ from .consts import SERIAL
def mock_dsm_hardware(fan_speed: FanSpeed = FanSpeed.COOL) -> Mock:
"""Mock SynologyDSM hardware information."""
return AsyncMock(update=AsyncMock(), fan_speed=fan_speed)
return AsyncMock(
update=AsyncMock(),
fan_speed=fan_speed,
supported_fan_speeds=[
FanSpeed.FULL,
FanSpeed.COOL,
FanSpeed.QUIET,
FanSpeed.QUIET_STOP,
],
)
def mock_dsm_information(
@@ -7,6 +7,7 @@
'area_id': None,
'capabilities': dict({
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'low_power',
'quiet',
'cool',
'full_speed',
@@ -48,6 +49,7 @@
<EntityStateAttribute.ATTRIBUTION: 'attribution'>: 'Data provided by Synology',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'nas.meontheinternet.com Fan speed mode',
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'low_power',
'quiet',
'cool',
'full_speed',
@@ -61,3 +63,63 @@
'state': 'cool',
})
# ---
# name: test_fan_speed_supported_option[select.nas_meontheinternet_com_fan_speed_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'cool',
'full_speed',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.nas_meontheinternet_com_fan_speed_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Fan speed mode',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Fan speed mode',
'platform': 'synology_dsm',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'fan_speed_mode',
'unique_id': 'mySerial_SYNO.Core.Hardware:fan_speed_mode',
'unit_of_measurement': None,
})
# ---
# name: test_fan_speed_supported_option[select.nas_meontheinternet_com_fan_speed_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.ATTRIBUTION: 'attribution'>: 'Data provided by Synology',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'nas.meontheinternet.com Fan speed mode',
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'cool',
'full_speed',
]),
}),
'context': <ANY>,
'entity_id': 'select.nas_meontheinternet_com_fan_speed_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'cool',
})
# ---

Some files were not shown because too many files have changed in this diff Show More