Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ff21b70f2 | |||
| d01903cd59 | |||
| 0d17f3062c | |||
| c706c83337 | |||
| 20f30f76d1 | |||
| 966b89cc14 | |||
| 707742f720 | |||
| f58e0e5234 | |||
| 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 | |||
| b6f38c3cbb | |||
| a0162d2ff0 | |||
| b6f018873b | |||
| 43e21322ea | |||
| 86ccc59a5f | |||
| 2fce2547c7 | |||
| 6b40278d08 | |||
| 05bb8b94fa | |||
| 5ac3a8cdde | |||
| 266fccf0cf | |||
| a1e6a6f9a2 |
@@ -1,6 +1,10 @@
|
||||
"""The AirVisual Pro integration."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -25,6 +29,12 @@ class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
|
||||
"""Return device registry information for this entity."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.coordinator.data["serial_number"])},
|
||||
connections={
|
||||
(
|
||||
CONNECTION_NETWORK_MAC,
|
||||
format_mac(self.coordinator.data["status"]["mac_address"]),
|
||||
)
|
||||
},
|
||||
manufacturer="AirVisual",
|
||||
model=self.coordinator.data["status"]["model"],
|
||||
name=self.coordinator.data["settings"]["node_name"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -6,6 +6,7 @@ These APIs are the only documented way to interact with the bluetooth integratio
|
||||
import asyncio
|
||||
from asyncio import Future
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import ExitStack
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from bleak import BleakScanner
|
||||
@@ -178,15 +179,20 @@ async def async_process_advertisements(
|
||||
if not done.done() and callback(service_info):
|
||||
done.set_result(service_info)
|
||||
|
||||
unload = _get_manager(hass).async_register_callback(
|
||||
_async_discovered_device, match_dict, mode, scan_duration=timeout
|
||||
)
|
||||
manager = _get_manager(hass)
|
||||
|
||||
with ExitStack() as stack:
|
||||
unload = manager.async_register_callback(
|
||||
_async_discovered_device, match_dict, mode
|
||||
)
|
||||
stack.callback(unload)
|
||||
|
||||
if mode == BluetoothScanningMode.ACTIVE:
|
||||
task = hass.async_create_task(manager.async_request_active_scan(timeout))
|
||||
stack.callback(task.cancel)
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
return await done
|
||||
finally:
|
||||
unload()
|
||||
|
||||
|
||||
@hass_callback
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["data-grand-lyon-ha==0.7.0"]
|
||||
"requirements": ["data-grand-lyon-ha==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -13,7 +13,6 @@ from dsmr_parser.clients.rfxtrx_protocol import (
|
||||
from dsmr_parser.objects import DSMRObject
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
@@ -23,6 +22,7 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.selector import SerialPortSelector
|
||||
|
||||
from .const import (
|
||||
CONF_DSMR_VERSION,
|
||||
@@ -37,8 +37,6 @@ from .const import (
|
||||
RFXTRX_DSMR_PROTOCOL,
|
||||
)
|
||||
|
||||
CONF_MANUAL_PATH = "Enter Manually"
|
||||
|
||||
|
||||
class DSMRConnection:
|
||||
"""Test the connection to DSMR and receive telegram to read serial ids."""
|
||||
@@ -165,8 +163,6 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
_dsmr_version: str | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
@@ -222,34 +218,13 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Step when setting up serial configuration."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
user_selection = user_input[CONF_PORT]
|
||||
if user_selection == CONF_MANUAL_PATH:
|
||||
self._dsmr_version = user_input[CONF_DSMR_VERSION]
|
||||
return await self.async_step_setup_serial_manual_path()
|
||||
|
||||
dev_path = user_selection
|
||||
|
||||
validate_data = {
|
||||
CONF_PORT: dev_path,
|
||||
CONF_DSMR_VERSION: user_input[CONF_DSMR_VERSION],
|
||||
}
|
||||
|
||||
data = await self.async_validate_dsmr(validate_data, errors)
|
||||
data = await self.async_validate_dsmr(user_input, errors)
|
||||
if not errors:
|
||||
return self.async_create_entry(title=data[CONF_PORT], data=data)
|
||||
|
||||
ports = await usb.async_scan_serial_ports(self.hass)
|
||||
list_of_ports = {
|
||||
port.device: f"{port.device} - {port.description or 'n/a'}"
|
||||
f", s/n: {port.serial_number or 'n/a'}"
|
||||
+ (f" - {port.manufacturer}" if port.manufacturer else "")
|
||||
for port in ports
|
||||
}
|
||||
list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PORT): vol.In(list_of_ports),
|
||||
vol.Required(CONF_PORT): SerialPortSelector(),
|
||||
vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS),
|
||||
}
|
||||
)
|
||||
@@ -259,27 +234,6 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_setup_serial_manual_path(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select path manually."""
|
||||
if user_input is not None:
|
||||
validate_data = {
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_DSMR_VERSION: self._dsmr_version,
|
||||
}
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
data = await self.async_validate_dsmr(validate_data, errors)
|
||||
if not errors:
|
||||
return self.async_create_entry(title=data[CONF_PORT], data=data)
|
||||
|
||||
schema = vol.Schema({vol.Required(CONF_PORT): str})
|
||||
return self.async_show_form(
|
||||
step_id="setup_serial_manual_path",
|
||||
data_schema=schema,
|
||||
)
|
||||
|
||||
async def async_validate_dsmr(
|
||||
self, input_data: dict[str, Any], errors: dict[str, str]
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -26,12 +26,6 @@
|
||||
},
|
||||
"title": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"setup_serial_manual_path": {
|
||||
"data": {
|
||||
"port": "[%key:common::config_flow::data::usb_path%]"
|
||||
},
|
||||
"title": "[%key:common::config_flow::data::path%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"type": "Connection type"
|
||||
|
||||
@@ -7,3 +7,4 @@ from homeassistant.const import Platform
|
||||
DOMAIN = "duco"
|
||||
PLATFORMS = [Platform.FAN, Platform.SENSOR]
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
BOX_NODE_ID = 1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import BOX_NODE_ID, DOMAIN
|
||||
from .coordinator import DucoConfigEntry, DucoCoordinator
|
||||
from .entity import DucoEntity
|
||||
|
||||
@@ -158,7 +158,13 @@ async def async_setup_entry(
|
||||
# The firmware removes deregistered RF/wired nodes automatically.
|
||||
# BSRH box sensors that are physically unplugged from the PCB are
|
||||
# not deregistered by the firmware and will never appear here as stale.
|
||||
stale_node_ids = known_nodes - coordinator.data.nodes.keys()
|
||||
# The BOX node can transiently disappear from the API response, so keep
|
||||
# node 1 to avoid removing the main controller device.
|
||||
stale_node_ids = {
|
||||
node_id
|
||||
for node_id in known_nodes - coordinator.data.nodes.keys()
|
||||
if node_id != BOX_NODE_ID
|
||||
}
|
||||
if stale_node_ids:
|
||||
device_reg = dr.async_get(hass)
|
||||
mac = entry.unique_id
|
||||
|
||||
@@ -9,7 +9,6 @@ Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor
|
||||
Wetterwarnungen (Stufe 1)
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
@@ -17,6 +16,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
ADVANCE_WARNING_SENSOR,
|
||||
@@ -100,7 +100,7 @@ class DwdWeatherWarningsSensor(
|
||||
if warnings is None:
|
||||
return []
|
||||
|
||||
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
now = dt_util.utcnow()
|
||||
return [warning for warning in warnings if warning[API_ATTR_WARNING_END] > now]
|
||||
|
||||
@property
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/forecast_solar",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["forecast-solar==5.0.0"]
|
||||
"requirements": ["forecast-solar==5.0.1"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"""The Gardena Bluetooth integration."""
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from gardena_bluetooth.client import CachedConnection, Client
|
||||
from gardena_bluetooth.const import ProductType
|
||||
from gardena_bluetooth.scan import async_get_manufacturer_data
|
||||
from gardena_bluetooth.const import ScanService
|
||||
from gardena_bluetooth.parse import ManufacturerData, ProductType
|
||||
from habluetooth import BluetoothServiceInfoBleak
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
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,
|
||||
@@ -30,6 +33,79 @@ PLATFORMS: list[Platform] = [
|
||||
]
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
DISCONNECT_DELAY = 5
|
||||
PRODUCTS_SCAN_TIMEOUT = 10
|
||||
PRODUCT_TYPE_TIMEOUT = 30
|
||||
|
||||
|
||||
async def async_get_product(hass: HomeAssistant, address: str) -> ManufacturerData:
|
||||
"""Get manufacturer data for the given address via active scan."""
|
||||
|
||||
data = ManufacturerData()
|
||||
|
||||
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
|
||||
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
|
||||
if info.device.address != address:
|
||||
return False
|
||||
|
||||
data.update(info.manufacturer_data.get(ManufacturerData.company, b""))
|
||||
return data.product_type is not ProductType.UNKNOWN
|
||||
|
||||
with suppress(TimeoutError):
|
||||
await bluetooth.async_process_advertisements(
|
||||
hass,
|
||||
_data_callback,
|
||||
bluetooth.BluetoothCallbackMatcher(
|
||||
address=address, manufacturer_id=ManufacturerData.company
|
||||
),
|
||||
mode=bluetooth.BluetoothScanningMode.ACTIVE,
|
||||
timeout=PRODUCT_TYPE_TIMEOUT,
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def async_get_products(hass: HomeAssistant) -> dict[str, ManufacturerData]:
|
||||
"""Get all products that are currently advertising."""
|
||||
products: dict[str, ManufacturerData] = {}
|
||||
|
||||
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
|
||||
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
|
||||
if ScanService not in info.service_uuids:
|
||||
return False
|
||||
|
||||
raw = info.manufacturer_data.get(ManufacturerData.company, b"")
|
||||
if (data := products.get(info.device.address)) is None:
|
||||
data = ManufacturerData()
|
||||
products[info.device.address] = data
|
||||
|
||||
data.update(raw)
|
||||
return False
|
||||
|
||||
with suppress(TimeoutError):
|
||||
await bluetooth.async_process_advertisements(
|
||||
hass,
|
||||
_data_callback,
|
||||
bluetooth.BluetoothCallbackMatcher(
|
||||
manufacturer_id=ManufacturerData.company
|
||||
),
|
||||
mode=bluetooth.BluetoothScanningMode.ACTIVE,
|
||||
timeout=PRODUCTS_SCAN_TIMEOUT,
|
||||
)
|
||||
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:
|
||||
@@ -51,16 +127,11 @@ async def async_setup_entry(
|
||||
) -> bool:
|
||||
"""Set up Gardena Bluetooth from a config entry."""
|
||||
|
||||
if entry.minor_version < 2:
|
||||
entry = await async_migrate_product_type(hass, entry)
|
||||
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
|
||||
try:
|
||||
mfg_data = await async_get_manufacturer_data({address})
|
||||
except TimeoutError as exc:
|
||||
raise ConfigEntryNotReady("Unable to find product type") from exc
|
||||
|
||||
product_type = mfg_data[address].product_type
|
||||
if product_type is ProductType.UNKNOWN:
|
||||
raise ConfigEntryNotReady("Unable to find product type")
|
||||
product_type = ProductType[entry.data[CONF_PRODUCT_TYPE]]
|
||||
|
||||
client = Client(get_connection(hass, address), product_type)
|
||||
|
||||
|
||||
@@ -4,22 +4,18 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from gardena_bluetooth.client import Client
|
||||
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation, ScanService
|
||||
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure
|
||||
from gardena_bluetooth.parse import ManufacturerData, ProductType
|
||||
from gardena_bluetooth.scan import async_get_manufacturer_data
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfo,
|
||||
async_discovered_service_info,
|
||||
)
|
||||
from homeassistant.components.bluetooth import BluetoothServiceInfo
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
|
||||
from . import 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,26 +29,16 @@ _SUPPORTED_PRODUCT_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
def _is_supported(discovery_info: BluetoothServiceInfo):
|
||||
"""Check if device is supported."""
|
||||
if ScanService not in discovery_info.service_uuids:
|
||||
return False
|
||||
|
||||
if discovery_info.manufacturer_data.get(ManufacturerData.company) is None:
|
||||
_LOGGER.debug("Missing manufacturer data: %s", discovery_info)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
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."""
|
||||
@@ -68,20 +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)
|
||||
data = await async_get_manufacturer_data({discovery_info.address})
|
||||
product_type = data[discovery_info.address].product_type
|
||||
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()
|
||||
@@ -91,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()
|
||||
@@ -117,31 +106,25 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_confirm()
|
||||
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
candidates = set()
|
||||
for discovery_info in async_discovered_service_info(self.hass):
|
||||
address = discovery_info.address
|
||||
if address in current_addresses or not _is_supported(discovery_info):
|
||||
continue
|
||||
candidates.add(address)
|
||||
|
||||
data = await async_get_manufacturer_data(candidates)
|
||||
for address, mfg_data in data.items():
|
||||
if mfg_data.product_type not in _SUPPORTED_PRODUCT_TYPES:
|
||||
continue
|
||||
self.devices[address] = PRODUCT_NAMES[mfg_data.product_type]
|
||||
current = self._async_current_ids(include_ignore=False)
|
||||
self.devices = await async_get_products(self.hass)
|
||||
|
||||
# Keep selection sorted by address to ensure stable tests
|
||||
self.devices = dict(sorted(self.devices.items(), key=lambda x: x[0]))
|
||||
devices = {
|
||||
address: PRODUCT_NAMES[data.product_type]
|
||||
for address in sorted(self.devices)
|
||||
if address not in current
|
||||
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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -735,6 +735,7 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity):
|
||||
value = self.data.value
|
||||
|
||||
if isinstance(value, time):
|
||||
# pylint: disable-next=home-assistant-enforce-now
|
||||
local_now = datetime.now(
|
||||
tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
|
||||
)
|
||||
@@ -847,6 +848,7 @@ class ThinQEnergySensorEntity(ThinQEntity, SensorEntity):
|
||||
|
||||
async def _async_update_and_schedule(self) -> None:
|
||||
"""Update the state of the sensor."""
|
||||
# pylint: disable-next=home-assistant-enforce-now
|
||||
local_now = datetime.now(
|
||||
dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
|
||||
)
|
||||
|
||||
@@ -264,9 +264,9 @@ class MetOfficeWeather(
|
||||
self.forecast_coordinators["daily"],
|
||||
)
|
||||
timesteps = coordinator.data.timesteps
|
||||
start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
start_datetime = datetime.now( # pylint: disable=home-assistant-enforce-now
|
||||
tz=timesteps[0]["time"].tzinfo
|
||||
).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return [
|
||||
_build_daily_forecast_data(timestep)
|
||||
for timestep in timesteps
|
||||
@@ -282,9 +282,9 @@ class MetOfficeWeather(
|
||||
)
|
||||
|
||||
timesteps = coordinator.data.timesteps
|
||||
start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace(
|
||||
minute=0, second=0, microsecond=0
|
||||
)
|
||||
start_datetime = datetime.now( # pylint: disable=home-assistant-enforce-now
|
||||
tz=timesteps[0]["time"].tzinfo
|
||||
).replace(minute=0, second=0, microsecond=0)
|
||||
return [
|
||||
_build_hourly_forecast_data(timestep)
|
||||
for timestep in timesteps
|
||||
@@ -299,9 +299,9 @@ class MetOfficeWeather(
|
||||
self.forecast_coordinators["twice_daily"],
|
||||
)
|
||||
timesteps = coordinator.data.timesteps
|
||||
start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
start_datetime = datetime.now( # pylint: disable=home-assistant-enforce-now
|
||||
tz=timesteps[0]["time"].tzinfo
|
||||
).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return [
|
||||
_build_twice_daily_forecast_data(timestep)
|
||||
for timestep in timesteps
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) ->
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
# Start websocket listener for push updates
|
||||
coordinator.start_websocket()
|
||||
await coordinator.async_start_websocket()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@ class OpenEVSEDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Handle websocket data update."""
|
||||
self.async_set_updated_data(None)
|
||||
|
||||
def start_websocket(self) -> None:
|
||||
async def async_start_websocket(self) -> None:
|
||||
"""Start the websocket listener."""
|
||||
self.charger.ws_start()
|
||||
await self.charger.ws_start()
|
||||
|
||||
async def async_stop_websocket(self) -> None:
|
||||
"""Stop the websocket listener."""
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["openevsehttp"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-openevse-http==0.3.4"],
|
||||
"requirements": ["python-openevse-http==1.0.1"],
|
||||
"zeroconf": ["_openevse._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -43,10 +43,10 @@ NUMBER_TYPES: tuple[OpenEVSENumberDescription, ...] = (
|
||||
OpenEVSENumberDescription(
|
||||
key="charge_rate",
|
||||
translation_key="charge_rate",
|
||||
value_fn=lambda ev: ev.max_current_soft,
|
||||
min_value_fn=lambda ev: ev.min_amps,
|
||||
max_value_fn=lambda ev: ev.max_amps,
|
||||
set_value_fn=lambda ev, value: ev.set_current(value),
|
||||
value_fn=lambda ev: ev.max_current_soft or 0,
|
||||
min_value_fn=lambda ev: ev.min_amps or 0,
|
||||
max_value_fn=lambda ev: ev.max_amps or 0,
|
||||
set_value_fn=lambda ev, value: ev.set_current(int(value)),
|
||||
native_step=1.0,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
|
||||
@@ -75,7 +75,7 @@ SENSOR_TYPES: tuple[OpenEVSESensorDescription, ...] = (
|
||||
"1": "level_1",
|
||||
"2": "level_2",
|
||||
"a": "automatic",
|
||||
}.get(ev.service_level.lower()),
|
||||
}.get(str(ev.service_level).lower()),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["opower==0.18.2"]
|
||||
"requirements": ["opower==0.18.3"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["plugwise"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["plugwise==1.11.3"],
|
||||
"requirements": ["plugwise==1.11.4"],
|
||||
"zeroconf": ["_plugwise._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -95,7 +95,9 @@ class PowerfoxReportDataUpdateCoordinator(PowerfoxBaseCoordinator[DeviceReport])
|
||||
|
||||
async def _async_fetch_data(self) -> DeviceReport:
|
||||
"""Fetch report data from the Powerfox API."""
|
||||
local_now = datetime.now(tz=dt_util.get_time_zone(self.hass.config.time_zone))
|
||||
local_now = datetime.now( # pylint: disable=home-assistant-enforce-now
|
||||
tz=dt_util.get_time_zone(self.hass.config.time_zone)
|
||||
)
|
||||
return await self.client.report(
|
||||
device_id=self.device.id,
|
||||
year=local_now.year,
|
||||
|
||||
@@ -141,11 +141,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
|
||||
**get_device_info(device),
|
||||
)
|
||||
|
||||
enabled_devices = [
|
||||
device for device in devices if not _is_device_disabled(device_registry, device)
|
||||
]
|
||||
enabled_devices = []
|
||||
disabled_devices = []
|
||||
for device in devices:
|
||||
if _is_device_disabled(device_registry, device):
|
||||
disabled_devices.append(device)
|
||||
else:
|
||||
enabled_devices.append(device)
|
||||
_LOGGER.debug("%d of %d devices are enabled", len(enabled_devices), len(devices))
|
||||
|
||||
# Close connections for disabled devices to prevent their background
|
||||
# reconnect loops from triggering MQTT session restarts that would
|
||||
# disrupt coordinator setup for the enabled devices.
|
||||
if disabled_devices:
|
||||
close_results = await asyncio.gather(
|
||||
*[device.close() for device in disabled_devices],
|
||||
return_exceptions=True,
|
||||
)
|
||||
for device, close_result in zip(disabled_devices, close_results, strict=True):
|
||||
if isinstance(close_result, Exception):
|
||||
_LOGGER.debug(
|
||||
"Failed to close disabled Roborock device %s: %s",
|
||||
device.duid,
|
||||
close_result,
|
||||
)
|
||||
|
||||
coordinators = await asyncio.gather(
|
||||
*build_setup_functions(hass, entry, enabled_devices, user_data),
|
||||
return_exceptions=True,
|
||||
|
||||
@@ -4,6 +4,7 @@ from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from roborock.data import UserData
|
||||
from roborock.exceptions import (
|
||||
@@ -31,6 +32,9 @@ from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
@@ -38,6 +42,7 @@ from . import RoborockConfigEntry
|
||||
from .const import (
|
||||
CONF_BASE_URL,
|
||||
CONF_ENTRY_CODE,
|
||||
CONF_ROBOROCK_SERVER_URL,
|
||||
CONF_SHOW_BACKGROUND,
|
||||
CONF_SHOW_ROOMS,
|
||||
CONF_SHOW_WALLS,
|
||||
@@ -45,6 +50,8 @@ from .const import (
|
||||
DEFAULT_DRAWABLES,
|
||||
DOMAIN,
|
||||
DRAWABLES,
|
||||
REGION_AUTO,
|
||||
REGION_CUSTOM,
|
||||
REGION_OPTIONS,
|
||||
)
|
||||
|
||||
@@ -73,8 +80,10 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
region = user_input[CONF_REGION]
|
||||
self._username = username
|
||||
_LOGGER.debug("Requesting code for Roborock account")
|
||||
if region == REGION_CUSTOM:
|
||||
return await self.async_step_custom_url()
|
||||
base_url = None
|
||||
if region != "auto":
|
||||
if region != REGION_AUTO:
|
||||
base_url = f"https://{region}iot.roborock.com"
|
||||
self._client = RoborockApiClient(
|
||||
username,
|
||||
@@ -90,7 +99,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_REGION, default="auto"): SelectSelector(
|
||||
vol.Required(CONF_REGION, default=REGION_AUTO): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=REGION_OPTIONS,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
@@ -102,6 +111,44 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_custom_url(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle custom server URL entry."""
|
||||
errors: dict[str, str] = {}
|
||||
assert self._username
|
||||
if user_input is not None:
|
||||
url = user_input[CONF_ROBOROCK_SERVER_URL].strip()
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
errors[CONF_ROBOROCK_SERVER_URL] = "invalid_url_format"
|
||||
else:
|
||||
self._client = RoborockApiClient(
|
||||
self._username,
|
||||
base_url=url,
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
errors = await self._request_code()
|
||||
if not errors:
|
||||
return await self.async_step_code()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="custom_url",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_ROBOROCK_SERVER_URL,
|
||||
default=(
|
||||
user_input[CONF_ROBOROCK_SERVER_URL]
|
||||
if user_input is not None
|
||||
else "https://usiot.roborock.com"
|
||||
),
|
||||
): TextSelector(TextSelectorConfig(type=TextSelectorType.URL)),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _request_code(self) -> dict:
|
||||
assert self._client
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
@@ -13,7 +13,21 @@ CONF_USER_DATA = "user_data"
|
||||
CONF_SHOW_BACKGROUND = "show_background"
|
||||
CONF_SHOW_WALLS = "show_walls"
|
||||
CONF_SHOW_ROOMS = "show_rooms"
|
||||
REGION_OPTIONS = ["auto", "us", "eu", "ru", "cn"]
|
||||
CONF_ROBOROCK_SERVER_URL = "roborock_server_url"
|
||||
REGION_AUTO = "auto"
|
||||
REGION_CUSTOM = "custom"
|
||||
REGION_US = "us"
|
||||
REGION_EU = "eu"
|
||||
REGION_RU = "ru"
|
||||
REGION_CN = "cn"
|
||||
REGION_OPTIONS = [
|
||||
REGION_AUTO,
|
||||
REGION_US,
|
||||
REGION_EU,
|
||||
REGION_RU,
|
||||
REGION_CN,
|
||||
REGION_CUSTOM,
|
||||
]
|
||||
|
||||
# Option Flow steps
|
||||
DRAWABLES = "drawables"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"invalid_email": "There is no account associated with the email you entered, please try again.",
|
||||
"invalid_email_format": "There is an issue with the formatting of your email - please try again.",
|
||||
"invalid_email_or_region": "Either there is no account associated with the email you entered, or there is no account in the selected region.",
|
||||
"invalid_url_format": "The URL must start with http:// or https:// and include a valid host.",
|
||||
"too_frequent_code_requests": "You have attempted to request too many codes. Try again later.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"unknown_roborock": "There was an unknown Roborock exception - please check your logs.",
|
||||
@@ -25,6 +26,15 @@
|
||||
},
|
||||
"description": "Type the verification code sent to your email"
|
||||
},
|
||||
"custom_url": {
|
||||
"data": {
|
||||
"roborock_server_url": "Roborock Server URL"
|
||||
},
|
||||
"data_description": {
|
||||
"roborock_server_url": "The URL of the Roborock server."
|
||||
},
|
||||
"description": "Enter the Roborock server URL to connect to."
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "The Roborock integration needs to re-authenticate your account",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
@@ -764,6 +774,7 @@
|
||||
"options": {
|
||||
"auto": "Auto",
|
||||
"cn": "CN",
|
||||
"custom": "Manual",
|
||||
"eu": "EU",
|
||||
"ru": "RU",
|
||||
"us": "US"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -74,9 +74,9 @@ def _utc_minutes_to_time(utc_minutes: int, timezone: tzinfo) -> time:
|
||||
|
||||
def _time_to_utc_minutes(t: time, timezone: tzinfo) -> int:
|
||||
try:
|
||||
zoned_time = datetime.now(timezone).replace(
|
||||
hour=t.hour, minute=t.minute, second=0, microsecond=0
|
||||
)
|
||||
zoned_time = datetime.now( # pylint: disable=home-assistant-enforce-now
|
||||
timezone
|
||||
).replace(hour=t.hour, minute=t.minute, second=0, microsecond=0)
|
||||
except ValueError as exc:
|
||||
raise HomeAssistantError from exc
|
||||
utc_time = zoned_time.astimezone(UTC).time()
|
||||
|
||||
@@ -155,7 +155,9 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
# Tado resets somewhere between 12:00 and 13:00, Berlin time
|
||||
# So let's pretend we're in Berlin...
|
||||
reset_time = datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
reset_time = datetime.now( # pylint: disable=home-assistant-enforce-now
|
||||
ZoneInfo("Europe/Berlin")
|
||||
)
|
||||
|
||||
today_reset = datetime.combine(
|
||||
reset_time.date(),
|
||||
|
||||
@@ -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 ##
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user