Compare commits

...

37 Commits

Author SHA1 Message Date
Erik b9688b7fb2 Open repair issue when deprecated trigger behavior is used 2026-06-08 10:13:36 +02:00
robotsnh c3d6ad029f refactor(energyid): replace datetime.now with dt_util.utcnow (#173241) 2026-06-08 09:11:48 +02:00
Mark Purcell 630f442042 Bump pydaikin to 2.18.1 (#173249) 2026-06-08 09:05:28 +02:00
Manu 62419789b9 Add version to Uptime Kuma diagnostics (#173254) 2026-06-08 08:46:55 +02:00
Joakim Plate f2f5a55165 Store product type in gardena_bluetooth config entry (#173223) 2026-06-08 08:20:47 +02:00
Mick Vleeshouwer c6a57bc81a Bump pyOverkiz to 2.0.0 in Overkiz (#173212) 2026-06-07 22:09:52 -04:00
Raphael Hehl 4171f566e9 Bump uiprotect to 11.8.0 (#173227) 2026-06-07 22:08:49 -04:00
renovate[bot] 0ac9834d93 Update syrupy to 5.3.1 (#173245)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-07 22:07:48 -04:00
Paul Bottein d7673a08c8 Bump yoto-api to 4.0.2 (#173238) 2026-06-07 22:07:36 -04:00
J. Nick Koston 35cb7c6147 Bump aiohttp to 3.14.1 (#173242) 2026-06-07 22:07:05 -04:00
Paulus Schoutsen d098622021 Return all matches for duplicate names in GetLiveContext (#173157)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-07 21:50:15 -04:00
karwosts f88e757e51 Add a battery charging sensor to demo device (#173219) 2026-06-07 23:00:22 +02:00
Pierre Pinon 653e6a43fa fix(indevolt): unable to discharge at 0 (#173085) 2026-06-07 21:55:10 +02:00
Bram Kragten 1462e7a181 Update frontend to 20260527.5 (#173236) 2026-06-07 21:39:34 +02:00
Martin Claesson e34d821f7d Add Kiosker Clear Blackout Button (#173225) 2026-06-07 21:29:51 +02:00
G Johansson 02b4442a6c Fix config flow version in goodwe (#173235) 2026-06-07 21:26:40 +02:00
J. Nick Koston 809571443c Bump habluetooth to 6.8.3 (#173194) 2026-06-07 19:17:52 +02:00
mvn23 d59398e0ea Remove name fields from opentherm_gw config flow (#173159) 2026-06-07 18:21:21 +02:00
Raphael Hehl 9c9695d0ba Bump uiprotect to 11.3.0 (#173024)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-06-07 14:19:26 +02:00
Allen Porter 3fbdbb12e2 Support streaming updates for V1 Roborock devices (#173182) 2026-06-07 14:12:20 +02:00
Ronald van der Meer a29f2907f7 Use NodeType enum in Duco entity (#173189) 2026-06-07 14:07:48 +02:00
mvn23 83534f286e Ensure opentherm_gw boiler and thermostat manufacturers are strings (#173162) 2026-06-07 12:23:09 +02:00
Ronald van der Meer 4fe93f9c64 Fix uncaught Duco diagnostics client errors (#173191) 2026-06-07 07:27:02 +02:00
Shay Levy fd8789d599 Fix Shelly virtual component unit retrieval (#173183) 2026-06-07 00:34:18 +03:00
Joost Lekkerkerker d0b34dfe92 Have Plugwise handle unavailable temperature measurements (#173173) 2026-06-07 00:19:29 +03:00
Tomer 390766ba3a Bump victron-mqtt to 2026.6.1.1 (#173142) 2026-06-07 00:15:39 +03:00
Vincent Knoop Pathuis 3a46d1088b Refactor Landis+Gyr heat meter to use the HA standard SerialPortSelector (#173170) 2026-06-06 15:16:41 -04:00
epenet 26d56b8218 Use DOMAIN constant in test (async_setup_component o-z) (#173018) 2026-06-06 12:14:46 -07:00
Vincent Knoop Pathuis 6ee819cdc3 Bump to Ultraheat 0.6.1 (#173175) 2026-06-06 15:14:01 -04:00
Stefan Agner 1cf8fe4d0b Drop legacy requires_api_password from discovery announcement (#173090)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:54:36 -04:00
Michael Hansen c5f93cdd72 Validate sentences and answers (#173127)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2026-06-06 13:48:07 -04:00
Michael Hansen 42136f1464 Bubble up conversation response in script run (#173131)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-06 13:47:49 -04:00
J. Nick Koston 34f3452280 Wait for Shelly bluetooth proxy connection at startup (#173165) 2026-06-06 11:17:49 -05:00
Michael Hansen ba9248cc94 LLM: format numeric states with display precision (#173128) 2026-06-06 12:15:48 -04:00
Michael Hansen 018cd1333e Bump ollama library (#173129) 2026-06-06 12:14:59 -04:00
J. Nick Koston c72d723e0d Wait for ESPHome bluetooth proxy connection at startup (#173164) 2026-06-06 11:13:03 -05:00
Paul Bottein b9b36d9e12 Add card group browsing to the Yoto media browser (#173152) 2026-06-06 12:12:58 -04:00
184 changed files with 3078 additions and 2037 deletions
-1
View File
@@ -59,7 +59,6 @@ ATTR_EXTERNAL_URL = "external_url"
ATTR_INTERNAL_URL = "internal_url"
ATTR_LOCATION_NAME = "location_name"
ATTR_INSTALLATION_TYPE = "installation_type"
ATTR_REQUIRES_API_PASSWORD = "requires_api_password"
ATTR_UUID = "uuid"
ATTR_VERSION = "version"
@@ -5,6 +5,8 @@ import logging
from pathlib import Path
from typing import Any
from hassil.parse_expression import parse_sentence
from hassil.parser import ParseError
from hassil.util import (
PUNCTUATION_END,
PUNCTUATION_END_WORD,
@@ -164,6 +166,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
[cv.string],
has_one_non_empty_item,
has_no_punctuation,
is_valid_sentence,
),
}
],
@@ -212,6 +215,17 @@ def has_no_punctuation(value: list[str]) -> list[str]:
return value
def is_valid_sentence(value: list[str]) -> list[str]:
"""Validate result can be parsed by hassil."""
for sentence in value:
try:
parse_sentence(sentence)
except ParseError as err:
raise vol.Invalid(f"invalid sentence: {err}") from err
return value
def has_one_non_empty_item(value: list[str]) -> list[str]:
"""Validate result has at least one item."""
if len(value) < 1:
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.16",
"habluetooth==6.8.1"
"habluetooth==6.8.3"
]
}
@@ -3,6 +3,8 @@
from collections.abc import Awaitable, Callable
from typing import Any
from hassil.parse_expression import parse_sentence
from hassil.parser import ParseError
from hassil.recognize import RecognizeResult
from hassil.util import (
PUNCTUATION_END,
@@ -42,6 +44,17 @@ def has_no_punctuation(value: list[str]) -> list[str]:
return value
def is_valid_sentence(value: list[str]) -> list[str]:
"""Validate result can be parsed by hassil."""
for sentence in value:
try:
parse_sentence(sentence)
except ParseError as err:
raise vol.Invalid(f"invalid sentence: {err}") from err
return value
def has_one_non_empty_item(value: list[str]) -> list[str]:
"""Validate result has at least one item."""
if len(value) < 1:
@@ -58,7 +71,11 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_PLATFORM): DOMAIN,
vol.Required(CONF_COMMAND): vol.All(
cv.ensure_list, [cv.string], has_one_non_empty_item, has_no_punctuation
cv.ensure_list,
[cv.string],
has_one_non_empty_item,
has_no_punctuation,
is_valid_sentence,
),
}
)
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
"requirements": ["pydaikin==2.17.2"],
"requirements": ["pydaikin==2.18.1"],
"zeroconf": ["_dkapi._tcp.local."]
}
+20 -2
View File
@@ -5,6 +5,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -27,7 +28,19 @@ async def async_setup_entry(
BinarySensorDeviceClass.MOISTURE,
),
DemoBinarySensor(
"binary_2", "Movement Backyard", True, BinarySensorDeviceClass.MOTION
"binary_2",
"Movement Backyard",
True,
BinarySensorDeviceClass.MOTION,
),
DemoBinarySensor(
"binary_3",
"Outside Temperature",
False,
BinarySensorDeviceClass.BATTERY_CHARGING,
device_id="sensor_1",
entity_category=EntityCategory.DIAGNOSTIC,
entity_name="Battery Charging",
),
]
)
@@ -46,6 +59,9 @@ class DemoBinarySensor(BinarySensorEntity):
device_name: str,
state: bool,
device_class: BinarySensorDeviceClass,
device_id: str | None = None,
entity_category: EntityCategory | None = None,
entity_name: str | None = None,
) -> None:
"""Initialize the demo sensor."""
self._unique_id = unique_id
@@ -54,10 +70,12 @@ class DemoBinarySensor(BinarySensorEntity):
self._attr_device_info = DeviceInfo(
identifiers={
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, self.unique_id)
(DOMAIN, device_id or unique_id)
},
name=device_name,
)
self._attr_entity_category = entity_category
self._attr_name = entity_name
@property
def unique_id(self) -> str:
+7 -1
View File
@@ -3,7 +3,7 @@
from dataclasses import asdict
from typing import Any
from duco_connectivity.exceptions import DucoConnectionError
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
@@ -52,6 +52,12 @@ async def async_get_config_entry_diagnostics(
translation_domain=DOMAIN,
translation_key="connection_error",
) from err
except DucoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
+3 -3
View File
@@ -1,6 +1,6 @@
"""Base entity for the Duco integration."""
from duco_connectivity.models import Node
from duco_connectivity.models import Node, NodeType
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -25,7 +25,7 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
identifiers={(DOMAIN, f"{mac}_{node.node_id}")},
manufacturer="Duco",
model=coordinator.board_info.box_name
if node.general.node_type == "BOX"
if node.general.node_type == NodeType.BOX
else node.general.node_type,
name=node.general.name or f"Node {node.node_id}",
)
@@ -34,7 +34,7 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
"connections": {(CONNECTION_NETWORK_MAC, mac)},
"serial_number": coordinator.board_info.serial_board_box,
}
if node.general.node_type == "BOX"
if node.general.node_type == NodeType.BOX
else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")}
)
self._attr_device_info = device_info
@@ -26,6 +26,7 @@ from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.util import dt as dt_util
from .const import (
CONF_DEVICE_NAME,
@@ -221,8 +222,7 @@ def update_listeners(hass: HomeAssistant, entry: EnergyIDConfigEntry) -> None:
):
try:
value = float(current_state.state)
# pylint: disable-next=home-assistant-enforce-utcnow
timestamp = current_state.last_updated or dt.datetime.now(dt.UTC)
timestamp = current_state.last_updated or dt_util.utcnow()
client.get_or_create_sensor(energyid_key).update(value, timestamp)
except ValueError, TypeError:
_LOGGER.debug(
@@ -166,6 +166,8 @@ class RuntimeEntryData:
)
loaded_platforms: set[Platform] = field(default_factory=set)
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
# Set once the first connection has finished scanner setup or teardown.
first_connect_done: asyncio.Event = field(default_factory=asyncio.Event)
_storage_contents: StoreData | None = None
_pending_storage: Callable[[], StoreData] | None = None
assist_pipeline_update_callbacks: list[CALLBACK_TYPE] = field(default_factory=list)
+22 -1
View File
@@ -1,11 +1,12 @@
"""Manager for esphome devices."""
import asyncio
import base64
from functools import partial
import logging
import secrets
import struct
from typing import TYPE_CHECKING, Any, NamedTuple
from typing import TYPE_CHECKING, Any, Final, NamedTuple
from aioesphomeapi import (
APIClient,
@@ -106,6 +107,9 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
# Max time to wait at startup for a BLE proxy to register its scanner.
STARTUP_SCANNER_WAIT: Final = 3.0
LOG_LEVEL_TO_LOGGER = {
LogLevel.LOG_LEVEL_NONE: logging.DEBUG,
LogLevel.LOG_LEVEL_ERROR: logging.ERROR,
@@ -677,6 +681,8 @@ class ESPHomeManager:
hass, device_info.bluetooth_mac_address or device_info.mac_address
)
entry_data.first_connect_done.set()
if device_info.voice_assistant_feature_flags_compat(api_version) and (
Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms
):
@@ -988,6 +994,21 @@ class ESPHomeManager:
await reconnect_logic.start()
# Wait for a cached BLE proxy to register its scanner before finishing setup.
if (
device_info := entry_data.device_info
) is not None and device_info.bluetooth_proxy_feature_flags_compat(
entry_data.api_version
):
try:
async with asyncio.timeout(STARTUP_SCANNER_WAIT):
await entry_data.first_connect_done.wait()
except TimeoutError:
_LOGGER.debug(
"%s: Timed out waiting for Bluetooth scanner to register",
self.host,
)
@callback
def _async_setup_device_registry(
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260527.4"]
"requirements": ["home-assistant-frontend==20260527.5"]
}
@@ -14,6 +14,7 @@ from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_PRODUCT_TYPE
from .coordinator import (
DeviceUnavailable,
GardenaBluetoothConfigEntry,
@@ -36,8 +37,8 @@ PRODUCTS_SCAN_TIMEOUT = 10
PRODUCT_TYPE_TIMEOUT = 30
async def async_get_product_type(hass: HomeAssistant, address: str) -> ProductType:
"""Get a product type for the given address."""
async def async_get_product(hass: HomeAssistant, address: str) -> ManufacturerData:
"""Get manufacturer data for the given address via active scan."""
data = ManufacturerData()
@@ -59,7 +60,7 @@ async def async_get_product_type(hass: HomeAssistant, address: str) -> ProductTy
mode=bluetooth.BluetoothScanningMode.ACTIVE,
timeout=PRODUCT_TYPE_TIMEOUT,
)
return data.product_type
return data
async def async_get_products(hass: HomeAssistant) -> dict[str, ManufacturerData]:
@@ -92,6 +93,21 @@ async def async_get_products(hass: HomeAssistant) -> dict[str, ManufacturerData]
return products
async def async_migrate_product_type(
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
) -> GardenaBluetoothConfigEntry:
"""Discover product type for old entries and upgrade them to minor version 2."""
mfg = await async_get_product(hass, entry.data[CONF_ADDRESS])
if mfg.product_type is ProductType.UNKNOWN:
raise ConfigEntryNotReady("Unable to find product type")
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_PRODUCT_TYPE: mfg.product_type.name},
minor_version=2,
)
return entry
def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
"""Set up a cached client that keeps connection after last use."""
@@ -111,11 +127,11 @@ async def async_setup_entry(
) -> bool:
"""Set up Gardena Bluetooth from a config entry."""
address = entry.data[CONF_ADDRESS]
if entry.minor_version < 2:
entry = await async_migrate_product_type(hass, entry)
product_type = await async_get_product_type(hass, address)
if product_type is ProductType.UNKNOWN:
raise ConfigEntryNotReady("Unable to find product type")
address = entry.data[CONF_ADDRESS]
product_type = ProductType[entry.data[CONF_PRODUCT_TYPE]]
client = Client(get_connection(hass, address), product_type)
@@ -6,7 +6,7 @@ from typing import Any
from gardena_bluetooth.client import Client
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation
from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure
from gardena_bluetooth.parse import ProductType
from gardena_bluetooth.parse import ManufacturerData, ProductType
import voluptuous as vol
from homeassistant.components.bluetooth import BluetoothServiceInfo
@@ -14,8 +14,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from homeassistant.data_entry_flow import AbortFlow
from . import async_get_product_type, async_get_products, get_connection
from .const import DOMAIN
from . import async_get_product, async_get_products, get_connection
from .const import CONF_PRODUCT_TYPE, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -33,11 +33,12 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Gardena Bluetooth."""
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow."""
self.devices: dict[str, str] = {}
self.address: str | None
self.devices: dict[str, ManufacturerData] = {}
async def async_read_data(self):
"""Try to connect to device and extract information."""
@@ -53,19 +54,23 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
finally:
await client.disconnect()
return {CONF_ADDRESS: self.address}
assert self.address in self.devices
return {
CONF_ADDRESS: self.address,
CONF_PRODUCT_TYPE: self.devices[self.address].product_type.name,
}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("Discovered device: %s", discovery_info)
product_type = await async_get_product_type(self.hass, discovery_info.address)
if product_type not in _SUPPORTED_PRODUCT_TYPES:
mfg = await async_get_product(self.hass, discovery_info.address)
self.devices[discovery_info.address] = mfg
if mfg.product_type not in _SUPPORTED_PRODUCT_TYPES:
return self.async_abort(reason="no_devices_found")
self.address = discovery_info.address
self.devices = {discovery_info.address: PRODUCT_NAMES[product_type]}
await self.async_set_unique_id(self.address)
self._abort_if_unique_id_configured()
return await self.async_step_confirm()
@@ -75,7 +80,7 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Confirm discovery."""
assert self.address
title = self.devices[self.address]
title = PRODUCT_NAMES[self.devices[self.address].product_type]
if user_input is not None:
data = await self.async_read_data()
@@ -102,24 +107,24 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_confirm()
current = self._async_current_ids(include_ignore=False)
devices = await async_get_products(self.hass)
self.devices = await async_get_products(self.hass)
# Keep selection sorted by address to ensure stable tests
self.devices = {
devices = {
address: PRODUCT_NAMES[data.product_type]
for address in sorted(devices)
for address in sorted(self.devices)
if address not in current
and (data := devices[address]).product_type in _SUPPORTED_PRODUCT_TYPES
and (data := self.devices[address]).product_type in _SUPPORTED_PRODUCT_TYPES
}
if not self.devices:
if not devices:
return self.async_abort(reason="no_devices_found")
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(self.devices),
vol.Required(CONF_ADDRESS): vol.In(devices),
},
),
)
@@ -1,3 +1,4 @@
"""Constants for the Gardena Bluetooth integration."""
DOMAIN = "gardena_bluetooth"
CONF_PRODUCT_TYPE = "product_type"
@@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__)
class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Goodwe config flow."""
MINOR_VERSION = 2
VERSION = 2
async def async_handle_successful_connection(
self,
@@ -135,6 +135,10 @@
"description": "The {integration_title} integration is being removed as it depends on system packages that can only be installed on systems running a deprecated architecture. To resolve this, remove the {domain} entry from your configuration.yaml file and restart Home Assistant.",
"title": "The {integration_title} integration is being removed"
},
"deprecated_trigger_behavior": {
"description": "An automation, script or template entity uses the trigger behavior option `{deprecated_behavior}`, which has been renamed to `{new_behavior}`. The old value still works for now, but support for it will be removed in a future release.\n\nTo fix this issue, edit the affected automations and scripts and change the behavior option from `{deprecated_behavior}` to `{new_behavior}`, then restart Home Assistant.",
"title": "Deprecated trigger behavior option in use"
},
"deprecated_yaml": {
"description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
"title": "The {integration_title} YAML configuration is being removed"
@@ -30,7 +30,7 @@ RT_ACTION_SERVICE_SCHEMA: Final = vol.Schema(
),
vol.Required("power"): vol.All(
vol.Coerce(int),
vol.Range(min=1, max=2400),
vol.Range(min=0, max=2400),
),
}
)
@@ -18,7 +18,7 @@ charge:
required: true
selector:
number:
min: 1
min: 0
max: 2400
step: 1
unit_of_measurement: "W"
@@ -43,7 +43,7 @@ discharge:
required: true
selector:
number:
min: 1
min: 0
max: 2400
step: 1
unit_of_measurement: "W"
@@ -72,6 +72,11 @@ BUTTONS: tuple[KioskerButtonEntityDescription, ...] = (
translation_key="screensaver_interact",
action_fn=lambda api: api.screensaver_interact(),
),
KioskerButtonEntityDescription(
key="blackoutClear",
translation_key="blackout_clear",
action_fn=lambda api: api.blackout_clear(),
),
)
@@ -15,6 +15,9 @@
}
},
"button": {
"blackout_clear": {
"default": "mdi:monitor"
},
"clear_cache": {
"default": "mdi:cached"
},
@@ -57,6 +57,9 @@
}
},
"button": {
"blackout_clear": {
"name": "Clear blackout"
},
"clear_cache": {
"name": "Clear cache"
},
@@ -8,21 +8,19 @@ import serialx
import ultraheat_api
import voluptuous as vol
from homeassistant.components import usb
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import SerialPortSelector
from .const import DOMAIN, ULTRAHEAT_TIMEOUT
_LOGGER = logging.getLogger(__name__)
CONF_MANUAL_PATH = "Enter Manually"
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_DEVICE): str,
vol.Required(CONF_DEVICE): SerialPortSelector(),
}
)
@@ -39,9 +37,6 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
if user_input is not None:
if user_input[CONF_DEVICE] == CONF_MANUAL_PATH:
return await self.async_step_setup_serial_manual_path()
dev_path = user_input[CONF_DEVICE]
_LOGGER.debug("Using this path : %s", dev_path)
@@ -50,30 +45,8 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN):
except CannotConnect:
errors["base"] = "cannot_connect"
ports = await get_usb_ports(self.hass)
ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH
schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(ports)})
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def async_step_setup_serial_manual_path(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Set path manually."""
errors = {}
if user_input is not None:
dev_path = user_input[CONF_DEVICE]
try:
return await self.validate_and_create_entry(dev_path)
except CannotConnect:
errors["base"] = "cannot_connect"
schema = vol.Schema({vol.Required(CONF_DEVICE): str})
return self.async_show_form(
step_id="setup_serial_manual_path",
data_schema=schema,
errors=errors,
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def validate_and_create_entry(self, dev_path):
@@ -111,24 +84,5 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN):
return data.model, data.device_number
async def get_usb_ports(hass: HomeAssistant) -> dict[str, str]:
"""Return a dict of USB ports and their friendly names."""
ports = await usb.async_scan_serial_ports(hass)
port_descriptions = {}
for port in ports:
if isinstance(port, usb.USBDevice):
human_name = usb.human_readable_device_name(
port.device,
port.serial_number,
port.manufacturer,
port.description,
port.vid,
port.pid,
)
port_descriptions[port.device] = human_name
return port_descriptions
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
@@ -4,5 +4,5 @@ from datetime import timedelta
DOMAIN = "landisgyr_heat_meter"
ULTRAHEAT_TIMEOUT = 30 # reading the IR port can take some time
ULTRAHEAT_TIMEOUT = 60 # reading the IR port can take some time
POLLING_INTERVAL = timedelta(days=1) # Polling is only daily to prevent battery drain.
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["ultraheat-api==0.6.0"]
"requirements": ["ultraheat-api==0.6.1"]
}
@@ -7,11 +7,6 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"setup_serial_manual_path": {
"data": {
"device": "[%key:common::config_flow::data::usb_path%]"
}
},
"user": {
"data": {
"device": "Select device"
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/ollama",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["ollama==0.5.1"]
"requirements": ["ollama==0.6.2"]
}
@@ -8,13 +8,7 @@ import pyotgw.vars as gw_vars
from serial import SerialException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE,
CONF_ID,
CONF_NAME,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.const import CONF_DEVICE, CONF_ID, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
@@ -100,7 +94,6 @@ class OpenThermGatewayHub:
self.hass = hass
self.device_path = config_entry.data[CONF_DEVICE]
self.hub_id = config_entry.data[CONF_ID]
self.name = config_entry.data[CONF_NAME]
self.options = config_entry.options
self.config_entry_id = config_entry.entry_id
self.update_signal = f"{DATA_OPENTHERM_GW}_{self.hub_id}_update"
@@ -159,11 +152,14 @@ class OpenThermGatewayHub:
_LOGGER.debug("Received report: %s", status)
async_dispatcher_send(self.hass, self.update_signal, status)
boiler_manufacturer = status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_MEMBERID
)
dev_reg.async_update_device(
boiler_device.id,
manufacturer=status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_MEMBERID
),
manufacturer=str(boiler_manufacturer)
if boiler_manufacturer is not None
else None,
model_id=status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_PRODUCT_TYPE
),
@@ -175,11 +171,14 @@ class OpenThermGatewayHub:
),
)
thermostat_manufacturer = status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_MEMBERID
)
dev_reg.async_update_device(
thermostat_device.id,
manufacturer=status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_MEMBERID
),
manufacturer=str(thermostat_manufacturer)
if thermostat_manufacturer is not None
else None,
model_id=status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_PRODUCT_TYPE
),
@@ -17,7 +17,6 @@ from homeassistant.config_entries import (
from homeassistant.const import (
CONF_DEVICE,
CONF_ID,
CONF_NAME,
PRECISION_HALVES,
PRECISION_TENTHS,
PRECISION_WHOLE,
@@ -54,9 +53,8 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle config flow initiation."""
if info:
name = info[CONF_NAME]
device = info[CONF_DEVICE]
gw_id = cv.slugify(info.get(CONF_ID, name))
gw_id = cv.slugify(info[CONF_ID])
entries = [e.data for e in self._async_current_entries()]
@@ -83,7 +81,7 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN):
except ConnectionError, SerialException:
return self._show_form({"base": "cannot_connect"})
return self._create_entry(gw_id, name, device)
return self._create_entry(gw_id, device)
return self._show_form()
@@ -99,20 +97,17 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="init",
data_schema=vol.Schema(
{
# Name field is no longer allowed in config flow schemas
# pylint: disable-next=home-assistant-config-flow-name-field
vol.Required(CONF_NAME): str,
vol.Required(CONF_DEVICE): str,
vol.Optional(CONF_ID): str,
vol.Required(CONF_ID): str,
}
),
errors=errors or {},
)
def _create_entry(self, gw_id, name, device):
def _create_entry(self, gw_id, device):
"""Create entry for the OpenTherm Gateway device."""
return self.async_create_entry(
title=name, data={CONF_ID: gw_id, CONF_DEVICE: device, CONF_NAME: name}
title="OpenTherm Gateway", data={CONF_ID: gw_id, CONF_DEVICE: device}
)
@@ -14,8 +14,7 @@
"init": {
"data": {
"device": "Path or URL",
"id": "ID",
"name": "[%key:common::config_flow::data::name%]"
"id": "ID"
}
}
}
+34 -27
View File
@@ -4,18 +4,21 @@ from collections import defaultdict
from dataclasses import dataclass
from aiohttp import ClientError
from pyoverkiz.client import OverkizClient
from pyoverkiz.const import SUPPORTED_SERVERS
from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget
from pyoverkiz.exceptions import (
BadCredentialsException,
MaintenanceException,
NotAuthenticatedException,
NotSuchTokenException,
TooManyRequestsException,
from pyoverkiz.auth.credentials import (
LocalTokenCredentials,
UsernamePasswordCredentials,
)
from pyoverkiz.models import Device, OverkizServer, Scenario
from pyoverkiz.utils import generate_local_server
from pyoverkiz.client import OverkizClient
from pyoverkiz.enums import APIType, OverkizState, Server, UIClass, UIWidget
from pyoverkiz.exceptions import (
BadCredentialsError,
MaintenanceError,
NoSuchTokenError,
NotAuthenticatedError,
TooManyRequestsError,
)
from pyoverkiz.models import Device, PersistedActionGroup
from pyoverkiz.utils import create_local_server_config
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -58,7 +61,7 @@ class HomeAssistantOverkizData:
coordinator: OverkizDataUpdateCoordinator
platforms: defaultdict[Platform, list[Device]]
scenarios: list[Scenario]
scenarios: list[PersistedActionGroup]
type OverkizDataConfigEntry = ConfigEntry[HomeAssistantOverkizData]
@@ -90,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
hass,
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
server=SUPPORTED_SERVERS[entry.data[CONF_HUB]],
server=entry.data[CONF_HUB],
)
try:
@@ -100,20 +103,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
# Local API does expose scenarios, but they are not functional.
# Tracked in https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/21
if api_type == APIType.CLOUD:
scenarios = await client.get_scenarios()
scenarios = await client.get_action_groups()
else:
scenarios = []
except (
BadCredentialsException,
NotSuchTokenException,
NotAuthenticatedException,
BadCredentialsError,
NoSuchTokenError,
NotAuthenticatedError,
) as exception:
raise ConfigEntryAuthFailed("Invalid authentication") from exception
except TooManyRequestsException as exception:
except TooManyRequestsError as exception:
raise ConfigEntryNotReady("Too many requests, try again later") from exception
except (TimeoutError, ClientError) as exception:
raise ConfigEntryNotReady("Failed to connect") from exception
except MaintenanceException as exception:
except MaintenanceError as exception:
raise ConfigEntryNotReady("Server is down for maintenance") from exception
coordinator = OverkizDataUpdateCoordinator(
@@ -173,13 +176,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
identifiers={(DOMAIN, gateway.id)},
model=gateway.type.beautify_name if gateway.type else None,
model_id=str(gateway.type),
manufacturer=client.server.manufacturer,
manufacturer=client.server_config.manufacturer,
name=gateway.type.beautify_name if gateway.type else gateway.id,
sw_version=gateway.connectivity.protocol_version,
hw_version=f"{gateway.type}:{gateway.sub_type}"
if gateway.type and gateway.sub_type
else None,
configuration_url=client.server.configuration_url,
configuration_url=client.server_config.configuration_url,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -214,6 +217,9 @@ async def _async_migrate_strenum_unique_ids(
"""Migrate entities to the StrEnum-style unique IDs."""
entity_registry = er.async_get(hass)
# Map enum members renamed in pyoverkiz 2.0 to their current names.
renamed_enum_members = {"TSKALARM_CONTROLLER": "TSK_ALARM_CONTROLLER"}
@callback
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:
# Python 3.11 treats (str, Enum) and StrEnum
@@ -229,6 +235,7 @@ async def _async_migrate_strenum_unique_ids(
("OverkizState", "UIWidget", "UIClass")
):
state = key.split(".")[1]
state = renamed_enum_members.get(state, state)
new_key = ""
if key.startswith("UIClass"):
@@ -276,17 +283,15 @@ def create_local_client(
session = async_create_clientsession(hass, verify_ssl=verify_ssl)
return OverkizClient(
username="",
password="",
token=token,
server=create_local_server_config(host=host),
credentials=LocalTokenCredentials(token),
session=session,
server=generate_local_server(host=host),
verify_ssl=verify_ssl,
)
def create_cloud_client(
hass: HomeAssistant, username: str, password: str, server: OverkizServer
hass: HomeAssistant, username: str, password: str, server: Server
) -> OverkizClient:
"""Create Overkiz cloud client."""
# To allow users with multiple accounts/hubs, we create a
@@ -294,5 +299,7 @@ def create_cloud_client(
session = async_create_clientsession(hass)
return OverkizClient(
username=username, password=password, session=session, server=server
server=server,
credentials=UsernamePasswordCredentials(username, password),
session=session,
)
@@ -144,7 +144,7 @@ ALARM_DESCRIPTIONS: list[OverkizAlarmDescription] = [
# Disabled by default since all Overkiz hubs have this
# virtual device, but only a few users actually use this.
OverkizAlarmDescription(
key=UIWidget.TSKALARM_CONTROLLER,
key=UIWidget.TSK_ALARM_CONTROLLER,
entity_registry_enabled_default=False,
supported_features=(
AlarmControlPanelEntityFeature.ARM_AWAY
@@ -165,7 +165,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state.qualified_name))
if (description := SUPPORTED_STATES.get(state))
)
async_add_entities(entities)
+1 -1
View File
@@ -120,7 +120,7 @@ async def async_setup_entry(
description,
)
for command in device.definition.commands
if (description := SUPPORTED_COMMANDS.get(command.command_name))
if (description := SUPPORTED_COMMANDS.get(command))
)
async_add_entities(entities)
@@ -115,12 +115,13 @@ async def async_setup_entry(
# Match devices based on the widget and protocol.
# #ie Hitachi Air To Air Heat Pumps
entities_based_on_widget_and_protocol: list[Entity] = [
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol](
device.device_url, data.coordinator
)
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][
device.identifier.protocol
](device.device_url, data.coordinator)
for device in data.platforms[Platform.CLIMATE]
if device.widget in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY
and device.protocol in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget]
and device.identifier.protocol
in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget]
]
async_add_entities(
@@ -157,7 +157,7 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
@property
def target_temperature(self) -> float | None:
"""Return the temperature."""
if state := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]:
if state := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE):
return state.value_as_float
return None
@@ -165,7 +165,9 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return temperature.value_as_float
return None
@@ -104,7 +104,9 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return cast(float, temperature.value)
@@ -67,7 +67,9 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return cast(float, temperature.value)
@@ -106,7 +106,9 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return cast(float, temperature.value)
@@ -74,7 +74,7 @@ class EvoHomeController(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states[OverkizState.RAMSES_RAMSES_OPERATING_MODE]
state := self.device.states.get(OverkizState.RAMSES_RAMSES_OPERATING_MODE)
) and state.value_as_str in OVERKIZ_TO_PRESET_MODES:
return OVERKIZ_TO_PRESET_MODES[state.value_as_str]
@@ -114,13 +114,13 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if (
main_op_state := self.device.states[MAIN_OPERATION_STATE]
main_op_state := self.device.states.get(MAIN_OPERATION_STATE)
) and main_op_state.value_as_str:
if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF:
return HVACMode.OFF
if (
mode_change_state := self.device.states[MODE_CHANGE_STATE]
mode_change_state := self.device.states.get(MODE_CHANGE_STATE)
) and mode_change_state.value_as_str:
sanitized_value = mode_change_state.value_as_str.lower()
return OVERKIZ_TO_HVAC_MODES[sanitized_value]
@@ -140,7 +140,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def fan_mode(self) -> str | None:
"""Return the fan setting."""
if (state := self.device.states[FAN_SPEED_STATE]) and state.value_as_str:
if (state := self.device.states.get(FAN_SPEED_STATE)) and state.value_as_str:
return OVERKIZ_TO_FAN_MODES[state.value_as_str]
return None
@@ -157,7 +157,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def swing_mode(self) -> str | None:
"""Return the swing setting."""
if (state := self.device.states[SWING_STATE]) and state.value_as_str:
if (state := self.device.states.get(SWING_STATE)) and state.value_as_str:
return OVERKIZ_TO_SWING_MODES[state.value_as_str]
return None
@@ -170,7 +170,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
def target_temperature(self) -> int | None:
"""Return the temperature."""
if (
temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
temperature := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE)
) and temperature.value_as_int:
return temperature.value_as_int
@@ -179,7 +179,9 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def current_temperature(self) -> int | None:
"""Return current temperature."""
if (state := self.device.states[ROOM_TEMPERATURE_STATE]) and state.value_as_int:
if (
state := self.device.states.get(ROOM_TEMPERATURE_STATE)
) and state.value_as_int:
return state.value_as_int
return None
@@ -192,7 +194,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (state := self.device.states[LEAVE_HOME_STATE]) and state.value_as_str:
if (state := self.device.states.get(LEAVE_HOME_STATE)) and state.value_as_str:
if state.value_as_str == OverkizCommandParam.ON:
return PRESET_HOLIDAY_MODE
@@ -222,7 +224,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
"""
if value:
return value
state = self.device.states[state_name]
state = self.device.states.get(state_name)
if state and state.value_as_str:
return state.value_as_str
return fallback_value
@@ -118,13 +118,13 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if (
main_op_state := self.device.states[OverkizState.OVP_MAIN_OPERATION]
main_op_state := self.device.states.get(OverkizState.OVP_MAIN_OPERATION)
) and main_op_state.value_as_str:
if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF:
return HVACMode.OFF
if (
mode_change_state := self.device.states[OverkizState.OVP_MODE_CHANGE]
mode_change_state := self.device.states.get(OverkizState.OVP_MODE_CHANGE)
) and mode_change_state.value_as_str:
# The OVP protocol has 'auto cooling' and 'auto heating' values
# that are equivalent to the HLRRWIFI protocol without spaces
@@ -147,7 +147,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def fan_mode(self) -> str | None:
"""Return the fan setting."""
if (
state := self.device.states[OverkizState.OVP_FAN_SPEED]
state := self.device.states.get(OverkizState.OVP_FAN_SPEED)
) and state.value_as_str:
return OVERKIZ_TO_FAN_MODES[state.value_as_str]
@@ -160,7 +160,9 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
@property
def swing_mode(self) -> str | None:
"""Return the swing setting."""
if (state := self.device.states[OverkizState.OVP_SWING]) and state.value_as_str:
if (
state := self.device.states.get(OverkizState.OVP_SWING)
) and state.value_as_str:
return OVERKIZ_TO_SWING_MODES[state.value_as_str]
return None
@@ -173,7 +175,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def target_temperature(self) -> int | None:
"""Return the target temperature."""
if (
temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
temperature := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE)
) and temperature.value_as_int:
return temperature.value_as_int
@@ -183,7 +185,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def current_temperature(self) -> int | None:
"""Return current temperature."""
if (
state := self.device.states[OverkizState.OVP_ROOM_TEMPERATURE]
state := self.device.states.get(OverkizState.OVP_ROOM_TEMPERATURE)
) and state.value_as_int:
return state.value_as_int
@@ -197,7 +199,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states[OverkizState.CORE_HOLIDAYS_MODE]
state := self.device.states.get(OverkizState.CORE_HOLIDAYS_MODE)
) and state.value_as_str:
if state.value_as_str == OverkizCommandParam.ON:
return PRESET_HOLIDAY_MODE
@@ -225,7 +227,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def auto_manu_mode(self) -> str | None:
"""Return auto/manu mode."""
if (
state := self.device.states[OverkizState.CORE_AUTO_MANU_MODE]
state := self.device.states.get(OverkizState.CORE_AUTO_MANU_MODE)
) and state.value_as_str:
return state.value_as_str
return None
@@ -235,7 +237,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def temperature_change(self) -> int | None:
"""Return temperature change state."""
if (
state := self.device.states[OverkizState.OVP_TEMPERATURE_CHANGE]
state := self.device.states.get(OverkizState.OVP_TEMPERATURE_CHANGE)
) and state.value_as_int:
return state.value_as_int
@@ -266,7 +268,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
"""
if value:
return value
if (state := self.device.states[state_name]) is not None and (
if (state := self.device.states.get(state_name)) is not None and (
value := state.value_as_str
) is not None:
return value
@@ -60,7 +60,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if (
state := self.device.states[OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1]
state := self.device.states.get(OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1)
) and state.value_as_str:
return OVERKIZ_TO_HVAC_MODE[state.value_as_str]
@@ -76,7 +76,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states[OverkizState.MODBUS_YUTAKI_TARGET_MODE]
state := self.device.states.get(OverkizState.MODBUS_YUTAKI_TARGET_MODE)
) and state.value_as_str:
return OVERKIZ_TO_PRESET_MODE[state.value_as_str]
@@ -91,9 +91,9 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
current_temperature = self.device.states[
current_temperature = self.device.states.get(
OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1
]
)
if current_temperature:
return current_temperature.value_as_float
@@ -103,9 +103,9 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
target_temperature = self.device.states[
target_temperature = self.device.states.get(
OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE_1
]
)
if target_temperature:
return target_temperature.value_as_float
@@ -99,14 +99,14 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation i.e. heat, cool mode."""
state = self.device.states[OverkizState.CORE_ON_OFF]
state = self.device.states.get(OverkizState.CORE_ON_OFF)
if state and state.value_as_str == OverkizCommandParam.OFF:
return HVACMode.OFF
if (
state := self.device.states[
state := self.device.states.get(
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_ACTIVE_MODE
]
)
) and state.value_as_str:
return OVERKIZ_TO_HVAC_MODES[state.value_as_str]
@@ -127,9 +127,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states[
state := self.device.states.get(
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE
]
)
) and state.value_as_str:
return OVERKIZ_TO_PRESET_MODES[state.value_as_str]
return None
@@ -145,9 +145,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac operation if supported."""
if (
current_operation := self.device.states[
current_operation := self.device.states.get(
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_OPERATING_MODE
]
)
) and current_operation.value_as_str:
return OVERKIZ_TO_HVAC_ACTION[current_operation.value_as_str]
@@ -167,7 +167,7 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
if mode not in MAP_PRESET_TEMPERATURES:
return None
if state := self.device.states[MAP_PRESET_TEMPERATURES[mode]]:
if state := self.device.states.get(MAP_PRESET_TEMPERATURES[mode]):
return state.value_as_float
return None
@@ -175,7 +175,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return temperature.value_as_float
return None
@@ -185,9 +187,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
temperature = kwargs[ATTR_TEMPERATURE]
if (
mode := self.device.states[
mode := self.device.states.get(
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE
]
)
) and mode.value_as_str:
await self.executor.async_execute_command(
SETPOINT_MODE_TO_OVERKIZ_COMMAND[mode.value_as_str], temperature
@@ -40,10 +40,10 @@ OVERKIZ_TO_PRESET_MODES: dict[OverkizCommandParam, str] = {
PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()}
TARGET_TEMP_TO_OVERKIZ = {
PRESET_HOME: OverkizState.SOMFY_THERMOSTAT_AT_HOME_TARGET_TEMPERATURE,
PRESET_AWAY: OverkizState.SOMFY_THERMOSTAT_AWAY_MODE_TARGET_TEMPERATURE,
PRESET_FREEZE: OverkizState.SOMFY_THERMOSTAT_FREEZE_MODE_TARGET_TEMPERATURE,
PRESET_NIGHT: OverkizState.SOMFY_THERMOSTAT_SLEEPING_MODE_TARGET_TEMPERATURE,
PRESET_HOME: OverkizState.SOMFYTHERMOSTAT_AT_HOME_TARGET_TEMPERATURE,
PRESET_AWAY: OverkizState.SOMFYTHERMOSTAT_AWAY_MODE_TARGET_TEMPERATURE,
PRESET_FREEZE: OverkizState.SOMFYTHERMOSTAT_FREEZE_MODE_TARGET_TEMPERATURE,
PRESET_NIGHT: OverkizState.SOMFYTHERMOSTAT_SLEEPING_MODE_TARGET_TEMPERATURE,
}
# controllableName is somfythermostat:SomfyThermostatTemperatureSensor
@@ -88,9 +88,9 @@ class SomfyThermostat(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str:
"""Return the current preset mode, e.g., home, away, temp."""
if self.hvac_mode == HVACMode.AUTO:
state_key = OverkizState.SOMFY_THERMOSTAT_HEATING_MODE
state_key = OverkizState.SOMFYTHERMOSTAT_HEATING_MODE
else:
state_key = OverkizState.SOMFY_THERMOSTAT_DEROGATION_HEATING_MODE
state_key = OverkizState.SOMFYTHERMOSTAT_DEROGATION_HEATING_MODE
if state := self.executor.select_state(state_key):
return OVERKIZ_TO_PRESET_MODES[OverkizCommandParam(cast(str, state))]
@@ -101,7 +101,9 @@ class SomfyThermostat(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return cast(float, temperature.value)
return None
@@ -91,7 +91,9 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return temperature.value_as_float
+32 -29
View File
@@ -4,21 +4,25 @@ from collections.abc import Mapping
from typing import Any, cast
from aiohttp import ClientConnectorCertificateError, ClientError
from pyoverkiz.auth.credentials import (
LocalTokenCredentials,
UsernamePasswordCredentials,
)
from pyoverkiz.client import OverkizClient
from pyoverkiz.const import SERVERS_WITH_LOCAL_API, SUPPORTED_SERVERS
from pyoverkiz.enums import APIType, Server
from pyoverkiz.exceptions import (
BadCredentialsException,
CozyTouchBadCredentialsException,
MaintenanceException,
NotAuthenticatedException,
NotSuchTokenException,
TooManyAttemptsBannedException,
TooManyRequestsException,
UnknownUserException,
BadCredentialsError,
CozyTouchBadCredentialsError,
MaintenanceError,
NoSuchTokenError,
NotAuthenticatedError,
TooManyAttemptsBannedError,
TooManyRequestsError,
UnknownUserError,
)
from pyoverkiz.obfuscate import obfuscate_id
from pyoverkiz.utils import generate_local_server, is_overkiz_gateway
from pyoverkiz.utils import create_local_server_config, is_overkiz_gateway
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
@@ -58,19 +62,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
self.hass, verify_ssl=user_input[CONF_VERIFY_SSL]
)
client = OverkizClient(
username="",
password="",
token=user_input[CONF_TOKEN],
server=create_local_server_config(host=user_input[CONF_HOST]),
credentials=LocalTokenCredentials(user_input[CONF_TOKEN]),
session=session,
server=generate_local_server(host=user_input[CONF_HOST]),
verify_ssl=user_input[CONF_VERIFY_SSL],
)
else: # APIType.CLOUD
session = async_create_clientsession(self.hass)
client = OverkizClient(
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
server=SUPPORTED_SERVERS[user_input[CONF_HUB]],
server=user_input[CONF_HUB],
credentials=UsernamePasswordCredentials(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
),
session=session,
)
@@ -149,9 +152,9 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
try:
await self.async_validate_input(user_input)
except TooManyRequestsException:
except TooManyRequestsError:
errors["base"] = "too_many_requests"
except (BadCredentialsException, NotAuthenticatedException) as exception:
except (BadCredentialsError, NotAuthenticatedError) as exception:
# If authentication with CozyTouch auth server is
# valid, but token is invalid for Overkiz API
# server, the hardware is not supported.
@@ -159,18 +162,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
Server.ATLANTIC_COZYTOUCH,
Server.SAUTER_COZYTOUCH,
Server.THERMOR_COZYTOUCH,
} and not isinstance(exception, CozyTouchBadCredentialsException):
} and not isinstance(exception, CozyTouchBadCredentialsError):
description_placeholders["unsupported_device"] = "CozyTouch"
errors["base"] = "unsupported_hardware"
else:
errors["base"] = "invalid_auth"
except TimeoutError, ClientError:
errors["base"] = "cannot_connect"
except MaintenanceException:
except MaintenanceError:
errors["base"] = "server_in_maintenance"
except TooManyAttemptsBannedException:
except TooManyAttemptsBannedError:
errors["base"] = "too_many_attempts"
except UnknownUserException:
except UnknownUserError:
# If the user has no supported CozyTouch devices on
# the Overkiz API server. Login will return unknown user.
if user_input[CONF_HUB] in {
@@ -239,12 +242,12 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
try:
user_input = await self.async_validate_input(user_input)
except TooManyRequestsException:
except TooManyRequestsError:
errors["base"] = "too_many_requests"
except (
BadCredentialsException,
NotSuchTokenException,
NotAuthenticatedException,
BadCredentialsError,
NoSuchTokenError,
NotAuthenticatedError,
):
errors["base"] = "invalid_auth"
except ClientConnectorCertificateError as exception:
@@ -253,11 +256,11 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
except (TimeoutError, ClientError) as exception:
errors["base"] = "cannot_connect"
LOGGER.debug(exception)
except MaintenanceException:
except MaintenanceError:
errors["base"] = "server_in_maintenance"
except TooManyAttemptsBannedException:
except TooManyAttemptsBannedError:
errors["base"] = "too_many_attempts"
except UnknownUserException:
except UnknownUserError:
# Somfy Protect accounts are not supported since they don't use
# the Overkiz API server. Login will return unknown user.
description_placeholders["unsupported_device"] = "Somfy Protect"
+1 -1
View File
@@ -118,7 +118,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = {
UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH,
UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH,
UIWidget.TSKALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
UIWidget.TSK_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE,
}
+39 -33
View File
@@ -9,15 +9,23 @@ from aiohttp import ClientConnectorError, ServerDisconnectedError
from pyoverkiz.client import OverkizClient
from pyoverkiz.enums import EventName, ExecutionState, Protocol
from pyoverkiz.exceptions import (
BadCredentialsException,
InvalidEventListenerIdException,
MaintenanceException,
NotAuthenticatedException,
ServiceUnavailableException,
TooManyConcurrentRequestsException,
TooManyRequestsException,
BadCredentialsError,
InvalidEventListenerIdError,
MaintenanceError,
NotAuthenticatedError,
ServiceUnavailableError,
TooManyConcurrentRequestsError,
TooManyRequestsError,
)
from pyoverkiz.models import (
Device,
DeviceEvent,
DeviceRemovedEvent,
DeviceStateChangedEvent,
ExecutionRegisteredEvent,
ExecutionStateChangedEvent,
Place,
)
from pyoverkiz.models import Device, Event, Place
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -30,8 +38,9 @@ if TYPE_CHECKING:
from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, LOGGER, UPDATE_INTERVAL
# Events are a discriminated union; each handler narrows to its own subtype.
EVENT_HANDLERS: Registry[
str, Callable[[OverkizDataUpdateCoordinator, Event], Coroutine[Any, Any, None]]
str, Callable[[OverkizDataUpdateCoordinator, Any], Coroutine[Any, Any, None]]
] = Registry()
@@ -68,7 +77,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
self._default_update_interval = UPDATE_INTERVAL
self.is_stateless = all(
device.protocol in (Protocol.RTS, Protocol.INTERNAL)
device.identifier.protocol in (Protocol.RTS, Protocol.INTERNAL)
for device in devices
if device.widget not in IGNORED_OVERKIZ_DEVICES
and device.ui_class not in IGNORED_OVERKIZ_DEVICES
@@ -78,17 +87,17 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
"""Fetch Overkiz data via event listener."""
try:
events = await self.client.fetch_events()
except (BadCredentialsException, NotAuthenticatedException) as exception:
except (BadCredentialsError, NotAuthenticatedError) as exception:
raise ConfigEntryAuthFailed("Invalid authentication.") from exception
except TooManyConcurrentRequestsException as exception:
except TooManyConcurrentRequestsError as exception:
raise UpdateFailed("Too many concurrent requests.") from exception
except TooManyRequestsException as exception:
except TooManyRequestsError as exception:
raise UpdateFailed("Too many requests, try again later.") from exception
except MaintenanceException as exception:
except MaintenanceError as exception:
raise UpdateFailed("Server is down for maintenance.") from exception
except ServiceUnavailableException as exception:
except ServiceUnavailableError as exception:
raise UpdateFailed("Server is unavailable.") from exception
except InvalidEventListenerIdException as exception:
except InvalidEventListenerIdError as exception:
raise UpdateFailed(exception) from exception
except (TimeoutError, ClientConnectorError) as exception:
LOGGER.debug("Failed to connect", exc_info=True)
@@ -100,9 +109,9 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
try:
await self.client.login()
self.devices = await self._get_devices()
except (BadCredentialsException, NotAuthenticatedException) as exception:
except (BadCredentialsError, NotAuthenticatedError) as exception:
raise ConfigEntryAuthFailed("Invalid authentication.") from exception
except TooManyRequestsException as exception:
except TooManyRequestsError as exception:
raise UpdateFailed("Too many requests, try again later.") from exception
return self.devices
@@ -144,27 +153,27 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
@EVENT_HANDLERS.register(EventName.DEVICE_AVAILABLE)
async def on_device_available(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
) -> None:
"""Handle device available event."""
if event.device_url and event.device_url in coordinator.devices:
if event.device_url in coordinator.devices:
coordinator.devices[event.device_url].available = True
@EVENT_HANDLERS.register(EventName.DEVICE_UNAVAILABLE)
@EVENT_HANDLERS.register(EventName.DEVICE_DISABLED)
async def on_device_unavailable_disabled(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
) -> None:
"""Handle device unavailable / disabled event."""
if event.device_url and event.device_url in coordinator.devices:
if event.device_url in coordinator.devices:
coordinator.devices[event.device_url].available = False
@EVENT_HANDLERS.register(EventName.DEVICE_CREATED)
@EVENT_HANDLERS.register(EventName.DEVICE_UPDATED)
async def on_device_created_updated(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
) -> None:
"""Handle device unavailable / disabled event."""
coordinator.hass.async_create_task(
@@ -174,10 +183,10 @@ async def on_device_created_updated(
@EVENT_HANDLERS.register(EventName.DEVICE_STATE_CHANGED)
async def on_device_state_changed(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceStateChangedEvent
) -> None:
"""Handle device state changed event."""
if not event.device_url or event.device_url not in coordinator.devices:
if event.device_url not in coordinator.devices:
return
for state in event.device_states:
@@ -187,12 +196,9 @@ async def on_device_state_changed(
@EVENT_HANDLERS.register(EventName.DEVICE_REMOVED)
async def on_device_removed(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceRemovedEvent
) -> None:
"""Handle device removed event."""
if not event.device_url:
return
base_device_url = event.device_url.split("#")[0]
registry = dr.async_get(coordinator.hass)
@@ -201,16 +207,16 @@ async def on_device_removed(
):
registry.async_remove_device(registered_device.id)
if event.device_url and event.device_url in coordinator.devices:
if event.device_url in coordinator.devices:
del coordinator.devices[event.device_url]
@EVENT_HANDLERS.register(EventName.EXECUTION_REGISTERED)
async def on_execution_registered(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: ExecutionRegisteredEvent
) -> None:
"""Handle execution registered event."""
if event.exec_id and event.exec_id not in coordinator.executions:
if event.exec_id not in coordinator.executions:
coordinator.executions[event.exec_id] = {}
if not coordinator.is_stateless:
@@ -219,7 +225,7 @@ async def on_execution_registered(
@EVENT_HANDLERS.register(EventName.EXECUTION_STATE_CHANGED)
async def on_execution_state_changed(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: ExecutionStateChangedEvent
) -> None:
"""Handle execution changed event."""
if event.exec_id in coordinator.executions and event.new_state in [
+7 -5
View File
@@ -631,7 +631,7 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
"""
state_name = self.entity_description.current_position_state
if not state_name or not (state := self.device.states[state_name]):
if not state_name or not (state := self.device.states.get(state_name)):
return None
position = state.value_as_int
@@ -645,9 +645,9 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
state_name,
)
if fallback_state := self.device.states[
if fallback_state := self.device.states.get(
OverkizState.CORE_MEMORIZED_1_POSITION
]:
):
position = fallback_state.value_as_int
else:
return None
@@ -661,7 +661,9 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
state_name,
)
if fallback_state := self.device.states[OverkizState.CORE_TARGET_CLOSURE]:
if fallback_state := self.device.states.get(
OverkizState.CORE_TARGET_CLOSURE
):
position = fallback_state.value_as_int
else:
return None
@@ -707,7 +709,7 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
"""
state_name = self.entity_description.current_tilt_position_state
if state_name and (state := self.device.states[state_name]):
if state_name and (state := self.device.states.get(state_name)):
position = state.value_as_int
if position is None:
return None
@@ -19,13 +19,13 @@ async def async_get_config_entry_diagnostics(
client = entry.runtime_data.coordinator.client
data = {
"setup": await client.get_diagnostic_data(),
**await client.get_diagnostic_data(),
"server": entry.data[CONF_HUB],
"api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD),
}
# Only Overkiz cloud servers expose an endpoint with execution history
if client.api_type == APIType.CLOUD:
if client.server_config.api_type == APIType.CLOUD:
execution_history = [
repr(execution) for execution in await client.get_execution_history()
]
@@ -49,13 +49,13 @@ async def async_get_device_diagnostics(
"device_url": obfuscate_id(device_url),
"model": device.model,
},
"setup": await client.get_diagnostic_data(),
**await client.get_diagnostic_data(),
"server": entry.data[CONF_HUB],
"api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD),
}
# Only Overkiz cloud servers expose an endpoint with execution history
if client.api_type == APIType.CLOUD:
if client.server_config.api_type == APIType.CLOUD:
data["execution_history"] = [
repr(execution)
for execution in await client.get_execution_history()
+3 -3
View File
@@ -49,7 +49,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
# Workaround: local API may incorrectly report
# available=False (Somfy-TaHoma-Developer-Mode#217)
if self.coordinator.client.api_type != APIType.LOCAL:
if self.coordinator.client.server_config.api_type != APIType.LOCAL:
return False
if status_state := self.device.states.get(OverkizState.CORE_STATUS):
@@ -85,7 +85,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
manufacturer = (
self.executor.select_attribute(OverkizAttribute.CORE_MANUFACTURER)
or self.executor.select_state(OverkizState.CORE_MANUFACTURER_NAME)
or self.coordinator.client.server.manufacturer
or self.coordinator.client.server_config.manufacturer
)
model = (
@@ -116,7 +116,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
hw_version=self.device.controllable_name,
suggested_area=suggested_area,
via_device=(DOMAIN, self.executor.get_gateway_id()),
configuration_url=self.coordinator.client.server.configuration_url,
configuration_url=self.coordinator.client.server_config.configuration_url,
)
+25 -23
View File
@@ -1,11 +1,11 @@
"""Class for helpers and communication with the OverKiz API."""
from typing import Any, cast
from typing import Any
from urllib.parse import urlparse
from pyoverkiz.enums import OverkizCommand, Protocol
from pyoverkiz.exceptions import BaseOverkizException
from pyoverkiz.models import Command, Device, StateDefinition
from pyoverkiz.exceptions import BaseOverkizError
from pyoverkiz.models import Action, Command, Device, StateDefinition
from pyoverkiz.types import StateType as OverkizStateType
from homeassistant.exceptions import HomeAssistantError
@@ -56,15 +56,15 @@ class OverkizExecutor:
def select_definition_state(self, *states: str) -> StateDefinition | None:
"""Select first existing definition state in a list of states."""
for existing_state in self.device.definition.states:
if existing_state.qualified_name in states:
return existing_state
for state_name in states:
if state_name in self.device.definition.states:
return self.device.definition.states[state_name]
return None
def select_state(self, *states: str) -> OverkizStateType:
"""Select first existing active state in a list of states."""
for state in states:
if current_state := self.device.states[state]:
if current_state := self.device.states.get(state):
return current_state.value
return None
@@ -76,7 +76,7 @@ class OverkizExecutor:
def select_attribute(self, *attributes: str) -> OverkizStateType:
"""Select first existing active state in a list of states."""
for attribute in attributes:
if current_attribute := self.device.attributes[attribute]:
if current_attribute := self.device.attributes.get(attribute):
return current_attribute.value
return None
@@ -94,19 +94,23 @@ class OverkizExecutor:
# Set the execution duration to 0 seconds for RTS devices on supported commands
# Default execution duration is 30 seconds and will block consecutive commands
if (
self.device.protocol == Protocol.RTS
self.device.identifier.protocol == Protocol.RTS
and command_name not in COMMANDS_WITHOUT_DELAY
):
parameters.append(0)
try:
exec_id = await self.coordinator.client.execute_command(
self.device.device_url,
Command(command_name, parameters),
"Home Assistant",
exec_id = await self.coordinator.client.execute_action_group(
label="Home Assistant",
actions=[
Action(
device_url=self.device.device_url,
commands=[Command(name=command_name, parameters=parameters)],
)
],
)
# Catch Overkiz exceptions to support `continue_on_error` functionality
except BaseOverkizException as exception:
except BaseOverkizError as exception:
raise HomeAssistantError(exception) from exception
# ExecutionRegisteredEvent doesn't contain the
@@ -142,18 +146,16 @@ class OverkizExecutor:
return True
# Retrieve executions initiated outside Home Assistant via API
executions = cast(Any, await self.coordinator.client.get_current_executions())
# executions.action_group is typed incorrectly in the upstream library
# or the below code is incorrect.
executions = await self.coordinator.client.get_current_executions()
exec_id = next(
(
execution.id
for execution in executions
# Reverse dictionary to cancel the last added execution
for action in reversed(execution.action_group.get("actions"))
for command in action.get("commands")
if action.get("device_url") == self.device.device_url
and command.get("name") in commands_to_cancel
if execution.action_group
for action in reversed(execution.action_group.actions)
for command in action.commands
if action.device_url == self.device.device_url
and command.name in commands_to_cancel
),
None,
)
@@ -166,7 +168,7 @@ class OverkizExecutor:
async def async_cancel_execution(self, exec_id: str) -> None:
"""Cancel running execution via execution id."""
await self.coordinator.client.cancel_command(exec_id)
await self.coordinator.client.cancel_execution(exec_id)
def get_gateway_id(self) -> str:
"""Retrieve gateway id from device url.
@@ -12,8 +12,8 @@
"documentation": "https://www.home-assistant.io/integrations/overkiz",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.20.4"],
"loggers": ["boto3", "botocore", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz[nexity]==2.0.0"],
"zeroconf": [
{
"name": "gateway*",
+1 -1
View File
@@ -213,7 +213,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state.qualified_name))
if (description := SUPPORTED_STATES.get(state))
)
async_add_entities(entities)
+3 -3
View File
@@ -3,7 +3,7 @@
from typing import Any
from pyoverkiz.client import OverkizClient
from pyoverkiz.models import Scenario
from pyoverkiz.models import PersistedActionGroup
from homeassistant.components.scene import Scene
from homeassistant.core import HomeAssistant
@@ -28,7 +28,7 @@ async def async_setup_entry(
class OverkizScene(Scene):
"""Representation of an Overkiz Scene."""
def __init__(self, scenario: Scenario, client: OverkizClient) -> None:
def __init__(self, scenario: PersistedActionGroup, client: OverkizClient) -> None:
"""Initialize the scene."""
self.scenario = scenario
self.client = client
@@ -37,4 +37,4 @@ class OverkizScene(Scene):
async def async_activate(self, **kwargs: Any) -> None:
"""Activate the scene."""
await self.client.execute_scenario(self.scenario.oid)
await self.client.execute_persisted_action_group(self.scenario.oid)
+1 -1
View File
@@ -144,7 +144,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state.qualified_name))
if (description := SUPPORTED_STATES.get(state))
)
async_add_entities(entities)
+3 -3
View File
@@ -550,7 +550,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state.qualified_name))
if (description := SUPPORTED_STATES.get(state))
)
async_add_entities(entities)
@@ -597,12 +597,12 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity):
return default_unit
attrs = self.device.attributes
if (unit := attrs[f"{state.name}MeasuredValueType"]) and (
if (unit := attrs.get(f"{state.name}MeasuredValueType")) and (
unit_value := unit.value_as_str
):
return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
if (unit := attrs[OverkizAttribute.CORE_MEASURED_VALUE_TYPE]) and (
if (unit := attrs.get(OverkizAttribute.CORE_MEASURED_VALUE_TYPE)) and (
unit_value := unit.value_as_str
):
ha_unit = OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
@@ -48,7 +48,9 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater
def min_temp(self) -> float:
"""Return the minimum temperature."""
min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE]
min_temp = self.device.states.get(
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
)
if min_temp:
return cast(float, min_temp.value_as_float)
return DEFAULT_MIN_TEMP
@@ -57,7 +59,9 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater
def max_temp(self) -> float:
"""Return the maximum temperature."""
max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE]
max_temp = self.device.states.get(
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
)
if max_temp:
return cast(float, max_temp.value_as_float)
return DEFAULT_MAX_TEMP
@@ -156,7 +156,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE]
min_temp = self.device.states.get(
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
)
if min_temp:
return cast(float, min_temp.value_as_float)
return DEFAULT_MIN_TEMP
@@ -164,7 +166,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE]
max_temp = self.device.states.get(
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
)
if max_temp:
return cast(float, max_temp.value_as_float)
return DEFAULT_MAX_TEMP
@@ -172,14 +176,14 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
current_temperature = self.device.states[
current_temperature = self.device.states.get(
OverkizState.IO_MIDDLE_WATER_TEMPERATURE
]
)
if current_temperature:
return current_temperature.value_as_float
current_temperature = self.device.states[
current_temperature = self.device.states.get(
OverkizState.MODBUSLINK_MIDDLE_WATER_TEMPERATURE
]
)
if current_temperature:
return current_temperature.value_as_float
return None
@@ -188,19 +192,21 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
target_temperature = self.device.states[
target_temperature = self.device.states.get(
OverkizState.CORE_WATER_TARGET_TEMPERATURE
]
)
if target_temperature:
return target_temperature.value_as_float
target_temperature = self.device.states[
target_temperature = self.device.states.get(
OverkizState.CORE_TARGET_DWH_TEMPERATURE
]
)
if target_temperature:
return target_temperature.value_as_float
target_temperature = self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
target_temperature = self.device.states.get(
OverkizState.CORE_TARGET_TEMPERATURE
)
if target_temperature:
return target_temperature.value_as_float
@@ -209,9 +215,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def target_temperature_high(self) -> float | None:
"""Return the highbound target temperature we try to reach."""
target_temperature_high = self.device.states[
target_temperature_high = self.device.states.get(
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
]
)
if target_temperature_high:
return target_temperature_high.value_as_float
return None
@@ -219,9 +225,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def target_temperature_low(self) -> float | None:
"""Return the lowbound target temperature we try to reach."""
target_temperature_low = self.device.states[
target_temperature_low = self.device.states.get(
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
]
)
if target_temperature_low:
return target_temperature_low.value_as_float
return None
@@ -45,7 +45,7 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
current_temperature = self.device.states[OverkizState.CORE_DHW_TEMPERATURE]
current_temperature = self.device.states.get(OverkizState.CORE_DHW_TEMPERATURE)
if current_temperature and current_temperature.value_as_int:
return float(current_temperature.value_as_int)
@@ -55,9 +55,9 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
target_temperature = self.device.states[
target_temperature = self.device.states.get(
OverkizState.MODBUS_CONTROL_DHW_SETTING_TEMPERATURE
]
)
if target_temperature and target_temperature.value_as_int:
return float(target_temperature.value_as_int)
@@ -74,11 +74,11 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
@property
def current_operation(self) -> str | None:
"""Return current operation ie. eco, electric, performance, ..."""
modbus_control = self.device.states[OverkizState.MODBUS_CONTROL_DHW]
modbus_control = self.device.states.get(OverkizState.MODBUS_CONTROL_DHW)
if modbus_control and modbus_control.value_as_str == OverkizCommandParam.STOP:
return STATE_OFF
current_mode = self.device.states[OverkizState.MODBUS_DHW_MODE]
current_mode = self.device.states.get(OverkizState.MODBUS_DHW_MODE)
if current_mode and current_mode.value_as_str in OVERKIZ_TO_OPERATION_MODE:
return OVERKIZ_TO_OPERATION_MODE[current_mode.value_as_str]
+2 -2
View File
@@ -155,9 +155,9 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
)
@property
def current_temperature(self) -> float:
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self.device["sensors"]["temperature"]
return self.device["sensors"].get("temperature")
@property
def target_temperature(self) -> float:
@@ -1,5 +1,6 @@
"""Roborock Coordinator."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
@@ -21,7 +22,7 @@ from roborock.roborock_message import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_CONNECTIONS
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -117,6 +118,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState | None]):
# to the base class. This is reset on successful data update.
self._last_update_success_time: datetime | None = None
self._has_connected_locally: bool = False
self._unsubs: list[Callable[[], None]] = []
@cached_property
def dock_device_info(self) -> DeviceInfo:
@@ -169,6 +171,15 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState | None]):
# Force a map refresh on first setup
self.last_home_update = dt_util.utcnow() - IMAGE_CACHE_INTERVAL
self._unsubs.append(
self.properties_api.status.add_update_listener(self._handle_trait_update)
)
self._unsubs.append(
self.properties_api.consumables.add_update_listener(
self._handle_trait_update
)
)
async def update_map(self) -> None:
"""Update the currently selected map."""
try:
@@ -266,12 +277,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState | None]):
self.last_update_state = self.properties_api.status.state_name
self._last_update_success_time = dt_util.utcnow()
_LOGGER.debug("Data update successful %s", self._last_update_success_time)
return DeviceState(
status=self.properties_api.status,
dnd_timer=self.properties_api.dnd,
consumable=self.properties_api.consumables,
clean_summary=self.properties_api.clean_summary,
)
return self._device_state
def _should_suppress_update_failure(self) -> bool:
"""Determine if we should suppress update failure reporting.
@@ -290,6 +296,31 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState | None]):
_LOGGER.debug("Update failure duration: %s", failure_duration)
return failure_duration < MIN_UNAVAILABLE_DURATION
@property
def _device_state(self) -> DeviceState:
"""Return the current device state."""
return DeviceState(
status=self.properties_api.status,
dnd_timer=self.properties_api.dnd,
consumable=self.properties_api.consumables,
clean_summary=self.properties_api.clean_summary,
)
@callback
def _handle_trait_update(self) -> None:
"""Handle trait updates from push notifications."""
_LOGGER.debug("Trait updated, updating coordinator data")
self.async_set_updated_data(self._device_state)
# We optimize streaming updates to catch state transitions immediately, but
# secondary updates (like refreshing the map) can happen on their own interval.
async def async_shutdown(self) -> None:
"""Shutdown coordinator and unsubscribe update listeners."""
await super().async_shutdown()
for unsub in self._unsubs:
unsub()
self._unsubs.clear()
async def get_routines(self) -> list[HomeDataScene]:
"""Get routines."""
try:
@@ -1,5 +1,6 @@
"""The Shelly integration."""
import asyncio
from functools import partial
from typing import Final
@@ -73,6 +74,7 @@ from .utils import (
get_http_port,
get_rpc_scripts_event_types,
get_ws_context,
is_rpc_ble_scanner_supported,
remove_empty_sub_devices,
remove_stale_blu_trv_devices,
)
@@ -114,6 +116,9 @@ COAP_SCHEMA: Final = vol.Schema(
)
CONFIG_SCHEMA: Final = vol.Schema({DOMAIN: COAP_SCHEMA}, extra=vol.ALLOW_EXTRA)
# Max time to wait at startup for a BLE proxy to register its scanner.
STARTUP_SCANNER_WAIT: Final = 3.0
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Shelly component."""
@@ -365,6 +370,21 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
runtime_data.rpc = ShellyRpcCoordinator(hass, entry, device)
runtime_data.rpc.async_setup()
if (
is_rpc_ble_scanner_supported(entry)
and entry.options.get(CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED)
!= BLEScannerMode.DISABLED
):
# Wait for the proxy to register its scanner before finishing setup.
try:
async with asyncio.timeout(STARTUP_SCANNER_WAIT):
await runtime_data.rpc.ble_scanner_setup_done.wait()
except TimeoutError:
LOGGER.debug(
"%s: Timed out waiting for BLE scanner to register", entry.title
)
runtime_data.rpc_poll = ShellyRpcPollingCoordinator(hass, entry, device)
await hass.config_entries.async_forward_entry_setups(
entry, runtime_data.platforms
+24 -19
View File
@@ -521,6 +521,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
super().__init__(hass, entry, device, update_interval)
self.connected = False
# Set once BLE scanner setup has been attempted after connecting.
self.ble_scanner_setup_done = asyncio.Event()
self._disconnected_callbacks: list[CALLBACK_TYPE] = []
self._connection_lock = asyncio.Lock()
self._event_listeners: list[Callable[[dict[str, Any]], None]] = []
@@ -759,27 +761,30 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
async def _async_connect_ble_scanner(self) -> None:
"""Connect BLE scanner."""
ble_scanner_mode = self.config_entry.options.get(
CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED
)
if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected:
await async_stop_scanner(self.device)
async_remove_scanner(self.hass, self.bluetooth_source)
return
if await async_ensure_ble_enabled(self.device):
# BLE enable required a reboot, don't bother connecting
# the scanner since it will be disconnected anyway
LOGGER.debug(
"Device %s BLE enable required a reboot, skipping scanner connect",
self.name,
try:
ble_scanner_mode = self.config_entry.options.get(
CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED
)
return
assert self.device_id is not None
self._disconnected_callbacks.append(
await async_connect_scanner(
self.hass, self, ble_scanner_mode, self.device_id
if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected:
await async_stop_scanner(self.device)
async_remove_scanner(self.hass, self.bluetooth_source)
return
if await async_ensure_ble_enabled(self.device):
# BLE enable required a reboot, don't bother connecting
# the scanner since it will be disconnected anyway
LOGGER.debug(
"Device %s BLE enable required a reboot, skipping scanner connect",
self.name,
)
return
assert self.device_id is not None
self._disconnected_callbacks.append(
await async_connect_scanner(
self.hass, self, ble_scanner_mode, self.device_id
)
)
)
finally:
self.ble_scanner_setup_done.set()
@callback
def _async_handle_rpc_device_online(self) -> None:
+2 -2
View File
@@ -663,9 +663,9 @@ def is_view_for_platform(config: dict[str, Any], key: str, platform: str) -> boo
def get_virtual_component_unit(config: dict[str, Any]) -> str | None:
"""Return the unit of a virtual component.
If the unit is not set, the device sends an empty string
If the unit is not set, the device sends an empty string or the key may be absent.
"""
unit = config["meta"]["ui"]["unit"]
unit = config["meta"]["ui"].get("unit")
return DEVICE_UNIT_MAP.get(unit, unit) if unit else None
@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["uiprotect"],
"quality_scale": "platinum",
"requirements": ["uiprotect==10.5.0"]
"requirements": ["uiprotect==11.8.0"]
}
@@ -16,6 +16,11 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return async_redact_data(
{k: asdict(v) for k, v in entry.runtime_data.data.items()}, TO_REDACT
)
return {
"version": entry.runtime_data.version.version
if entry.runtime_data.version
else None,
"monitors": async_redact_data(
{k: asdict(v) for k, v in entry.runtime_data.data.items()}, TO_REDACT
),
}
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"quality_scale": "platinum",
"requirements": ["victron-mqtt==2026.6.1"],
"requirements": ["victron-mqtt==2026.6.1.1"],
"ssdp": [
{
"X_MqttOnLan": "1",
+6 -2
View File
@@ -114,11 +114,15 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
return self.client.players
async def _async_load_library(self) -> None:
"""Load the card library; failures only affect titles and artwork."""
"""Load the card library and groups; failures only affect browsing."""
try:
await self.client.update_library()
except YotoError as err:
_LOGGER.warning("Could not load Yoto card library: %s", err)
try:
await self.client.update_groups()
except YotoError as err:
_LOGGER.warning("Could not load Yoto card groups: %s", err)
async def _async_status_push_tick(self, _now: datetime) -> None:
"""Ask each player to push a fresh status snapshot over MQTT."""
@@ -127,7 +131,7 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
# Fire-and-forget: the data/status response lands via the on_update
# callback later, which already triggers async_set_updated_data.
for device_id in list(self.client.players):
await self.client.request_status_push(device_id)
await self.client.request_player_status(device_id)
def _mqtt_event(self, _player: YotoPlayer) -> None:
"""Handle a real-time update pushed by the Yoto MQTT broker."""
+1 -1
View File
@@ -10,5 +10,5 @@
"iot_class": "cloud_push",
"loggers": ["yoto_api"],
"quality_scale": "bronze",
"requirements": ["yoto-api==3.2.0"]
"requirements": ["yoto-api==4.0.2"]
}
+75 -20
View File
@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Any
from yoto_api import Card, Chapter, PlaybackStatus, Track, YotoError, YotoPlayer
from yoto_api import Card, Chapter, Group, PlaybackStatus, Track, YotoError, YotoPlayer
from homeassistant.components.media_player import (
BrowseError,
@@ -25,9 +25,8 @@ from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoEntity
URI_SCHEME = "yoto"
# The URI authority ("card") names the content type. Only cards exist today;
# reserving it leaves room for groups without breaking URIs.
URI_CARD = "card"
URI_GROUP = "group"
PARALLEL_UPDATES = 0
@@ -86,7 +85,7 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
@property
def available(self) -> bool:
"""Return whether the player is reachable through the Yoto cloud."""
return super().available and bool(self.player.status.is_online)
return super().available and bool(self.player.is_online)
@property
def state(self) -> MediaPlayerState:
@@ -186,7 +185,7 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
) -> None:
"""Play a Yoto card, chapter, or track from the browse tree."""
try:
card_id, chapter_key, track_key = _parse_uri(media_id)
card_id, chapter_key, track_key = _parse_card_uri(media_id)
except ValueError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
@@ -264,8 +263,27 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
if not media_content_id:
return self._browse_root()
client = self.coordinator.client
if media_content_id.startswith(f"{URI_SCHEME}://{URI_GROUP}/"):
try:
group_id = _parse_group_uri(media_content_id)
except ValueError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="invalid_media_id",
translation_placeholders={"media_id": media_content_id},
) from err
group = client.groups.get(group_id)
if group is None:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="unknown_group",
translation_placeholders={"group_id": group_id},
)
return self._browse_group(group)
try:
card_id, chapter_key, _ = _parse_uri(media_content_id)
card_id, chapter_key, _ = _parse_card_uri(media_content_id)
except ValueError as err:
raise BrowseError(
translation_domain=DOMAIN,
@@ -273,7 +291,7 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
translation_placeholders={"media_id": media_content_id},
) from err
card = self.coordinator.client.library.get(card_id)
card = client.library.get(card_id)
if card is None:
raise BrowseError(
translation_domain=DOMAIN,
@@ -283,7 +301,7 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
if not card.chapters:
try:
await self.coordinator.client.update_card_detail(card_id)
await client.update_card_detail(card_id)
except YotoError as err:
raise BrowseError(
translation_domain=DOMAIN,
@@ -307,7 +325,12 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
return self._browse_card(card)
def _browse_root(self) -> BrowseMedia:
"""List every card in the user's library."""
"""List every card and group in the user's library."""
client = self.coordinator.client
children: list[BrowseMedia] = [
self._card_node(card) for card in client.library.values()
]
children.extend(self._group_node(group) for group in client.groups.values())
return BrowseMedia(
media_class=MediaClass.DIRECTORY,
media_content_id="",
@@ -315,13 +338,19 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
title="Yoto library",
can_play=False,
can_expand=True,
children=[
self._card_node(card)
for card in self.coordinator.client.library.values()
],
children=children,
children_media_class=MediaClass.ALBUM,
)
def _browse_group(self, group: Group) -> BrowseMedia:
"""List the cards in a group."""
library = self.coordinator.client.library
cards = [library[card_id] for card_id in group.card_ids if card_id in library]
node = self._group_node(group)
node.children = [self._card_node(card) for card in cards]
node.children_media_class = MediaClass.ALBUM
return node
def _browse_card(self, card: Card) -> BrowseMedia:
"""List a card's chapters, collapsing single-chapter cards to tracks."""
chapters = card.chapters
@@ -366,6 +395,18 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
thumbnail=card.cover_image_large,
)
def _group_node(self, group: Group) -> BrowseMedia:
"""Build a browse node for a group."""
return BrowseMedia(
media_class=MediaClass.PLAYLIST,
media_content_id=_build_group_uri(group.id),
media_content_type=MediaType.PLAYLIST,
title=group.name or group.id,
can_play=False,
can_expand=True,
thumbnail=group.image_url,
)
def _chapter_node(
self, card_id: str, chapter_key: str, chapter: Chapter
) -> BrowseMedia:
@@ -423,19 +464,33 @@ def _build_uri(
return f"{URI_SCHEME}://{'/'.join(segments)}"
def _parse_uri(media_id: str) -> tuple[str, str | None, str | None]:
"""Parse a yoto://card/... URI into card/chapter/track parts.
def _build_group_uri(group_id: str) -> str:
"""Build a yoto://group/... URI."""
return f"{URI_SCHEME}://{URI_GROUP}/{group_id}"
Parsed manually because URL parsers lower-case the authority and Yoto
IDs are case-sensitive.
"""
# URIs parsed manually because URL parsers lower-case the authority and Yoto
# IDs are case-sensitive.
def _parse_card_uri(media_id: str) -> tuple[str, str | None, str | None]:
"""Parse a yoto://card/... URI into card/chapter/track parts."""
prefix = f"{URI_SCHEME}://{URI_CARD}/"
if not media_id.startswith(prefix):
raise ValueError(f"Not a Yoto media identifier: {media_id}")
raise ValueError(f"Not a Yoto card identifier: {media_id}")
parts = media_id[len(prefix) :].split("/")
if not parts or len(parts) > 3 or any(not segment for segment in parts):
raise ValueError(f"Not a Yoto media identifier: {media_id}")
raise ValueError(f"Not a Yoto card identifier: {media_id}")
card_id = parts[0]
chapter_key = parts[1] if len(parts) > 1 else None
track_key = parts[2] if len(parts) > 2 else None
return card_id, chapter_key, track_key
def _parse_group_uri(media_id: str) -> str:
"""Parse a yoto://group/<group_id> URI."""
prefix = f"{URI_SCHEME}://{URI_GROUP}/"
if not media_id.startswith(prefix):
raise ValueError(f"Not a Yoto group identifier: {media_id}")
parts = media_id[len(prefix) :].split("/")
if len(parts) != 1 or not parts[0]:
raise ValueError(f"Not a Yoto group identifier: {media_id}")
return parts[0]
@@ -52,6 +52,9 @@
"unknown_chapter": {
"message": "Unknown chapter {chapter_key} on card {card_id}"
},
"unknown_group": {
"message": "Unknown Yoto group: {group_id}"
},
"unknown_track": {
"message": "Unknown track {track_key} on card {card_id}"
},
@@ -258,8 +258,6 @@ async def _async_get_local_service_info(hass: HomeAssistant) -> AsyncServiceInfo
"internal_url": "",
# Old base URL, for backward compatibility
"base_url": "",
# Always needs authentication
"requires_api_password": True,
}
# Get instance URL's
+9
View File
@@ -22,6 +22,7 @@ from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER
from homeassistant.components.homeassistant import async_should_expose
from homeassistant.components.intent import async_device_supports_timers
from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
from homeassistant.components.sensor import async_rounded_state
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN, TodoServices
from homeassistant.components.weather import INTENT_GET_WEATHER
from homeassistant.const import (
@@ -720,6 +721,10 @@ def _get_exposed_entities(
if include_state:
info["state"] = state.state
# Format numeric states with configured display precision
if state.domain == "sensor":
info["state"] = async_rounded_state(hass, state.entity_id, state)
# Convert timestamp device_class states from UTC to local time
if state.attributes.get("device_class") == "timestamp" and state.state:
if (parsed_utc := dt_util.parse_datetime(state.state)) is not None:
@@ -1301,6 +1306,10 @@ class GetLiveContextTool(Tool):
name=name_filter,
area_name=area_filter,
domains=domain_filter,
# This tool only returns context, so multiple entities
# sharing a name (e.g. "AC" in two areas) should all be
# returned rather than failing as an ambiguous match.
allow_duplicate_names=True,
),
states=exposed_states,
)
+12 -2
View File
@@ -400,10 +400,16 @@ class _ConditionFail(_HaltScript):
class _StopScript(_HaltScript):
"""Throw if script needs to stop."""
def __init__(self, message: str, response: Any) -> None:
def __init__(
self,
message: str,
response: Any,
conversation_response: str | None | UndefinedType = UNDEFINED,
) -> None:
"""Initialize a halt exception."""
super().__init__(message)
self.response = response
self.conversation_response = conversation_response
class _ScriptRun:
@@ -481,6 +487,10 @@ class _ScriptRun:
response = err.response
# Bubble up child conversation response
if err.conversation_response is not UNDEFINED:
self._conversation_response = err.conversation_response
except Exception:
script_execution_set("error")
raise
@@ -979,7 +989,7 @@ class _ScriptRun:
) from ex
else:
response = None
raise _StopScript(stop, response)
raise _StopScript(stop, response, self._conversation_response)
## Variable actions ##
+28
View File
@@ -44,11 +44,13 @@ from homeassistant.const import (
)
from homeassistant.core import (
CALLBACK_TYPE,
DOMAIN as HOMEASSISTANT_DOMAIN,
Context,
HassJob,
HassJobType,
HomeAssistant,
State,
async_get_hass_or_none,
callback,
get_hassjob_callable_job_type,
is_callback,
@@ -331,11 +333,37 @@ BEHAVIOR_ALL: Final = "all"
BEHAVIOR_EACH: Final = "each"
def _create_deprecated_behavior_issue(deprecated: str, replacement: str) -> None:
"""Inform the user a renamed trigger behavior value is still in use."""
# Returns None when called from the wrong thread or before hass is set up
# (e.g. a `check_config` run), in which case there's nothing to report to.
if (hass := async_get_hass_or_none()) is None:
return
from .issue_registry import IssueSeverity, async_create_issue # noqa: PLC0415
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_trigger_behavior_{deprecated}",
breaks_in_ha_version="2027.1",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_trigger_behavior",
translation_placeholders={
"deprecated_behavior": deprecated,
"new_behavior": replacement,
},
)
def _backwards_compatible_behavior(value: Any) -> Any:
"""Convert legacy behavior values to new ones."""
if value == "any":
_create_deprecated_behavior_issue("any", BEHAVIOR_EACH)
return BEHAVIOR_EACH
if value == "last":
_create_deprecated_behavior_issue("last", BEHAVIOR_ALL)
return BEHAVIOR_ALL
return value
+3 -3
View File
@@ -6,7 +6,7 @@ aiodns==4.0.4
aiogithubapi==26.0.0
aiohttp-asyncmdnsresolver==0.2.0
aiohttp-fast-zlib==0.3.0
aiohttp==3.14.0
aiohttp==3.14.1
aiohttp_cors==0.8.1
aiousbwatcher==1.1.2
aiozoneinfo==0.2.3
@@ -35,11 +35,11 @@ file-read-backwards==2.0.0
fnv-hash-fast==2.0.3
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.8.1
habluetooth==6.8.3
hass-nabucasa==2.2.0
hassil==3.6.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260527.4
home-assistant-frontend==20260527.5
home-assistant-intents==2026.6.1
httpx==0.28.1
ifaddr==0.2.0
+1 -1
View File
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
from typing import Final
FRONTEND_VERSION: Final[str] = "20260527.4"
FRONTEND_VERSION: Final[str] = "20260527.5"
MDI_ICONS: Final[set[str]] = {
"ab-testing",
+1 -1
View File
@@ -28,7 +28,7 @@ dependencies = [
# module level in `bootstrap.py` and its requirements thus need to be in
# requirements.txt to ensure they are always installed
"aiogithubapi==26.0.0",
"aiohttp==3.14.0",
"aiohttp==3.14.1",
"aiohttp_cors==0.8.1",
"aiohttp-fast-zlib==0.3.0",
"aiohttp-asyncmdnsresolver==0.2.0",
+1 -1
View File
@@ -7,7 +7,7 @@ aiodns==4.0.4
aiogithubapi==26.0.0
aiohttp-asyncmdnsresolver==0.2.0
aiohttp-fast-zlib==0.3.0
aiohttp==3.14.0
aiohttp==3.14.1
aiohttp_cors==0.8.1
aiozoneinfo==0.2.3
annotatedyaml==1.0.2
+9 -9
View File
@@ -1216,7 +1216,7 @@ ha-xthings-cloud==1.0.5
habiticalib==0.4.7
# homeassistant.components.bluetooth
habluetooth==6.8.1
habluetooth==6.8.3
# homeassistant.components.hanna
hanna-cloud==0.0.7
@@ -1269,7 +1269,7 @@ hole==0.9.0
holidays==0.98
# homeassistant.components.frontend
home-assistant-frontend==20260527.4
home-assistant-frontend==20260527.5
# homeassistant.components.conversation
home-assistant-intents==2026.6.1
@@ -1727,7 +1727,7 @@ oemthermostat==1.1.1
ohme==1.9.1
# homeassistant.components.ollama
ollama==0.5.1
ollama==0.6.2
# homeassistant.components.omnilogic
omnilogic==0.4.5
@@ -2084,7 +2084,7 @@ pycsspeechtts==1.0.8
pycync==0.5.0
# homeassistant.components.daikin
pydaikin==2.17.2
pydaikin==2.18.1
# homeassistant.components.danfoss_air
pydanfossair==0.1.0
@@ -2432,7 +2432,7 @@ pyotgw==2.2.3
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.20.4
pyoverkiz[nexity]==2.0.0
# homeassistant.components.palazzetti
pypalazzetti==0.1.20
@@ -3246,10 +3246,10 @@ uasiren==0.0.1
uhooapi==1.2.8
# homeassistant.components.unifiprotect
uiprotect==10.5.0
uiprotect==11.8.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.6.0
ultraheat-api==0.6.1
# homeassistant.components.unifi_discovery
unifi-discovery==1.4.0
@@ -3302,7 +3302,7 @@ viaggiatreno_ha==0.2.4
victron-ble-ha-parser==0.7.0
# homeassistant.components.victron_gx
victron-mqtt==2026.6.1
victron-mqtt==2026.6.1.1
# homeassistant.components.victron_remote_monitoring
victron-vrm==0.1.8
@@ -3430,7 +3430,7 @@ yeelightsunflower==0.0.10
yolink-api==0.6.5
# homeassistant.components.yoto
yoto-api==3.2.0
yoto-api==4.0.2
# homeassistant.components.youless
youless-api==2.2.0
+1 -1
View File
@@ -37,7 +37,7 @@ pytest==9.0.3
requests==2.34.2
requests-mock==1.12.1
respx==0.23.1
syrupy==5.2.0
syrupy==5.3.1
tqdm==4.67.1
types-aiofiles==24.1.0.20250822
types-atomicwrites==1.4.5.1
-1
View File
@@ -192,7 +192,6 @@ EXCEPTIONS = {
"maxcube-api", # https://github.com/uebelack/python-maxcube-api/pull/48
"neurio", # https://github.com/jordanh/neurio-python/pull/13
"nsw-fuel-api-client", # https://github.com/nickw444/nsw-fuel-api-client/pull/14
"ollama", # https://github.com/ollama/ollama-python/pull/526
"pigpio", # https://github.com/joan2937/pigpio/pull/608
"pymitv", # MIT
"pybbox", # https://github.com/HydrelioxGitHub/pybbox/pull/5
@@ -6,6 +6,7 @@ from dataclasses import asdict
from unittest.mock import Mock, patch
import pytest
import voluptuous as vol
from homeassistant.components import stt
from homeassistant.components.assist_pipeline import (
@@ -986,6 +987,28 @@ async def test_ask_question_requires_entity_permission(
)
@pytest.mark.parametrize("sentence", ["no punctuation!", "[malformed template)"])
async def test_ask_question_invalid_sentences(
hass: HomeAssistant,
init_components: ConfigEntry,
entity: MockAssistSatellite,
sentence: str,
) -> None:
"""Test that invalid sentences raise an exception."""
with pytest.raises(vol.Invalid):
await hass.services.async_call(
DOMAIN,
"ask_question",
{
"entity_id": "assist_satellite.test_entity",
"question": "Test",
"answers": [{"id": "answer", "sentences": [sentence]}],
},
blocking=True,
return_response=True,
)
async def test_wake_word_start_keeps_responding(
hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite
) -> None:
@@ -573,6 +573,21 @@ async def test_fails_on_no_sentences(hass: HomeAssistant) -> None:
)
async def test_fails_on_bad_parse(hass: HomeAssistant) -> None:
"""Test that validation fails when sentence is malformed."""
with pytest.raises(vol.Invalid):
await trigger.async_validate_trigger_config(
hass,
[
{
"id": "trigger1",
"platform": "conversation",
"command": ["[test)"],
},
],
)
async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall]) -> None:
"""Test wildcards in trigger sentences."""
assert await async_setup_component(
+36 -14
View File
@@ -4,7 +4,12 @@ from dataclasses import replace
from http import HTTPStatus
from unittest.mock import AsyncMock
from duco_connectivity import ApiInfo, DucoConnectionError
from duco_connectivity import ApiInfo
from duco_connectivity.exceptions import (
DucoConnectionError,
DucoError,
DucoResponseError,
)
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -16,6 +21,29 @@ from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
CLIENT_ERROR_CASES = [
pytest.param(
"async_get_api_info",
DucoConnectionError("Server disconnected"),
id="api-info-connection-error",
),
pytest.param(
"async_get_lan_info",
DucoConnectionError("Server disconnected"),
id="lan-info-connection-error",
),
pytest.param(
"async_get_diagnostics",
DucoResponseError(500, "/info", "bad response"),
id="diagnostics-response-error",
),
pytest.param(
"async_get_write_requests_remaining",
DucoResponseError(500, "/info", "bad response"),
id="write-budget-response-error",
),
]
@pytest.mark.usefixtures("init_integration")
async def test_diagnostics(
@@ -34,25 +62,19 @@ async def test_diagnostics(
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"failing_method",
[
"async_get_api_info",
"async_get_lan_info",
"async_get_diagnostics",
"async_get_write_requests_remaining",
],
("failing_method", "raised_error"),
CLIENT_ERROR_CASES,
)
async def test_diagnostics_connection_error(
async def test_diagnostics_client_error(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
failing_method: str,
raised_error: DucoError,
) -> None:
"""Test that a connection error during diagnostics returns a 500 response."""
getattr(mock_duco_client, failing_method).side_effect = DucoConnectionError(
"Server disconnected"
)
"""Test that client errors during diagnostics return a 500 response."""
getattr(mock_duco_client, failing_method).side_effect = raised_error
assert await async_setup_component(hass, DIAGNOSTICS_DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -70,7 +92,7 @@ async def test_diagnostics_without_optional_software_version(
) -> None:
"""Test that an optional software version is omitted from diagnostics."""
# BoardInfo is a frozen dataclass, so the mock must be updated before
# integration setup the coordinator stores board_info during async_setup.
# integration setup - the coordinator stores board_info during async_setup.
mock_duco_client.async_get_board_info.return_value = replace(
mock_duco_client.async_get_board_info.return_value,
software_version=None,
+123
View File
@@ -9,7 +9,9 @@ from unittest.mock import AsyncMock, Mock, call, patch
from aioesphomeapi import (
APIClient,
APIConnectionError,
APIVersion,
AreaInfo,
BluetoothProxyFeature,
DeviceInfo,
EncryptionPlaintextAPIError,
ExecuteServiceResponse,
@@ -3274,3 +3276,124 @@ async def test_service_registration_response_types(
hass.services.supports_response(DOMAIN, "test_status_service")
== SupportsResponse.NONE
)
def _create_cached_bluetooth_proxy_entry(
hass: HomeAssistant,
hass_storage: dict[str, Any],
bluetooth_proxy_feature_flags: BluetoothProxyFeature,
) -> tuple[MockConfigEntry, DeviceInfo]:
"""Create an entry with cached device info so setup knows the proxy state."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="11:22:33:44:55:aa",
data={
CONF_HOST: "test.local",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_BLUETOOTH_MAC_ADDRESS: "AA:BB:CC:DD:EE:FC",
},
)
entry.add_to_hass(hass)
device_info = DeviceInfo(
name="test",
mac_address="11:22:33:44:55:AA",
bluetooth_mac_address="AA:BB:CC:DD:EE:FC",
bluetooth_proxy_feature_flags=bluetooth_proxy_feature_flags,
)
storage_key = f"{DOMAIN}.{entry.entry_id}"
hass_storage[storage_key] = {
"version": 1,
"minor_version": 1,
"key": storage_key,
"data": {
"device_info": device_info.to_dict(),
"api_version": APIVersion(1, 9).to_dict(),
},
}
return entry, device_info
@pytest.mark.usefixtures("mock_zeroconf")
async def test_bluetooth_proxy_waits_for_scanner_at_startup(
hass: HomeAssistant,
mock_client: APIClient,
hass_storage: dict[str, Any],
) -> None:
"""Test setup waits for a cached bluetooth proxy to register its scanner."""
entry, device_info = _create_cached_bluetooth_proxy_entry(
hass, hass_storage, BluetoothProxyFeature.PASSIVE_SCAN
)
connect_event = asyncio.Event()
reached_connect = asyncio.Event()
async def _block_until_released() -> tuple[DeviceInfo, list[Any], list[Any]]:
reached_connect.set()
await connect_event.wait()
return (device_info, [], [])
mock_client.device_info_and_list_entities = _block_until_released
setup_task = hass.async_create_task(hass.config_entries.async_setup(entry.entry_id))
async with asyncio.timeout(2):
await reached_connect.wait()
# Setup must still be waiting for the scanner to be registered.
assert not setup_task.done()
connect_event.set()
async with asyncio.timeout(2):
assert await setup_task is True
assert entry.runtime_data.first_connect_done.is_set()
@pytest.mark.usefixtures("mock_zeroconf")
async def test_bluetooth_proxy_startup_wait_times_out(
hass: HomeAssistant,
mock_client: APIClient,
hass_storage: dict[str, Any],
) -> None:
"""Test setup finishes if a cached bluetooth proxy never connects."""
entry, device_info = _create_cached_bluetooth_proxy_entry(
hass, hass_storage, BluetoothProxyFeature.PASSIVE_SCAN
)
connect_event = asyncio.Event()
async def _never_returns() -> tuple[DeviceInfo, list[Any], list[Any]]:
await connect_event.wait()
return (device_info, [], [])
mock_client.device_info_and_list_entities = _never_returns
with patch("homeassistant.components.esphome.manager.STARTUP_SCANNER_WAIT", 0.05):
async with asyncio.timeout(2):
assert await hass.config_entries.async_setup(entry.entry_id) is True
connect_event.set()
await hass.async_block_till_done()
@pytest.mark.usefixtures("mock_zeroconf")
async def test_non_bluetooth_device_does_not_wait_at_startup(
hass: HomeAssistant,
mock_client: APIClient,
hass_storage: dict[str, Any],
) -> None:
"""Test setup does not wait for a device that is not a bluetooth proxy."""
entry, device_info = _create_cached_bluetooth_proxy_entry(
hass, hass_storage, BluetoothProxyFeature(0)
)
connect_event = asyncio.Event()
async def _never_returns() -> tuple[DeviceInfo, list[Any], list[Any]]:
await connect_event.wait()
return (device_info, [], [])
mock_client.device_info_and_list_entities = _never_returns
# The connection is blocked, but without proxy flags setup must not wait.
async with asyncio.timeout(2):
assert await hass.config_entries.async_setup(entry.entry_id) is True
connect_event.set()
await hass.async_block_till_done()
@@ -2,7 +2,9 @@
from unittest.mock import patch
from homeassistant.components.gardena_bluetooth.const import DOMAIN
from gardena_bluetooth.parse import ManufacturerData
from homeassistant.components.gardena_bluetooth.const import CONF_PRODUCT_TYPE, DOMAIN
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
@@ -91,10 +93,13 @@ UNSUPPORTED_GROUP_SERVICE_INFO = BluetoothServiceInfo(
def get_config_entry(service_info: BluetoothServiceInfo) -> MockConfigEntry:
"""Construct a config entry for a given discovery."""
mfg_bytes = service_info.manufacturer_data.get(ManufacturerData.company, b"")
product_type = ManufacturerData.decode(mfg_bytes).product_type
return MockConfigEntry(
domain=DOMAIN,
data={CONF_ADDRESS: service_info.address},
data={CONF_ADDRESS: service_info.address, CONF_PRODUCT_TYPE: product_type.name},
unique_id=service_info.address,
minor_version=2,
)
+7 -13
View File
@@ -14,7 +14,7 @@ from gardena_bluetooth.parse import Characteristic, Service
import pytest
from homeassistant.components import bluetooth
from homeassistant.components.gardena_bluetooth import async_get_product_type
from homeassistant.components.gardena_bluetooth import async_get_product
from homeassistant.components.gardena_bluetooth.const import DOMAIN
from homeassistant.components.gardena_bluetooth.coordinator import SCAN_INTERVAL
from homeassistant.core import HomeAssistant
@@ -177,24 +177,18 @@ def enable_all_entities(entity_registry_enabled_by_default: None) -> None:
@pytest.fixture
def get_product_type_event() -> Generator[asyncio.Event]:
"""Track product type data requests with an event."""
def get_product_event() -> Generator[asyncio.Event]:
"""Track product data requests with an event."""
event = asyncio.Event()
async def _get(*args, **kwargs):
event.set()
return await async_get_product_type(*args, **kwargs)
return await async_get_product(*args, **kwargs)
with (
patch(
"homeassistant.components.gardena_bluetooth.async_get_product_type",
wraps=_get,
),
patch(
"homeassistant.components.gardena_bluetooth.config_flow.async_get_product_type",
wraps=_get,
),
with patch(
"homeassistant.components.gardena_bluetooth.async_get_product",
wraps=_get,
):
yield event
@@ -37,17 +37,19 @@
}),
'data': dict({
'address': '00000000-0000-0000-0000-000000000001',
'product_type': 'WATER_COMPUTER',
}),
'description': None,
'description_placeholders': None,
'flow_id': <ANY>,
'handler': 'gardena_bluetooth',
'minor_version': 1,
'minor_version': 2,
'options': dict({
}),
'result': ConfigEntrySnapshot({
'data': dict({
'address': '00000000-0000-0000-0000-000000000001',
'product_type': 'WATER_COMPUTER',
}),
'disabled_by': None,
'discovery_keys': dict({
@@ -61,7 +63,7 @@
}),
'domain': 'gardena_bluetooth',
'entry_id': <ANY>,
'minor_version': 1,
'minor_version': 2,
'options': dict({
}),
'pref_disable_new_entities': False,
@@ -196,24 +198,26 @@
}),
'data': dict({
'address': '00000000-0000-0000-0000-000000000001',
'product_type': 'WATER_COMPUTER',
}),
'description': None,
'description_placeholders': None,
'flow_id': <ANY>,
'handler': 'gardena_bluetooth',
'minor_version': 1,
'minor_version': 2,
'options': dict({
}),
'result': ConfigEntrySnapshot({
'data': dict({
'address': '00000000-0000-0000-0000-000000000001',
'product_type': 'WATER_COMPUTER',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'gardena_bluetooth',
'entry_id': <ANY>,
'minor_version': 1,
'minor_version': 2,
'options': dict({
}),
'pref_disable_new_entities': False,
+61 -12
View File
@@ -16,8 +16,9 @@ import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.gardena_bluetooth import DeviceUnavailable
from homeassistant.components.gardena_bluetooth.const import DOMAIN
from homeassistant.components.gardena_bluetooth.const import CONF_PRODUCT_TYPE, DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.util import utcnow
@@ -95,35 +96,83 @@ async def test_setup(
assert device == snapshot
async def test_setup_delayed_product(
@pytest.mark.usefixtures("constant_advertisements")
async def test_migrate_config_entry_product_type(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_entry: MockConfigEntry,
mock_read_char_raw: dict[str, bytes],
get_product_type_event: asyncio.Event,
snapshot: SnapshotAssertion,
) -> None:
"""Test setup creates expected devices."""
"""Test migration: product type resolved immediately from existing advertisement."""
mock_read_char_raw[Battery.battery_level.uuid] = Battery.battery_level.encode(100)
mock_entry.add_to_hass(hass)
inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO)
legacy_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_ADDRESS: WATER_TIMER_SERVICE_INFO.address},
unique_id=WATER_TIMER_SERVICE_INFO.address,
)
legacy_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(legacy_entry.entry_id) is True
assert legacy_entry.minor_version == 2
assert legacy_entry.data[CONF_PRODUCT_TYPE] == "WATER_COMPUTER"
async def test_migrate_config_entry_product_type_delayed(
hass: HomeAssistant,
mock_read_char_raw: dict[str, bytes],
get_product_event: asyncio.Event,
) -> None:
"""Test migration: product type discovered via active scan after a delay."""
mock_read_char_raw[Battery.battery_level.uuid] = Battery.battery_level.encode(100)
legacy_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_ADDRESS: WATER_TIMER_SERVICE_INFO.address},
unique_id=WATER_TIMER_SERVICE_INFO.address,
)
legacy_entry.add_to_hass(hass)
await hass.async_block_till_done()
get_product_type_event.clear()
get_product_event.clear()
async with asyncio.TaskGroup() as tg:
setup_task = tg.create_task(
hass.config_entries.async_setup(mock_entry.entry_id)
hass.config_entries.async_setup(legacy_entry.entry_id)
)
await get_product_type_event.wait()
assert mock_entry.state is ConfigEntryState.SETUP_IN_PROGRESS
await get_product_event.wait()
assert legacy_entry.state is ConfigEntryState.SETUP_IN_PROGRESS
inject_bluetooth_service_info(hass, MISSING_MANUFACTURER_DATA_SERVICE_INFO)
inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO)
assert await setup_task is True
assert legacy_entry.minor_version == 2
assert legacy_entry.data[CONF_PRODUCT_TYPE] == "WATER_COMPUTER"
@pytest.mark.usefixtures("constant_advertisements")
async def test_migrate_config_entry_product_type_fails(
hass: HomeAssistant,
) -> None:
"""Test migration fails when no advertisement with a known product type is found."""
legacy_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_ADDRESS: WATER_TIMER_SERVICE_INFO.address},
unique_id=WATER_TIMER_SERVICE_INFO.address,
)
legacy_entry.add_to_hass(hass)
await hass.config_entries.async_setup(legacy_entry.entry_id)
await hass.async_block_till_done()
assert legacy_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("constant_advertisements")
async def test_setup_retry(
@@ -1,4 +1,54 @@
# serializer version: 1
# name: test_all_entities[button.kiosker_a98be1ce_clear_blackout-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.kiosker_a98be1ce_clear_blackout',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Clear blackout',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Clear blackout',
'platform': 'kiosker',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'blackout_clear',
'unique_id': 'A98BE1CE-5FE7-4A8D-B2C3-123456789ABC_blackoutClear',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[button.kiosker_a98be1ce_clear_blackout-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Kiosker A98BE1CE Clear blackout',
}),
'context': <ANY>,
'entity_id': 'button.kiosker_a98be1ce_clear_blackout',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[button.kiosker_a98be1ce_clear_cache-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
+1
View File
@@ -54,6 +54,7 @@ async def test_all_entities(
("button.kiosker_a98be1ce_clear_cache", "clear_cache"),
("button.kiosker_a98be1ce_clear_cookies", "clear_cookies"),
("button.kiosker_a98be1ce_dismiss_screensaver", "screensaver_interact"),
("button.kiosker_a98be1ce_clear_blackout", "blackout_clear"),
],
)
async def test_press_button(
@@ -8,7 +8,6 @@ import serialx
from homeassistant import config_entries
from homeassistant.components.landisgyr_heat_meter import DOMAIN
from homeassistant.components.usb import USBDevice
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -22,18 +21,6 @@ API_HEAT_METER_SERVICE = (
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
def mock_serial_port() -> USBDevice:
"""Mock of a serial port."""
return USBDevice(
device="/dev/ttyUSB1234",
vid="162E",
pid="269C",
serial_number="1234",
manufacturer="Virtual serial port",
description="Some serial port",
)
@dataclass
class MockUltraheatRead:
"""Mock of the response from the read method of the Ultraheat API."""
@@ -43,8 +30,8 @@ class MockUltraheatRead:
@patch(API_HEAT_METER_SERVICE)
async def test_manual_entry(mock_heat_meter, hass: HomeAssistant) -> None:
"""Test manual entry."""
async def test_user_flow_success(mock_heat_meter, hass: HomeAssistant) -> None:
"""Test successful user flow."""
mock_heat_meter().read.return_value = MockUltraheatRead("LUGCUH50", "123456789")
@@ -55,14 +42,6 @@ async def test_manual_entry(mock_heat_meter, hass: HomeAssistant) -> None:
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"device": "Enter Manually"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "setup_serial_manual_path"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"device": "/dev/ttyUSB0"}
)
@@ -77,38 +56,10 @@ async def test_manual_entry(mock_heat_meter, hass: HomeAssistant) -> None:
@patch(API_HEAT_METER_SERVICE)
@patch(
"homeassistant.components.landisgyr_heat_meter.config_flow.usb.async_scan_serial_ports",
return_value=[mock_serial_port()],
)
async def test_list_entry(mock_port, mock_heat_meter, hass: HomeAssistant) -> None:
"""Test select from list entry."""
mock_heat_meter().read.return_value = MockUltraheatRead("LUGCUH50", "123456789")
port = mock_serial_port()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"device": port.device}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "LUGCUH50"
assert result["data"] == {
"device": port.device,
"model": "LUGCUH50",
"device_number": "123456789",
}
@patch(API_HEAT_METER_SERVICE)
async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None:
"""Test manual entry fails."""
async def test_user_flow_cannot_connect_oserror(
mock_heat_meter, hass: HomeAssistant
) -> None:
"""Test connection failure due to OSError."""
mock_heat_meter().read.side_effect = OSError("device unavailable")
@@ -119,33 +70,22 @@ async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None:
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"device": "Enter Manually"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "setup_serial_manual_path"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"device": "/dev/ttyUSB0"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "setup_serial_manual_path"
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
@patch(API_HEAT_METER_SERVICE)
@patch(
"homeassistant.components.landisgyr_heat_meter.config_flow.usb.async_scan_serial_ports",
return_value=[mock_serial_port()],
)
async def test_list_entry_fail(mock_port, mock_heat_meter, hass: HomeAssistant) -> None:
"""Test select from list entry fails."""
async def test_user_flow_cannot_connect_serial_exception(
mock_heat_meter, hass: HomeAssistant
) -> None:
"""Test connection failure due to serialx.SerialException."""
mock_heat_meter().read.side_effect = serialx.SerialException("connection failed")
port = mock_serial_port()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -155,24 +95,18 @@ async def test_list_entry_fail(mock_port, mock_heat_meter, hass: HomeAssistant)
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"device": port.device}
result["flow_id"], {"device": "/dev/ttyUSB0"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
@patch(API_HEAT_METER_SERVICE)
@patch(
"homeassistant.components.landisgyr_heat_meter.config_flow.usb.async_scan_serial_ports",
return_value=[mock_serial_port()],
)
async def test_already_configured(
mock_port, mock_heat_meter, hass: HomeAssistant
) -> None:
async def test_already_configured(mock_heat_meter, hass: HomeAssistant) -> None:
"""Test we abort if the Heat Meter is already configured."""
# create and add existing entry
entry_data = {
"device": "/dev/USB0",
"model": "LUGCUH50",
@@ -184,16 +118,14 @@ async def test_already_configured(
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
# run flow and see if it aborts
mock_heat_meter().read.return_value = MockUltraheatRead("LUGCUH50", "123456789")
port = mock_serial_port()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"device": port.device}
result["flow_id"], {"device": "/dev/ttyUSB0"}
)
assert result["type"] is FlowResultType.ABORT
+5 -4
View File
@@ -4,6 +4,7 @@ from typing import Any
from unittest.mock import Mock, patch
from homeassistant.components import onboarding
from homeassistant.components.onboarding import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@@ -21,7 +22,7 @@ async def test_not_setup_views_if_onboarded(
mock_storage(hass_storage, {"done": onboarding.STEPS})
with patch("homeassistant.components.onboarding.views.async_setup") as mock_setup:
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
assert len(mock_setup.mock_calls) == 0
assert onboarding.DOMAIN not in hass.data
@@ -33,7 +34,7 @@ async def test_setup_views_if_not_onboarded(hass: HomeAssistant) -> None:
with patch(
"homeassistant.components.onboarding.views.async_setup",
) as mock_setup:
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
assert len(mock_setup.mock_calls) == 1
assert onboarding.DOMAIN in hass.data
@@ -84,7 +85,7 @@ async def test_having_owner_finishes_user_step(
patch("homeassistant.components.onboarding.views.async_setup") as mock_setup,
patch.object(onboarding, "STEPS", [onboarding.STEP_USER]),
):
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
assert len(mock_setup.mock_calls) == 0
assert onboarding.DOMAIN not in hass.data
@@ -97,5 +98,5 @@ async def test_having_owner_finishes_user_step(
async def test_migration(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None:
"""Test migrating onboarding to new version."""
hass_storage[onboarding.STORAGE_KEY] = {"version": 1, "data": {"done": ["user"]}}
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
assert onboarding.async_is_onboarded(hass)
+25 -25
View File
@@ -11,7 +11,7 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest
from homeassistant.components import onboarding
from homeassistant.components.onboarding import const, views
from homeassistant.components.onboarding import DOMAIN, const, views
from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar
from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component
@@ -110,7 +110,7 @@ async def test_onboarding_progress(
"""Test fetching progress."""
mock_storage(hass_storage, {"done": ["hello"]})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client_no_auth()
@@ -134,7 +134,7 @@ async def test_onboarding_user_already_done(
mock_storage(hass_storage, {"done": [views.STEP_USER]})
with patch.object(onboarding, "STEPS", ["hello", "world"]):
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client_no_auth()
@@ -165,7 +165,7 @@ async def test_onboarding_user(
area_registry.async_create("Living Room")
assert await async_setup_component(hass, "person", {})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
cur_users = len(await hass.auth.async_get_users())
@@ -228,7 +228,7 @@ async def test_onboarding_user_invalid_name(
"""Test not providing name."""
mock_storage(hass_storage, {"done": []})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client_no_auth()
@@ -254,7 +254,7 @@ async def test_onboarding_user_race(
"""Test race condition on creating new user."""
mock_storage(hass_storage, {"done": ["hello"]})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client_no_auth()
@@ -294,7 +294,7 @@ async def test_onboarding_integration(
"""Test finishing integration step."""
mock_storage(hass_storage, {"done": [const.STEP_USER]})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -338,7 +338,7 @@ async def test_onboarding_integration_missing_credential(
"""Test that we fail integration step if user is missing credentials."""
mock_storage(hass_storage, {"done": [const.STEP_USER]})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
@@ -362,7 +362,7 @@ async def test_onboarding_integration_invalid_redirect_uri(
"""Test finishing integration step."""
mock_storage(hass_storage, {"done": [const.STEP_USER]})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -396,7 +396,7 @@ async def test_onboarding_integration_requires_auth(
"""Test finishing integration step."""
mock_storage(hass_storage, {"done": [const.STEP_USER]})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client_no_auth()
@@ -417,7 +417,7 @@ async def test_onboarding_core_sets_up_met(
"""Test finishing the core step."""
mock_storage(hass_storage, {"done": [const.STEP_USER]})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -438,7 +438,7 @@ async def test_onboarding_core_sets_up_shopping_list(
"""Test finishing the core step set up the shopping list."""
mock_storage(hass_storage, {"done": [const.STEP_USER]})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -459,7 +459,7 @@ async def test_onboarding_core_sets_up_google_translate(
"""Test finishing the core step sets up google translate."""
mock_storage(hass_storage, {"done": [const.STEP_USER]})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -480,7 +480,7 @@ async def test_onboarding_core_sets_up_radio_browser(
"""Test finishing the core step set up the radio browser."""
mock_storage(hass_storage, {"done": [const.STEP_USER]})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -502,7 +502,7 @@ async def test_onboarding_core_no_rpi_power(
"""Test that the core step do not set up rpi_power on non RPi."""
mock_storage(hass_storage, {"done": [const.STEP_USER]})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -527,7 +527,7 @@ async def test_onboarding_core_ensures_analytics_loaded(
mock_storage(hass_storage, {"done": [const.STEP_USER]})
assert "analytics" not in hass.config.components
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -548,7 +548,7 @@ async def test_onboarding_analytics(
"""Test finishing analytics step."""
mock_storage(hass_storage, {"done": [const.STEP_USER]})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -571,7 +571,7 @@ async def test_onboarding_installation_type(
"""Test returning installation type during onboarding."""
mock_storage(hass_storage, {"done": []})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -605,7 +605,7 @@ async def test_onboarding_view_after_done(
"""Test raising after onboarding."""
mock_storage(hass_storage, {"done": [const.STEP_USER]})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -623,7 +623,7 @@ async def test_complete_onboarding(
onboarding.async_add_listener(hass, listener_1)
listener_1.assert_not_called()
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
listener_2 = Mock()
@@ -693,7 +693,7 @@ async def test_wait_integration(
"""Test we can get wait for an integration to load."""
mock_storage(hass_storage, {"done": []})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -712,7 +712,7 @@ async def test_wait_integration_startup(
"""Test we can get wait for an integration to load during startup."""
mock_storage(hass_storage, {"done": []})
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_client()
@@ -767,7 +767,7 @@ async def test_not_setup_platform_if_onboarded(
assert await async_setup_component(hass, "test", {})
await hass.async_block_till_done()
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert len(platform_mock.async_setup_views.mock_calls) == 0
@@ -782,7 +782,7 @@ async def test_setup_platform_if_not_onboarded(
assert await async_setup_component(hass, "test", {})
await hass.async_block_till_done()
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
platform_mock.async_setup_views.assert_awaited_once_with(hass, {"done": []})
@@ -805,7 +805,7 @@ async def test_bad_platform(
assert await async_setup_component(hass, "test", {})
await hass.async_block_till_done()
assert await async_setup_component(hass, "onboarding", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert platform_mock.mock_calls == []
@@ -125,7 +125,7 @@ async def mock_init_component(
with patch(
"openai.resources.models.AsyncModels.list",
):
assert await async_setup_component(hass, "openai_conversation", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
@@ -284,7 +284,7 @@ async def test_init_error(
"openai.resources.models.AsyncModels.list",
side_effect=side_effect,
):
assert await async_setup_component(hass, "openai_conversation", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert error in caplog.text
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
@@ -305,7 +305,7 @@ async def test_init_auth_error(
message="",
),
):
assert await async_setup_component(hass, "openai_conversation", {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
@@ -13,13 +13,7 @@ from homeassistant.components.opentherm_gw.const import (
CONF_TEMPORARY_OVRD_MODE,
DOMAIN,
)
from homeassistant.const import (
CONF_DEVICE,
CONF_ID,
CONF_NAME,
PRECISION_HALVES,
PRECISION_TENTHS,
)
from homeassistant.const import CONF_DEVICE, CONF_ID, PRECISION_HALVES, PRECISION_TENTHS
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -37,14 +31,13 @@ async def test_form_user(hass: HomeAssistant, mock_pyotgw: MagicMock) -> None:
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"}
result["flow_id"], {CONF_DEVICE: "/dev/ttyUSB0", CONF_ID: "test_entry_1"}
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Test Entry 1"
assert result2["title"] == "OpenTherm Gateway"
assert result2["data"] == {
CONF_NAME: "Test Entry 1",
CONF_DEVICE: "/dev/ttyUSB0",
CONF_ID: "test_entry_1",
}
@@ -68,18 +61,18 @@ async def test_form_duplicate_entries(
)
result1 = await hass.config_entries.flow.async_configure(
flow1["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"}
flow1["flow_id"], {CONF_DEVICE: "/dev/ttyUSB0", CONF_ID: "test_entry_1"}
)
assert result1["type"] is FlowResultType.CREATE_ENTRY
result2 = await hass.config_entries.flow.async_configure(
flow2["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB1"}
flow2["flow_id"], {CONF_DEVICE: "/dev/ttyUSB1", CONF_ID: "test_entry_1"}
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "id_exists"}
result3 = await hass.config_entries.flow.async_configure(
flow3["flow_id"], {CONF_NAME: "Test Entry 2", CONF_DEVICE: "/dev/ttyUSB0"}
flow3["flow_id"], {CONF_DEVICE: "/dev/ttyUSB0", CONF_ID: "test_entry_2"}
)
assert result3["type"] is FlowResultType.FORM
assert result3["errors"] == {"base": "already_configured"}
@@ -101,7 +94,7 @@ async def test_form_connection_timeout(
result = await hass.config_entries.flow.async_configure(
flow["flow_id"],
{CONF_NAME: "Test Entry 1", CONF_DEVICE: "socket://192.0.2.254:1234"},
{CONF_DEVICE: "socket://192.0.2.254:1234", CONF_ID: "test_entry_1"},
)
assert result["type"] is FlowResultType.FORM
@@ -122,7 +115,7 @@ async def test_form_connection_error(
mock_pyotgw.return_value.connect.side_effect = SerialException
result = await hass.config_entries.flow.async_configure(
flow["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"}
flow["flow_id"], {CONF_DEVICE: "/dev/ttyUSB0", CONF_ID: "test_entry_1"}
)
assert result["type"] is FlowResultType.FORM
@@ -137,7 +130,6 @@ async def test_options_form(hass: HomeAssistant, mock_pyotgw: MagicMock) -> None
domain=DOMAIN,
title="Mock Gateway",
data={
CONF_NAME: "Mock Gateway",
CONF_DEVICE: "/dev/null",
CONF_ID: "mock_gateway",
},

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