mirror of
https://github.com/home-assistant/core.git
synced 2026-06-11 11:41:42 +02:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9688b7fb2 | |||
| c3d6ad029f | |||
| 630f442042 | |||
| 62419789b9 | |||
| f2f5a55165 | |||
| c6a57bc81a | |||
| 4171f566e9 | |||
| 0ac9834d93 | |||
| d7673a08c8 | |||
| 35cb7c6147 | |||
| d098622021 | |||
| f88e757e51 | |||
| 653e6a43fa | |||
| 1462e7a181 | |||
| e34d821f7d | |||
| 02b4442a6c | |||
| 809571443c | |||
| d59398e0ea | |||
| 9c9695d0ba | |||
| 3fbdbb12e2 | |||
| a29f2907f7 | |||
| 83534f286e | |||
| 4fe93f9c64 | |||
| fd8789d599 | |||
| d0b34dfe92 | |||
| 390766ba3a | |||
| 3a46d1088b | |||
| 26d56b8218 | |||
| 6ee819cdc3 | |||
| 1cf8fe4d0b | |||
| c5f93cdd72 | |||
| 42136f1464 | |||
| 34f3452280 | |||
| ba9248cc94 | |||
| 018cd1333e | |||
| c72d723e0d | |||
| b9b36d9e12 |
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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*",
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+6
-2
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 ##
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
Generated
+9
-9
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user