Compare commits

...

55 Commits

Author SHA1 Message Date
Martin Hjelmare 6ff21b70f2 Disable instead of fixing in lg_thinq 2026-06-08 13:56:14 +02:00
Martin Hjelmare d01903cd59 Fix lg_thinq 2026-06-08 13:10:54 +02:00
Martin Hjelmare 0d17f3062c Add tests for dt_util.UTC 2026-06-08 11:59:51 +02:00
Martin Hjelmare c706c83337 Update readme 2026-06-08 11:49:54 +02:00
Martin Hjelmare 20f30f76d1 Deduplicate checker id 2026-06-08 11:45:50 +02:00
Martin Hjelmare 966b89cc14 Add pylint enforce dt.now 2026-06-08 11:27:59 +02:00
Colin 707742f720 Bump python-openevse-http to 1.0.1 (#172982) 2026-06-08 10:46:27 +02:00
Ronald van der Meer f58e0e5234 Fix Duco box device removal on partial node refreshes (#173186) 2026-06-08 09:48:27 +02:00
robotsnh c3d6ad029f refactor(energyid): replace datetime.now with dt_util.utcnow (#173241) 2026-06-08 09:11:48 +02:00
Mark Purcell 630f442042 Bump pydaikin to 2.18.1 (#173249) 2026-06-08 09:05:28 +02:00
Manu 62419789b9 Add version to Uptime Kuma diagnostics (#173254) 2026-06-08 08:46:55 +02:00
Joakim Plate f2f5a55165 Store product type in gardena_bluetooth config entry (#173223) 2026-06-08 08:20:47 +02:00
Mick Vleeshouwer c6a57bc81a Bump pyOverkiz to 2.0.0 in Overkiz (#173212) 2026-06-07 22:09:52 -04:00
Raphael Hehl 4171f566e9 Bump uiprotect to 11.8.0 (#173227) 2026-06-07 22:08:49 -04:00
renovate[bot] 0ac9834d93 Update syrupy to 5.3.1 (#173245)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-07 22:07:48 -04:00
Paul Bottein d7673a08c8 Bump yoto-api to 4.0.2 (#173238) 2026-06-07 22:07:36 -04:00
J. Nick Koston 35cb7c6147 Bump aiohttp to 3.14.1 (#173242) 2026-06-07 22:07:05 -04:00
Paulus Schoutsen d098622021 Return all matches for duplicate names in GetLiveContext (#173157)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-07 21:50:15 -04:00
karwosts f88e757e51 Add a battery charging sensor to demo device (#173219) 2026-06-07 23:00:22 +02:00
Pierre Pinon 653e6a43fa fix(indevolt): unable to discharge at 0 (#173085) 2026-06-07 21:55:10 +02:00
Bram Kragten 1462e7a181 Update frontend to 20260527.5 (#173236) 2026-06-07 21:39:34 +02:00
Martin Claesson e34d821f7d Add Kiosker Clear Blackout Button (#173225) 2026-06-07 21:29:51 +02:00
G Johansson 02b4442a6c Fix config flow version in goodwe (#173235) 2026-06-07 21:26:40 +02:00
J. Nick Koston 809571443c Bump habluetooth to 6.8.3 (#173194) 2026-06-07 19:17:52 +02:00
mvn23 d59398e0ea Remove name fields from opentherm_gw config flow (#173159) 2026-06-07 18:21:21 +02:00
Raphael Hehl 9c9695d0ba Bump uiprotect to 11.3.0 (#173024)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-06-07 14:19:26 +02:00
Allen Porter 3fbdbb12e2 Support streaming updates for V1 Roborock devices (#173182) 2026-06-07 14:12:20 +02:00
Ronald van der Meer a29f2907f7 Use NodeType enum in Duco entity (#173189) 2026-06-07 14:07:48 +02:00
mvn23 83534f286e Ensure opentherm_gw boiler and thermostat manufacturers are strings (#173162) 2026-06-07 12:23:09 +02:00
Ronald van der Meer 4fe93f9c64 Fix uncaught Duco diagnostics client errors (#173191) 2026-06-07 07:27:02 +02:00
Shay Levy fd8789d599 Fix Shelly virtual component unit retrieval (#173183) 2026-06-07 00:34:18 +03:00
Joost Lekkerkerker d0b34dfe92 Have Plugwise handle unavailable temperature measurements (#173173) 2026-06-07 00:19:29 +03:00
Tomer 390766ba3a Bump victron-mqtt to 2026.6.1.1 (#173142) 2026-06-07 00:15:39 +03:00
Vincent Knoop Pathuis 3a46d1088b Refactor Landis+Gyr heat meter to use the HA standard SerialPortSelector (#173170) 2026-06-06 15:16:41 -04:00
epenet 26d56b8218 Use DOMAIN constant in test (async_setup_component o-z) (#173018) 2026-06-06 12:14:46 -07:00
Vincent Knoop Pathuis 6ee819cdc3 Bump to Ultraheat 0.6.1 (#173175) 2026-06-06 15:14:01 -04:00
Stefan Agner 1cf8fe4d0b Drop legacy requires_api_password from discovery announcement (#173090)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:54:36 -04:00
Michael Hansen c5f93cdd72 Validate sentences and answers (#173127)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2026-06-06 13:48:07 -04:00
Michael Hansen 42136f1464 Bubble up conversation response in script run (#173131)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-06 13:47:49 -04:00
J. Nick Koston 34f3452280 Wait for Shelly bluetooth proxy connection at startup (#173165) 2026-06-06 11:17:49 -05:00
Michael Hansen ba9248cc94 LLM: format numeric states with display precision (#173128) 2026-06-06 12:15:48 -04:00
Michael Hansen 018cd1333e Bump ollama library (#173129) 2026-06-06 12:14:59 -04:00
J. Nick Koston c72d723e0d Wait for ESPHome bluetooth proxy connection at startup (#173164) 2026-06-06 11:13:03 -05:00
Paul Bottein b9b36d9e12 Add card group browsing to the Yoto media browser (#173152) 2026-06-06 12:12:58 -04:00
Klaas Schoute b6f38c3cbb Update forecast_solar integration to v5.0.1 (#173151) 2026-06-06 15:26:14 +02:00
Bouwe Westerdijk a0162d2ff0 Bump plugwise to v1.11.4 (#173147) 2026-06-06 12:03:32 +02:00
robotsnh b6f018873b refactor(dwd_weather_warnings): change datetime.now to dt_util.utcnow (#173149) 2026-06-06 11:58:06 +02:00
Crocmagnon 43e21322ea Bump data-grand-lyon-ha to 0.8.0 (#173108) 2026-06-06 11:08:43 +02:00
tronikos 86ccc59a5f Bump opower to 0.18.3 (#173141) 2026-06-06 08:53:52 +02:00
Luke Lashley 2fce2547c7 Close the connection for disabled Roborock devices (#172277) 2026-06-06 08:19:23 +02:00
Luke Lashley 6b40278d08 Allow using a custom server for Roborock setup. (#171645)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-05 21:36:39 -07:00
jasonjhofmann 05bb8b94fa Add network MAC connection to AirVisual Pro devices (#173071)
Co-authored-by: jasonjhofmann <16144532+jasonjhofmann@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 06:29:32 +02:00
Joakim Plate 5ac3a8cdde Switch to active scanner for gardena (#173062) 2026-06-06 04:08:10 +02:00
Paulus Schoutsen 266fccf0cf Use SerialPortSelector for DSMR serial port configuration (#171103)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 21:35:07 -04:00
Joakim Plate a1e6a6f9a2 Fix process advertisement for active scans (#173116) 2026-06-05 19:42:45 +02:00
228 changed files with 4051 additions and 2399 deletions
@@ -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"],
-1
View File
@@ -59,7 +59,6 @@ ATTR_EXTERNAL_URL = "external_url"
ATTR_INTERNAL_URL = "internal_url"
ATTR_LOCATION_NAME = "location_name"
ATTR_INSTALLATION_TYPE = "installation_type"
ATTR_REQUIRES_API_PASSWORD = "requires_api_password"
ATTR_UUID = "uuid"
ATTR_VERSION = "version"
@@ -5,6 +5,8 @@ import logging
from pathlib import Path
from typing import Any
from hassil.parse_expression import parse_sentence
from hassil.parser import ParseError
from hassil.util import (
PUNCTUATION_END,
PUNCTUATION_END_WORD,
@@ -164,6 +166,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
[cv.string],
has_one_non_empty_item,
has_no_punctuation,
is_valid_sentence,
),
}
],
@@ -212,6 +215,17 @@ def has_no_punctuation(value: list[str]) -> list[str]:
return value
def is_valid_sentence(value: list[str]) -> list[str]:
"""Validate result can be parsed by hassil."""
for sentence in value:
try:
parse_sentence(sentence)
except ParseError as err:
raise vol.Invalid(f"invalid sentence: {err}") from err
return value
def has_one_non_empty_item(value: list[str]) -> list[str]:
"""Validate result has at least one item."""
if len(value) < 1:
+12 -6
View File
@@ -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"]
}
+20 -2
View File
@@ -5,6 +5,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -27,7 +28,19 @@ async def async_setup_entry(
BinarySensorDeviceClass.MOISTURE,
),
DemoBinarySensor(
"binary_2", "Movement Backyard", True, BinarySensorDeviceClass.MOTION
"binary_2",
"Movement Backyard",
True,
BinarySensorDeviceClass.MOTION,
),
DemoBinarySensor(
"binary_3",
"Outside Temperature",
False,
BinarySensorDeviceClass.BATTERY_CHARGING,
device_id="sensor_1",
entity_category=EntityCategory.DIAGNOSTIC,
entity_name="Battery Charging",
),
]
)
@@ -46,6 +59,9 @@ class DemoBinarySensor(BinarySensorEntity):
device_name: str,
state: bool,
device_class: BinarySensorDeviceClass,
device_id: str | None = None,
entity_category: EntityCategory | None = None,
entity_name: str | None = None,
) -> None:
"""Initialize the demo sensor."""
self._unique_id = unique_id
@@ -54,10 +70,12 @@ class DemoBinarySensor(BinarySensorEntity):
self._attr_device_info = DeviceInfo(
identifiers={
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, self.unique_id)
(DOMAIN, device_id or unique_id)
},
name=device_name,
)
self._attr_entity_category = entity_category
self._attr_name = entity_name
@property
def unique_id(self) -> str:
+3 -49
View File
@@ -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"
+1
View File
@@ -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
+7 -1
View File
@@ -3,7 +3,7 @@
from dataclasses import asdict
from typing import Any
from duco_connectivity.exceptions import DucoConnectionError
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
@@ -52,6 +52,12 @@ async def async_get_config_entry_diagnostics(
translation_domain=DOMAIN,
translation_key="connection_error",
) from err
except DucoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
+3 -3
View File
@@ -1,6 +1,6 @@
"""Base entity for the Duco integration."""
from duco_connectivity.models import Node
from duco_connectivity.models import Node, NodeType
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -25,7 +25,7 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
identifiers={(DOMAIN, f"{mac}_{node.node_id}")},
manufacturer="Duco",
model=coordinator.board_info.box_name
if node.general.node_type == "BOX"
if node.general.node_type == NodeType.BOX
else node.general.node_type,
name=node.general.name or f"Node {node.node_id}",
)
@@ -34,7 +34,7 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
"connections": {(CONNECTION_NETWORK_MAC, mac)},
"serial_number": coordinator.board_info.serial_board_box,
}
if node.general.node_type == "BOX"
if node.general.node_type == NodeType.BOX
else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")}
)
self._attr_device_info = device_info
+8 -2
View File
@@ -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)
+22 -1
View File
@@ -1,11 +1,12 @@
"""Manager for esphome devices."""
import asyncio
import base64
from functools import partial
import logging
import secrets
import struct
from typing import TYPE_CHECKING, Any, NamedTuple
from typing import TYPE_CHECKING, Any, Final, NamedTuple
from aioesphomeapi import (
APIClient,
@@ -106,6 +107,9 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
# Max time to wait at startup for a BLE proxy to register its scanner.
STARTUP_SCANNER_WAIT: Final = 3.0
LOG_LEVEL_TO_LOGGER = {
LogLevel.LOG_LEVEL_NONE: logging.DEBUG,
LogLevel.LOG_LEVEL_ERROR: logging.ERROR,
@@ -677,6 +681,8 @@ class ESPHomeManager:
hass, device_info.bluetooth_mac_address or device_info.mac_address
)
entry_data.first_connect_done.set()
if device_info.voice_assistant_feature_flags_compat(api_version) and (
Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms
):
@@ -988,6 +994,21 @@ class ESPHomeManager:
await reconnect_logic.start()
# Wait for a cached BLE proxy to register its scanner before finishing setup.
if (
device_info := entry_data.device_info
) is not None and device_info.bluetooth_proxy_feature_flags_compat(
entry_data.api_version
):
try:
async with asyncio.timeout(STARTUP_SCANNER_WAIT):
await entry_data.first_connect_done.wait()
except TimeoutError:
_LOGGER.debug(
"%s: Timed out waiting for Bluetooth scanner to register",
self.host,
)
@callback
def _async_setup_device_registry(
@@ -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."]
}
+4 -4
View File
@@ -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,
+1 -1
View File
@@ -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"]
}
+34 -27
View File
@@ -4,18 +4,21 @@ from collections import defaultdict
from dataclasses import dataclass
from aiohttp import ClientError
from pyoverkiz.client import OverkizClient
from pyoverkiz.const import SUPPORTED_SERVERS
from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget
from pyoverkiz.exceptions import (
BadCredentialsException,
MaintenanceException,
NotAuthenticatedException,
NotSuchTokenException,
TooManyRequestsException,
from pyoverkiz.auth.credentials import (
LocalTokenCredentials,
UsernamePasswordCredentials,
)
from pyoverkiz.models import Device, OverkizServer, Scenario
from pyoverkiz.utils import generate_local_server
from pyoverkiz.client import OverkizClient
from pyoverkiz.enums import APIType, OverkizState, Server, UIClass, UIWidget
from pyoverkiz.exceptions import (
BadCredentialsError,
MaintenanceError,
NoSuchTokenError,
NotAuthenticatedError,
TooManyRequestsError,
)
from pyoverkiz.models import Device, PersistedActionGroup
from pyoverkiz.utils import create_local_server_config
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -58,7 +61,7 @@ class HomeAssistantOverkizData:
coordinator: OverkizDataUpdateCoordinator
platforms: defaultdict[Platform, list[Device]]
scenarios: list[Scenario]
scenarios: list[PersistedActionGroup]
type OverkizDataConfigEntry = ConfigEntry[HomeAssistantOverkizData]
@@ -90,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
hass,
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
server=SUPPORTED_SERVERS[entry.data[CONF_HUB]],
server=entry.data[CONF_HUB],
)
try:
@@ -100,20 +103,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
# Local API does expose scenarios, but they are not functional.
# Tracked in https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/21
if api_type == APIType.CLOUD:
scenarios = await client.get_scenarios()
scenarios = await client.get_action_groups()
else:
scenarios = []
except (
BadCredentialsException,
NotSuchTokenException,
NotAuthenticatedException,
BadCredentialsError,
NoSuchTokenError,
NotAuthenticatedError,
) as exception:
raise ConfigEntryAuthFailed("Invalid authentication") from exception
except TooManyRequestsException as exception:
except TooManyRequestsError as exception:
raise ConfigEntryNotReady("Too many requests, try again later") from exception
except (TimeoutError, ClientError) as exception:
raise ConfigEntryNotReady("Failed to connect") from exception
except MaintenanceException as exception:
except MaintenanceError as exception:
raise ConfigEntryNotReady("Server is down for maintenance") from exception
coordinator = OverkizDataUpdateCoordinator(
@@ -173,13 +176,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
identifiers={(DOMAIN, gateway.id)},
model=gateway.type.beautify_name if gateway.type else None,
model_id=str(gateway.type),
manufacturer=client.server.manufacturer,
manufacturer=client.server_config.manufacturer,
name=gateway.type.beautify_name if gateway.type else gateway.id,
sw_version=gateway.connectivity.protocol_version,
hw_version=f"{gateway.type}:{gateway.sub_type}"
if gateway.type and gateway.sub_type
else None,
configuration_url=client.server.configuration_url,
configuration_url=client.server_config.configuration_url,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -214,6 +217,9 @@ async def _async_migrate_strenum_unique_ids(
"""Migrate entities to the StrEnum-style unique IDs."""
entity_registry = er.async_get(hass)
# Map enum members renamed in pyoverkiz 2.0 to their current names.
renamed_enum_members = {"TSKALARM_CONTROLLER": "TSK_ALARM_CONTROLLER"}
@callback
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:
# Python 3.11 treats (str, Enum) and StrEnum
@@ -229,6 +235,7 @@ async def _async_migrate_strenum_unique_ids(
("OverkizState", "UIWidget", "UIClass")
):
state = key.split(".")[1]
state = renamed_enum_members.get(state, state)
new_key = ""
if key.startswith("UIClass"):
@@ -276,17 +283,15 @@ def create_local_client(
session = async_create_clientsession(hass, verify_ssl=verify_ssl)
return OverkizClient(
username="",
password="",
token=token,
server=create_local_server_config(host=host),
credentials=LocalTokenCredentials(token),
session=session,
server=generate_local_server(host=host),
verify_ssl=verify_ssl,
)
def create_cloud_client(
hass: HomeAssistant, username: str, password: str, server: OverkizServer
hass: HomeAssistant, username: str, password: str, server: Server
) -> OverkizClient:
"""Create Overkiz cloud client."""
# To allow users with multiple accounts/hubs, we create a
@@ -294,5 +299,7 @@ def create_cloud_client(
session = async_create_clientsession(hass)
return OverkizClient(
username=username, password=password, session=session, server=server
server=server,
credentials=UsernamePasswordCredentials(username, password),
session=session,
)
@@ -144,7 +144,7 @@ ALARM_DESCRIPTIONS: list[OverkizAlarmDescription] = [
# Disabled by default since all Overkiz hubs have this
# virtual device, but only a few users actually use this.
OverkizAlarmDescription(
key=UIWidget.TSKALARM_CONTROLLER,
key=UIWidget.TSK_ALARM_CONTROLLER,
entity_registry_enabled_default=False,
supported_features=(
AlarmControlPanelEntityFeature.ARM_AWAY
@@ -165,7 +165,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state.qualified_name))
if (description := SUPPORTED_STATES.get(state))
)
async_add_entities(entities)
+1 -1
View File
@@ -120,7 +120,7 @@ async def async_setup_entry(
description,
)
for command in device.definition.commands
if (description := SUPPORTED_COMMANDS.get(command.command_name))
if (description := SUPPORTED_COMMANDS.get(command))
)
async_add_entities(entities)
@@ -115,12 +115,13 @@ async def async_setup_entry(
# Match devices based on the widget and protocol.
# #ie Hitachi Air To Air Heat Pumps
entities_based_on_widget_and_protocol: list[Entity] = [
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol](
device.device_url, data.coordinator
)
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][
device.identifier.protocol
](device.device_url, data.coordinator)
for device in data.platforms[Platform.CLIMATE]
if device.widget in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY
and device.protocol in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget]
and device.identifier.protocol
in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget]
]
async_add_entities(
@@ -157,7 +157,7 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
@property
def target_temperature(self) -> float | None:
"""Return the temperature."""
if state := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]:
if state := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE):
return state.value_as_float
return None
@@ -165,7 +165,9 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return temperature.value_as_float
return None
@@ -104,7 +104,9 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return cast(float, temperature.value)
@@ -67,7 +67,9 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return cast(float, temperature.value)
@@ -106,7 +106,9 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return cast(float, temperature.value)
@@ -74,7 +74,7 @@ class EvoHomeController(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states[OverkizState.RAMSES_RAMSES_OPERATING_MODE]
state := self.device.states.get(OverkizState.RAMSES_RAMSES_OPERATING_MODE)
) and state.value_as_str in OVERKIZ_TO_PRESET_MODES:
return OVERKIZ_TO_PRESET_MODES[state.value_as_str]
@@ -114,13 +114,13 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if (
main_op_state := self.device.states[MAIN_OPERATION_STATE]
main_op_state := self.device.states.get(MAIN_OPERATION_STATE)
) and main_op_state.value_as_str:
if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF:
return HVACMode.OFF
if (
mode_change_state := self.device.states[MODE_CHANGE_STATE]
mode_change_state := self.device.states.get(MODE_CHANGE_STATE)
) and mode_change_state.value_as_str:
sanitized_value = mode_change_state.value_as_str.lower()
return OVERKIZ_TO_HVAC_MODES[sanitized_value]
@@ -140,7 +140,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def fan_mode(self) -> str | None:
"""Return the fan setting."""
if (state := self.device.states[FAN_SPEED_STATE]) and state.value_as_str:
if (state := self.device.states.get(FAN_SPEED_STATE)) and state.value_as_str:
return OVERKIZ_TO_FAN_MODES[state.value_as_str]
return None
@@ -157,7 +157,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def swing_mode(self) -> str | None:
"""Return the swing setting."""
if (state := self.device.states[SWING_STATE]) and state.value_as_str:
if (state := self.device.states.get(SWING_STATE)) and state.value_as_str:
return OVERKIZ_TO_SWING_MODES[state.value_as_str]
return None
@@ -170,7 +170,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
def target_temperature(self) -> int | None:
"""Return the temperature."""
if (
temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
temperature := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE)
) and temperature.value_as_int:
return temperature.value_as_int
@@ -179,7 +179,9 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def current_temperature(self) -> int | None:
"""Return current temperature."""
if (state := self.device.states[ROOM_TEMPERATURE_STATE]) and state.value_as_int:
if (
state := self.device.states.get(ROOM_TEMPERATURE_STATE)
) and state.value_as_int:
return state.value_as_int
return None
@@ -192,7 +194,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (state := self.device.states[LEAVE_HOME_STATE]) and state.value_as_str:
if (state := self.device.states.get(LEAVE_HOME_STATE)) and state.value_as_str:
if state.value_as_str == OverkizCommandParam.ON:
return PRESET_HOLIDAY_MODE
@@ -222,7 +224,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
"""
if value:
return value
state = self.device.states[state_name]
state = self.device.states.get(state_name)
if state and state.value_as_str:
return state.value_as_str
return fallback_value
@@ -118,13 +118,13 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if (
main_op_state := self.device.states[OverkizState.OVP_MAIN_OPERATION]
main_op_state := self.device.states.get(OverkizState.OVP_MAIN_OPERATION)
) and main_op_state.value_as_str:
if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF:
return HVACMode.OFF
if (
mode_change_state := self.device.states[OverkizState.OVP_MODE_CHANGE]
mode_change_state := self.device.states.get(OverkizState.OVP_MODE_CHANGE)
) and mode_change_state.value_as_str:
# The OVP protocol has 'auto cooling' and 'auto heating' values
# that are equivalent to the HLRRWIFI protocol without spaces
@@ -147,7 +147,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def fan_mode(self) -> str | None:
"""Return the fan setting."""
if (
state := self.device.states[OverkizState.OVP_FAN_SPEED]
state := self.device.states.get(OverkizState.OVP_FAN_SPEED)
) and state.value_as_str:
return OVERKIZ_TO_FAN_MODES[state.value_as_str]
@@ -160,7 +160,9 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
@property
def swing_mode(self) -> str | None:
"""Return the swing setting."""
if (state := self.device.states[OverkizState.OVP_SWING]) and state.value_as_str:
if (
state := self.device.states.get(OverkizState.OVP_SWING)
) and state.value_as_str:
return OVERKIZ_TO_SWING_MODES[state.value_as_str]
return None
@@ -173,7 +175,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def target_temperature(self) -> int | None:
"""Return the target temperature."""
if (
temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
temperature := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE)
) and temperature.value_as_int:
return temperature.value_as_int
@@ -183,7 +185,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def current_temperature(self) -> int | None:
"""Return current temperature."""
if (
state := self.device.states[OverkizState.OVP_ROOM_TEMPERATURE]
state := self.device.states.get(OverkizState.OVP_ROOM_TEMPERATURE)
) and state.value_as_int:
return state.value_as_int
@@ -197,7 +199,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states[OverkizState.CORE_HOLIDAYS_MODE]
state := self.device.states.get(OverkizState.CORE_HOLIDAYS_MODE)
) and state.value_as_str:
if state.value_as_str == OverkizCommandParam.ON:
return PRESET_HOLIDAY_MODE
@@ -225,7 +227,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def auto_manu_mode(self) -> str | None:
"""Return auto/manu mode."""
if (
state := self.device.states[OverkizState.CORE_AUTO_MANU_MODE]
state := self.device.states.get(OverkizState.CORE_AUTO_MANU_MODE)
) and state.value_as_str:
return state.value_as_str
return None
@@ -235,7 +237,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def temperature_change(self) -> int | None:
"""Return temperature change state."""
if (
state := self.device.states[OverkizState.OVP_TEMPERATURE_CHANGE]
state := self.device.states.get(OverkizState.OVP_TEMPERATURE_CHANGE)
) and state.value_as_int:
return state.value_as_int
@@ -266,7 +268,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
"""
if value:
return value
if (state := self.device.states[state_name]) is not None and (
if (state := self.device.states.get(state_name)) is not None and (
value := state.value_as_str
) is not None:
return value
@@ -60,7 +60,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if (
state := self.device.states[OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1]
state := self.device.states.get(OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1)
) and state.value_as_str:
return OVERKIZ_TO_HVAC_MODE[state.value_as_str]
@@ -76,7 +76,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states[OverkizState.MODBUS_YUTAKI_TARGET_MODE]
state := self.device.states.get(OverkizState.MODBUS_YUTAKI_TARGET_MODE)
) and state.value_as_str:
return OVERKIZ_TO_PRESET_MODE[state.value_as_str]
@@ -91,9 +91,9 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
current_temperature = self.device.states[
current_temperature = self.device.states.get(
OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1
]
)
if current_temperature:
return current_temperature.value_as_float
@@ -103,9 +103,9 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
target_temperature = self.device.states[
target_temperature = self.device.states.get(
OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE_1
]
)
if target_temperature:
return target_temperature.value_as_float
@@ -99,14 +99,14 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation i.e. heat, cool mode."""
state = self.device.states[OverkizState.CORE_ON_OFF]
state = self.device.states.get(OverkizState.CORE_ON_OFF)
if state and state.value_as_str == OverkizCommandParam.OFF:
return HVACMode.OFF
if (
state := self.device.states[
state := self.device.states.get(
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_ACTIVE_MODE
]
)
) and state.value_as_str:
return OVERKIZ_TO_HVAC_MODES[state.value_as_str]
@@ -127,9 +127,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states[
state := self.device.states.get(
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE
]
)
) and state.value_as_str:
return OVERKIZ_TO_PRESET_MODES[state.value_as_str]
return None
@@ -145,9 +145,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac operation if supported."""
if (
current_operation := self.device.states[
current_operation := self.device.states.get(
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_OPERATING_MODE
]
)
) and current_operation.value_as_str:
return OVERKIZ_TO_HVAC_ACTION[current_operation.value_as_str]
@@ -167,7 +167,7 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
if mode not in MAP_PRESET_TEMPERATURES:
return None
if state := self.device.states[MAP_PRESET_TEMPERATURES[mode]]:
if state := self.device.states.get(MAP_PRESET_TEMPERATURES[mode]):
return state.value_as_float
return None
@@ -175,7 +175,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return temperature.value_as_float
return None
@@ -185,9 +187,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
temperature = kwargs[ATTR_TEMPERATURE]
if (
mode := self.device.states[
mode := self.device.states.get(
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE
]
)
) and mode.value_as_str:
await self.executor.async_execute_command(
SETPOINT_MODE_TO_OVERKIZ_COMMAND[mode.value_as_str], temperature
@@ -40,10 +40,10 @@ OVERKIZ_TO_PRESET_MODES: dict[OverkizCommandParam, str] = {
PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()}
TARGET_TEMP_TO_OVERKIZ = {
PRESET_HOME: OverkizState.SOMFY_THERMOSTAT_AT_HOME_TARGET_TEMPERATURE,
PRESET_AWAY: OverkizState.SOMFY_THERMOSTAT_AWAY_MODE_TARGET_TEMPERATURE,
PRESET_FREEZE: OverkizState.SOMFY_THERMOSTAT_FREEZE_MODE_TARGET_TEMPERATURE,
PRESET_NIGHT: OverkizState.SOMFY_THERMOSTAT_SLEEPING_MODE_TARGET_TEMPERATURE,
PRESET_HOME: OverkizState.SOMFYTHERMOSTAT_AT_HOME_TARGET_TEMPERATURE,
PRESET_AWAY: OverkizState.SOMFYTHERMOSTAT_AWAY_MODE_TARGET_TEMPERATURE,
PRESET_FREEZE: OverkizState.SOMFYTHERMOSTAT_FREEZE_MODE_TARGET_TEMPERATURE,
PRESET_NIGHT: OverkizState.SOMFYTHERMOSTAT_SLEEPING_MODE_TARGET_TEMPERATURE,
}
# controllableName is somfythermostat:SomfyThermostatTemperatureSensor
@@ -88,9 +88,9 @@ class SomfyThermostat(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str:
"""Return the current preset mode, e.g., home, away, temp."""
if self.hvac_mode == HVACMode.AUTO:
state_key = OverkizState.SOMFY_THERMOSTAT_HEATING_MODE
state_key = OverkizState.SOMFYTHERMOSTAT_HEATING_MODE
else:
state_key = OverkizState.SOMFY_THERMOSTAT_DEROGATION_HEATING_MODE
state_key = OverkizState.SOMFYTHERMOSTAT_DEROGATION_HEATING_MODE
if state := self.executor.select_state(state_key):
return OVERKIZ_TO_PRESET_MODES[OverkizCommandParam(cast(str, state))]
@@ -101,7 +101,9 @@ class SomfyThermostat(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return cast(float, temperature.value)
return None
@@ -91,7 +91,9 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return temperature.value_as_float
+32 -29
View File
@@ -4,21 +4,25 @@ from collections.abc import Mapping
from typing import Any, cast
from aiohttp import ClientConnectorCertificateError, ClientError
from pyoverkiz.auth.credentials import (
LocalTokenCredentials,
UsernamePasswordCredentials,
)
from pyoverkiz.client import OverkizClient
from pyoverkiz.const import SERVERS_WITH_LOCAL_API, SUPPORTED_SERVERS
from pyoverkiz.enums import APIType, Server
from pyoverkiz.exceptions import (
BadCredentialsException,
CozyTouchBadCredentialsException,
MaintenanceException,
NotAuthenticatedException,
NotSuchTokenException,
TooManyAttemptsBannedException,
TooManyRequestsException,
UnknownUserException,
BadCredentialsError,
CozyTouchBadCredentialsError,
MaintenanceError,
NoSuchTokenError,
NotAuthenticatedError,
TooManyAttemptsBannedError,
TooManyRequestsError,
UnknownUserError,
)
from pyoverkiz.obfuscate import obfuscate_id
from pyoverkiz.utils import generate_local_server, is_overkiz_gateway
from pyoverkiz.utils import create_local_server_config, is_overkiz_gateway
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
@@ -58,19 +62,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
self.hass, verify_ssl=user_input[CONF_VERIFY_SSL]
)
client = OverkizClient(
username="",
password="",
token=user_input[CONF_TOKEN],
server=create_local_server_config(host=user_input[CONF_HOST]),
credentials=LocalTokenCredentials(user_input[CONF_TOKEN]),
session=session,
server=generate_local_server(host=user_input[CONF_HOST]),
verify_ssl=user_input[CONF_VERIFY_SSL],
)
else: # APIType.CLOUD
session = async_create_clientsession(self.hass)
client = OverkizClient(
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
server=SUPPORTED_SERVERS[user_input[CONF_HUB]],
server=user_input[CONF_HUB],
credentials=UsernamePasswordCredentials(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
),
session=session,
)
@@ -149,9 +152,9 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
try:
await self.async_validate_input(user_input)
except TooManyRequestsException:
except TooManyRequestsError:
errors["base"] = "too_many_requests"
except (BadCredentialsException, NotAuthenticatedException) as exception:
except (BadCredentialsError, NotAuthenticatedError) as exception:
# If authentication with CozyTouch auth server is
# valid, but token is invalid for Overkiz API
# server, the hardware is not supported.
@@ -159,18 +162,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
Server.ATLANTIC_COZYTOUCH,
Server.SAUTER_COZYTOUCH,
Server.THERMOR_COZYTOUCH,
} and not isinstance(exception, CozyTouchBadCredentialsException):
} and not isinstance(exception, CozyTouchBadCredentialsError):
description_placeholders["unsupported_device"] = "CozyTouch"
errors["base"] = "unsupported_hardware"
else:
errors["base"] = "invalid_auth"
except TimeoutError, ClientError:
errors["base"] = "cannot_connect"
except MaintenanceException:
except MaintenanceError:
errors["base"] = "server_in_maintenance"
except TooManyAttemptsBannedException:
except TooManyAttemptsBannedError:
errors["base"] = "too_many_attempts"
except UnknownUserException:
except UnknownUserError:
# If the user has no supported CozyTouch devices on
# the Overkiz API server. Login will return unknown user.
if user_input[CONF_HUB] in {
@@ -239,12 +242,12 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
try:
user_input = await self.async_validate_input(user_input)
except TooManyRequestsException:
except TooManyRequestsError:
errors["base"] = "too_many_requests"
except (
BadCredentialsException,
NotSuchTokenException,
NotAuthenticatedException,
BadCredentialsError,
NoSuchTokenError,
NotAuthenticatedError,
):
errors["base"] = "invalid_auth"
except ClientConnectorCertificateError as exception:
@@ -253,11 +256,11 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
except (TimeoutError, ClientError) as exception:
errors["base"] = "cannot_connect"
LOGGER.debug(exception)
except MaintenanceException:
except MaintenanceError:
errors["base"] = "server_in_maintenance"
except TooManyAttemptsBannedException:
except TooManyAttemptsBannedError:
errors["base"] = "too_many_attempts"
except UnknownUserException:
except UnknownUserError:
# Somfy Protect accounts are not supported since they don't use
# the Overkiz API server. Login will return unknown user.
description_placeholders["unsupported_device"] = "Somfy Protect"
+1 -1
View File
@@ -118,7 +118,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = {
UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH,
UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH,
UIWidget.TSKALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
UIWidget.TSK_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE,
}
+39 -33
View File
@@ -9,15 +9,23 @@ from aiohttp import ClientConnectorError, ServerDisconnectedError
from pyoverkiz.client import OverkizClient
from pyoverkiz.enums import EventName, ExecutionState, Protocol
from pyoverkiz.exceptions import (
BadCredentialsException,
InvalidEventListenerIdException,
MaintenanceException,
NotAuthenticatedException,
ServiceUnavailableException,
TooManyConcurrentRequestsException,
TooManyRequestsException,
BadCredentialsError,
InvalidEventListenerIdError,
MaintenanceError,
NotAuthenticatedError,
ServiceUnavailableError,
TooManyConcurrentRequestsError,
TooManyRequestsError,
)
from pyoverkiz.models import (
Device,
DeviceEvent,
DeviceRemovedEvent,
DeviceStateChangedEvent,
ExecutionRegisteredEvent,
ExecutionStateChangedEvent,
Place,
)
from pyoverkiz.models import Device, Event, Place
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -30,8 +38,9 @@ if TYPE_CHECKING:
from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, LOGGER, UPDATE_INTERVAL
# Events are a discriminated union; each handler narrows to its own subtype.
EVENT_HANDLERS: Registry[
str, Callable[[OverkizDataUpdateCoordinator, Event], Coroutine[Any, Any, None]]
str, Callable[[OverkizDataUpdateCoordinator, Any], Coroutine[Any, Any, None]]
] = Registry()
@@ -68,7 +77,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
self._default_update_interval = UPDATE_INTERVAL
self.is_stateless = all(
device.protocol in (Protocol.RTS, Protocol.INTERNAL)
device.identifier.protocol in (Protocol.RTS, Protocol.INTERNAL)
for device in devices
if device.widget not in IGNORED_OVERKIZ_DEVICES
and device.ui_class not in IGNORED_OVERKIZ_DEVICES
@@ -78,17 +87,17 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
"""Fetch Overkiz data via event listener."""
try:
events = await self.client.fetch_events()
except (BadCredentialsException, NotAuthenticatedException) as exception:
except (BadCredentialsError, NotAuthenticatedError) as exception:
raise ConfigEntryAuthFailed("Invalid authentication.") from exception
except TooManyConcurrentRequestsException as exception:
except TooManyConcurrentRequestsError as exception:
raise UpdateFailed("Too many concurrent requests.") from exception
except TooManyRequestsException as exception:
except TooManyRequestsError as exception:
raise UpdateFailed("Too many requests, try again later.") from exception
except MaintenanceException as exception:
except MaintenanceError as exception:
raise UpdateFailed("Server is down for maintenance.") from exception
except ServiceUnavailableException as exception:
except ServiceUnavailableError as exception:
raise UpdateFailed("Server is unavailable.") from exception
except InvalidEventListenerIdException as exception:
except InvalidEventListenerIdError as exception:
raise UpdateFailed(exception) from exception
except (TimeoutError, ClientConnectorError) as exception:
LOGGER.debug("Failed to connect", exc_info=True)
@@ -100,9 +109,9 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
try:
await self.client.login()
self.devices = await self._get_devices()
except (BadCredentialsException, NotAuthenticatedException) as exception:
except (BadCredentialsError, NotAuthenticatedError) as exception:
raise ConfigEntryAuthFailed("Invalid authentication.") from exception
except TooManyRequestsException as exception:
except TooManyRequestsError as exception:
raise UpdateFailed("Too many requests, try again later.") from exception
return self.devices
@@ -144,27 +153,27 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
@EVENT_HANDLERS.register(EventName.DEVICE_AVAILABLE)
async def on_device_available(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
) -> None:
"""Handle device available event."""
if event.device_url and event.device_url in coordinator.devices:
if event.device_url in coordinator.devices:
coordinator.devices[event.device_url].available = True
@EVENT_HANDLERS.register(EventName.DEVICE_UNAVAILABLE)
@EVENT_HANDLERS.register(EventName.DEVICE_DISABLED)
async def on_device_unavailable_disabled(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
) -> None:
"""Handle device unavailable / disabled event."""
if event.device_url and event.device_url in coordinator.devices:
if event.device_url in coordinator.devices:
coordinator.devices[event.device_url].available = False
@EVENT_HANDLERS.register(EventName.DEVICE_CREATED)
@EVENT_HANDLERS.register(EventName.DEVICE_UPDATED)
async def on_device_created_updated(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
) -> None:
"""Handle device unavailable / disabled event."""
coordinator.hass.async_create_task(
@@ -174,10 +183,10 @@ async def on_device_created_updated(
@EVENT_HANDLERS.register(EventName.DEVICE_STATE_CHANGED)
async def on_device_state_changed(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceStateChangedEvent
) -> None:
"""Handle device state changed event."""
if not event.device_url or event.device_url not in coordinator.devices:
if event.device_url not in coordinator.devices:
return
for state in event.device_states:
@@ -187,12 +196,9 @@ async def on_device_state_changed(
@EVENT_HANDLERS.register(EventName.DEVICE_REMOVED)
async def on_device_removed(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceRemovedEvent
) -> None:
"""Handle device removed event."""
if not event.device_url:
return
base_device_url = event.device_url.split("#")[0]
registry = dr.async_get(coordinator.hass)
@@ -201,16 +207,16 @@ async def on_device_removed(
):
registry.async_remove_device(registered_device.id)
if event.device_url and event.device_url in coordinator.devices:
if event.device_url in coordinator.devices:
del coordinator.devices[event.device_url]
@EVENT_HANDLERS.register(EventName.EXECUTION_REGISTERED)
async def on_execution_registered(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: ExecutionRegisteredEvent
) -> None:
"""Handle execution registered event."""
if event.exec_id and event.exec_id not in coordinator.executions:
if event.exec_id not in coordinator.executions:
coordinator.executions[event.exec_id] = {}
if not coordinator.is_stateless:
@@ -219,7 +225,7 @@ async def on_execution_registered(
@EVENT_HANDLERS.register(EventName.EXECUTION_STATE_CHANGED)
async def on_execution_state_changed(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: ExecutionStateChangedEvent
) -> None:
"""Handle execution changed event."""
if event.exec_id in coordinator.executions and event.new_state in [
+7 -5
View File
@@ -631,7 +631,7 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
"""
state_name = self.entity_description.current_position_state
if not state_name or not (state := self.device.states[state_name]):
if not state_name or not (state := self.device.states.get(state_name)):
return None
position = state.value_as_int
@@ -645,9 +645,9 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
state_name,
)
if fallback_state := self.device.states[
if fallback_state := self.device.states.get(
OverkizState.CORE_MEMORIZED_1_POSITION
]:
):
position = fallback_state.value_as_int
else:
return None
@@ -661,7 +661,9 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
state_name,
)
if fallback_state := self.device.states[OverkizState.CORE_TARGET_CLOSURE]:
if fallback_state := self.device.states.get(
OverkizState.CORE_TARGET_CLOSURE
):
position = fallback_state.value_as_int
else:
return None
@@ -707,7 +709,7 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
"""
state_name = self.entity_description.current_tilt_position_state
if state_name and (state := self.device.states[state_name]):
if state_name and (state := self.device.states.get(state_name)):
position = state.value_as_int
if position is None:
return None
@@ -19,13 +19,13 @@ async def async_get_config_entry_diagnostics(
client = entry.runtime_data.coordinator.client
data = {
"setup": await client.get_diagnostic_data(),
**await client.get_diagnostic_data(),
"server": entry.data[CONF_HUB],
"api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD),
}
# Only Overkiz cloud servers expose an endpoint with execution history
if client.api_type == APIType.CLOUD:
if client.server_config.api_type == APIType.CLOUD:
execution_history = [
repr(execution) for execution in await client.get_execution_history()
]
@@ -49,13 +49,13 @@ async def async_get_device_diagnostics(
"device_url": obfuscate_id(device_url),
"model": device.model,
},
"setup": await client.get_diagnostic_data(),
**await client.get_diagnostic_data(),
"server": entry.data[CONF_HUB],
"api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD),
}
# Only Overkiz cloud servers expose an endpoint with execution history
if client.api_type == APIType.CLOUD:
if client.server_config.api_type == APIType.CLOUD:
data["execution_history"] = [
repr(execution)
for execution in await client.get_execution_history()
+3 -3
View File
@@ -49,7 +49,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
# Workaround: local API may incorrectly report
# available=False (Somfy-TaHoma-Developer-Mode#217)
if self.coordinator.client.api_type != APIType.LOCAL:
if self.coordinator.client.server_config.api_type != APIType.LOCAL:
return False
if status_state := self.device.states.get(OverkizState.CORE_STATUS):
@@ -85,7 +85,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
manufacturer = (
self.executor.select_attribute(OverkizAttribute.CORE_MANUFACTURER)
or self.executor.select_state(OverkizState.CORE_MANUFACTURER_NAME)
or self.coordinator.client.server.manufacturer
or self.coordinator.client.server_config.manufacturer
)
model = (
@@ -116,7 +116,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
hw_version=self.device.controllable_name,
suggested_area=suggested_area,
via_device=(DOMAIN, self.executor.get_gateway_id()),
configuration_url=self.coordinator.client.server.configuration_url,
configuration_url=self.coordinator.client.server_config.configuration_url,
)
+25 -23
View File
@@ -1,11 +1,11 @@
"""Class for helpers and communication with the OverKiz API."""
from typing import Any, cast
from typing import Any
from urllib.parse import urlparse
from pyoverkiz.enums import OverkizCommand, Protocol
from pyoverkiz.exceptions import BaseOverkizException
from pyoverkiz.models import Command, Device, StateDefinition
from pyoverkiz.exceptions import BaseOverkizError
from pyoverkiz.models import Action, Command, Device, StateDefinition
from pyoverkiz.types import StateType as OverkizStateType
from homeassistant.exceptions import HomeAssistantError
@@ -56,15 +56,15 @@ class OverkizExecutor:
def select_definition_state(self, *states: str) -> StateDefinition | None:
"""Select first existing definition state in a list of states."""
for existing_state in self.device.definition.states:
if existing_state.qualified_name in states:
return existing_state
for state_name in states:
if state_name in self.device.definition.states:
return self.device.definition.states[state_name]
return None
def select_state(self, *states: str) -> OverkizStateType:
"""Select first existing active state in a list of states."""
for state in states:
if current_state := self.device.states[state]:
if current_state := self.device.states.get(state):
return current_state.value
return None
@@ -76,7 +76,7 @@ class OverkizExecutor:
def select_attribute(self, *attributes: str) -> OverkizStateType:
"""Select first existing active state in a list of states."""
for attribute in attributes:
if current_attribute := self.device.attributes[attribute]:
if current_attribute := self.device.attributes.get(attribute):
return current_attribute.value
return None
@@ -94,19 +94,23 @@ class OverkizExecutor:
# Set the execution duration to 0 seconds for RTS devices on supported commands
# Default execution duration is 30 seconds and will block consecutive commands
if (
self.device.protocol == Protocol.RTS
self.device.identifier.protocol == Protocol.RTS
and command_name not in COMMANDS_WITHOUT_DELAY
):
parameters.append(0)
try:
exec_id = await self.coordinator.client.execute_command(
self.device.device_url,
Command(command_name, parameters),
"Home Assistant",
exec_id = await self.coordinator.client.execute_action_group(
label="Home Assistant",
actions=[
Action(
device_url=self.device.device_url,
commands=[Command(name=command_name, parameters=parameters)],
)
],
)
# Catch Overkiz exceptions to support `continue_on_error` functionality
except BaseOverkizException as exception:
except BaseOverkizError as exception:
raise HomeAssistantError(exception) from exception
# ExecutionRegisteredEvent doesn't contain the
@@ -142,18 +146,16 @@ class OverkizExecutor:
return True
# Retrieve executions initiated outside Home Assistant via API
executions = cast(Any, await self.coordinator.client.get_current_executions())
# executions.action_group is typed incorrectly in the upstream library
# or the below code is incorrect.
executions = await self.coordinator.client.get_current_executions()
exec_id = next(
(
execution.id
for execution in executions
# Reverse dictionary to cancel the last added execution
for action in reversed(execution.action_group.get("actions"))
for command in action.get("commands")
if action.get("device_url") == self.device.device_url
and command.get("name") in commands_to_cancel
if execution.action_group
for action in reversed(execution.action_group.actions)
for command in action.commands
if action.device_url == self.device.device_url
and command.name in commands_to_cancel
),
None,
)
@@ -166,7 +168,7 @@ class OverkizExecutor:
async def async_cancel_execution(self, exec_id: str) -> None:
"""Cancel running execution via execution id."""
await self.coordinator.client.cancel_command(exec_id)
await self.coordinator.client.cancel_execution(exec_id)
def get_gateway_id(self) -> str:
"""Retrieve gateway id from device url.
@@ -12,8 +12,8 @@
"documentation": "https://www.home-assistant.io/integrations/overkiz",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.20.4"],
"loggers": ["boto3", "botocore", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz[nexity]==2.0.0"],
"zeroconf": [
{
"name": "gateway*",
+1 -1
View File
@@ -213,7 +213,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state.qualified_name))
if (description := SUPPORTED_STATES.get(state))
)
async_add_entities(entities)
+3 -3
View File
@@ -3,7 +3,7 @@
from typing import Any
from pyoverkiz.client import OverkizClient
from pyoverkiz.models import Scenario
from pyoverkiz.models import PersistedActionGroup
from homeassistant.components.scene import Scene
from homeassistant.core import HomeAssistant
@@ -28,7 +28,7 @@ async def async_setup_entry(
class OverkizScene(Scene):
"""Representation of an Overkiz Scene."""
def __init__(self, scenario: Scenario, client: OverkizClient) -> None:
def __init__(self, scenario: PersistedActionGroup, client: OverkizClient) -> None:
"""Initialize the scene."""
self.scenario = scenario
self.client = client
@@ -37,4 +37,4 @@ class OverkizScene(Scene):
async def async_activate(self, **kwargs: Any) -> None:
"""Activate the scene."""
await self.client.execute_scenario(self.scenario.oid)
await self.client.execute_persisted_action_group(self.scenario.oid)
+1 -1
View File
@@ -144,7 +144,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state.qualified_name))
if (description := SUPPORTED_STATES.get(state))
)
async_add_entities(entities)
+3 -3
View File
@@ -550,7 +550,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state.qualified_name))
if (description := SUPPORTED_STATES.get(state))
)
async_add_entities(entities)
@@ -597,12 +597,12 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity):
return default_unit
attrs = self.device.attributes
if (unit := attrs[f"{state.name}MeasuredValueType"]) and (
if (unit := attrs.get(f"{state.name}MeasuredValueType")) and (
unit_value := unit.value_as_str
):
return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
if (unit := attrs[OverkizAttribute.CORE_MEASURED_VALUE_TYPE]) and (
if (unit := attrs.get(OverkizAttribute.CORE_MEASURED_VALUE_TYPE)) and (
unit_value := unit.value_as_str
):
ha_unit = OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
@@ -48,7 +48,9 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater
def min_temp(self) -> float:
"""Return the minimum temperature."""
min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE]
min_temp = self.device.states.get(
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
)
if min_temp:
return cast(float, min_temp.value_as_float)
return DEFAULT_MIN_TEMP
@@ -57,7 +59,9 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater
def max_temp(self) -> float:
"""Return the maximum temperature."""
max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE]
max_temp = self.device.states.get(
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
)
if max_temp:
return cast(float, max_temp.value_as_float)
return DEFAULT_MAX_TEMP
@@ -156,7 +156,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE]
min_temp = self.device.states.get(
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
)
if min_temp:
return cast(float, min_temp.value_as_float)
return DEFAULT_MIN_TEMP
@@ -164,7 +166,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE]
max_temp = self.device.states.get(
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
)
if max_temp:
return cast(float, max_temp.value_as_float)
return DEFAULT_MAX_TEMP
@@ -172,14 +176,14 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
current_temperature = self.device.states[
current_temperature = self.device.states.get(
OverkizState.IO_MIDDLE_WATER_TEMPERATURE
]
)
if current_temperature:
return current_temperature.value_as_float
current_temperature = self.device.states[
current_temperature = self.device.states.get(
OverkizState.MODBUSLINK_MIDDLE_WATER_TEMPERATURE
]
)
if current_temperature:
return current_temperature.value_as_float
return None
@@ -188,19 +192,21 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
target_temperature = self.device.states[
target_temperature = self.device.states.get(
OverkizState.CORE_WATER_TARGET_TEMPERATURE
]
)
if target_temperature:
return target_temperature.value_as_float
target_temperature = self.device.states[
target_temperature = self.device.states.get(
OverkizState.CORE_TARGET_DWH_TEMPERATURE
]
)
if target_temperature:
return target_temperature.value_as_float
target_temperature = self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
target_temperature = self.device.states.get(
OverkizState.CORE_TARGET_TEMPERATURE
)
if target_temperature:
return target_temperature.value_as_float
@@ -209,9 +215,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def target_temperature_high(self) -> float | None:
"""Return the highbound target temperature we try to reach."""
target_temperature_high = self.device.states[
target_temperature_high = self.device.states.get(
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
]
)
if target_temperature_high:
return target_temperature_high.value_as_float
return None
@@ -219,9 +225,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def target_temperature_low(self) -> float | None:
"""Return the lowbound target temperature we try to reach."""
target_temperature_low = self.device.states[
target_temperature_low = self.device.states.get(
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
]
)
if target_temperature_low:
return target_temperature_low.value_as_float
return None
@@ -45,7 +45,7 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
current_temperature = self.device.states[OverkizState.CORE_DHW_TEMPERATURE]
current_temperature = self.device.states.get(OverkizState.CORE_DHW_TEMPERATURE)
if current_temperature and current_temperature.value_as_int:
return float(current_temperature.value_as_int)
@@ -55,9 +55,9 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
target_temperature = self.device.states[
target_temperature = self.device.states.get(
OverkizState.MODBUS_CONTROL_DHW_SETTING_TEMPERATURE
]
)
if target_temperature and target_temperature.value_as_int:
return float(target_temperature.value_as_int)
@@ -74,11 +74,11 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
@property
def current_operation(self) -> str | None:
"""Return current operation ie. eco, electric, performance, ..."""
modbus_control = self.device.states[OverkizState.MODBUS_CONTROL_DHW]
modbus_control = self.device.states.get(OverkizState.MODBUS_CONTROL_DHW)
if modbus_control and modbus_control.value_as_str == OverkizCommandParam.STOP:
return STATE_OFF
current_mode = self.device.states[OverkizState.MODBUS_DHW_MODE]
current_mode = self.device.states.get(OverkizState.MODBUS_DHW_MODE)
if current_mode and current_mode.value_as_str in OVERKIZ_TO_OPERATION_MODE:
return OVERKIZ_TO_OPERATION_MODE[current_mode.value_as_str]
+2 -2
View File
@@ -155,9 +155,9 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
)
@property
def current_temperature(self) -> float:
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self.device["sensors"]["temperature"]
return self.device["sensors"].get("temperature")
@property
def target_temperature(self) -> float:
@@ -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,
+23 -3
View File
@@ -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] = {}
+15 -1
View File
@@ -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
+24 -19
View File
@@ -521,6 +521,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
super().__init__(hass, entry, device, update_interval)
self.connected = False
# Set once BLE scanner setup has been attempted after connecting.
self.ble_scanner_setup_done = asyncio.Event()
self._disconnected_callbacks: list[CALLBACK_TYPE] = []
self._connection_lock = asyncio.Lock()
self._event_listeners: list[Callable[[dict[str, Any]], None]] = []
@@ -759,27 +761,30 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
async def _async_connect_ble_scanner(self) -> None:
"""Connect BLE scanner."""
ble_scanner_mode = self.config_entry.options.get(
CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED
)
if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected:
await async_stop_scanner(self.device)
async_remove_scanner(self.hass, self.bluetooth_source)
return
if await async_ensure_ble_enabled(self.device):
# BLE enable required a reboot, don't bother connecting
# the scanner since it will be disconnected anyway
LOGGER.debug(
"Device %s BLE enable required a reboot, skipping scanner connect",
self.name,
try:
ble_scanner_mode = self.config_entry.options.get(
CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED
)
return
assert self.device_id is not None
self._disconnected_callbacks.append(
await async_connect_scanner(
self.hass, self, ble_scanner_mode, self.device_id
if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected:
await async_stop_scanner(self.device)
async_remove_scanner(self.hass, self.bluetooth_source)
return
if await async_ensure_ble_enabled(self.device):
# BLE enable required a reboot, don't bother connecting
# the scanner since it will be disconnected anyway
LOGGER.debug(
"Device %s BLE enable required a reboot, skipping scanner connect",
self.name,
)
return
assert self.device_id is not None
self._disconnected_callbacks.append(
await async_connect_scanner(
self.hass, self, ble_scanner_mode, self.device_id
)
)
)
finally:
self.ble_scanner_setup_done.set()
@callback
def _async_handle_rpc_device_online(self) -> None:
+2 -2
View File
@@ -663,9 +663,9 @@ def is_view_for_platform(config: dict[str, Any], key: str, platform: str) -> boo
def get_virtual_component_unit(config: dict[str, Any]) -> str | None:
"""Return the unit of a virtual component.
If the unit is not set, the device sends an empty string
If the unit is not set, the device sends an empty string or the key may be absent.
"""
unit = config["meta"]["ui"]["unit"]
unit = config["meta"]["ui"].get("unit")
return DEVICE_UNIT_MAP.get(unit, unit) if unit else None
+3 -3
View File
@@ -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()
+3 -1
View File
@@ -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",
+6 -2
View File
@@ -114,11 +114,15 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
return self.client.players
async def _async_load_library(self) -> None:
"""Load the card library; failures only affect titles and artwork."""
"""Load the card library and groups; failures only affect browsing."""
try:
await self.client.update_library()
except YotoError as err:
_LOGGER.warning("Could not load Yoto card library: %s", err)
try:
await self.client.update_groups()
except YotoError as err:
_LOGGER.warning("Could not load Yoto card groups: %s", err)
async def _async_status_push_tick(self, _now: datetime) -> None:
"""Ask each player to push a fresh status snapshot over MQTT."""
@@ -127,7 +131,7 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
# Fire-and-forget: the data/status response lands via the on_update
# callback later, which already triggers async_set_updated_data.
for device_id in list(self.client.players):
await self.client.request_status_push(device_id)
await self.client.request_player_status(device_id)
def _mqtt_event(self, _player: YotoPlayer) -> None:
"""Handle a real-time update pushed by the Yoto MQTT broker."""
+1 -1
View File
@@ -10,5 +10,5 @@
"iot_class": "cloud_push",
"loggers": ["yoto_api"],
"quality_scale": "bronze",
"requirements": ["yoto-api==3.2.0"]
"requirements": ["yoto-api==4.0.2"]
}
+75 -20
View File
@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Any
from yoto_api import Card, Chapter, PlaybackStatus, Track, YotoError, YotoPlayer
from yoto_api import Card, Chapter, Group, PlaybackStatus, Track, YotoError, YotoPlayer
from homeassistant.components.media_player import (
BrowseError,
@@ -25,9 +25,8 @@ from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoEntity
URI_SCHEME = "yoto"
# The URI authority ("card") names the content type. Only cards exist today;
# reserving it leaves room for groups without breaking URIs.
URI_CARD = "card"
URI_GROUP = "group"
PARALLEL_UPDATES = 0
@@ -86,7 +85,7 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
@property
def available(self) -> bool:
"""Return whether the player is reachable through the Yoto cloud."""
return super().available and bool(self.player.status.is_online)
return super().available and bool(self.player.is_online)
@property
def state(self) -> MediaPlayerState:
@@ -186,7 +185,7 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
) -> None:
"""Play a Yoto card, chapter, or track from the browse tree."""
try:
card_id, chapter_key, track_key = _parse_uri(media_id)
card_id, chapter_key, track_key = _parse_card_uri(media_id)
except ValueError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
@@ -264,8 +263,27 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
if not media_content_id:
return self._browse_root()
client = self.coordinator.client
if media_content_id.startswith(f"{URI_SCHEME}://{URI_GROUP}/"):
try:
group_id = _parse_group_uri(media_content_id)
except ValueError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="invalid_media_id",
translation_placeholders={"media_id": media_content_id},
) from err
group = client.groups.get(group_id)
if group is None:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="unknown_group",
translation_placeholders={"group_id": group_id},
)
return self._browse_group(group)
try:
card_id, chapter_key, _ = _parse_uri(media_content_id)
card_id, chapter_key, _ = _parse_card_uri(media_content_id)
except ValueError as err:
raise BrowseError(
translation_domain=DOMAIN,
@@ -273,7 +291,7 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
translation_placeholders={"media_id": media_content_id},
) from err
card = self.coordinator.client.library.get(card_id)
card = client.library.get(card_id)
if card is None:
raise BrowseError(
translation_domain=DOMAIN,
@@ -283,7 +301,7 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
if not card.chapters:
try:
await self.coordinator.client.update_card_detail(card_id)
await client.update_card_detail(card_id)
except YotoError as err:
raise BrowseError(
translation_domain=DOMAIN,
@@ -307,7 +325,12 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
return self._browse_card(card)
def _browse_root(self) -> BrowseMedia:
"""List every card in the user's library."""
"""List every card and group in the user's library."""
client = self.coordinator.client
children: list[BrowseMedia] = [
self._card_node(card) for card in client.library.values()
]
children.extend(self._group_node(group) for group in client.groups.values())
return BrowseMedia(
media_class=MediaClass.DIRECTORY,
media_content_id="",
@@ -315,13 +338,19 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
title="Yoto library",
can_play=False,
can_expand=True,
children=[
self._card_node(card)
for card in self.coordinator.client.library.values()
],
children=children,
children_media_class=MediaClass.ALBUM,
)
def _browse_group(self, group: Group) -> BrowseMedia:
"""List the cards in a group."""
library = self.coordinator.client.library
cards = [library[card_id] for card_id in group.card_ids if card_id in library]
node = self._group_node(group)
node.children = [self._card_node(card) for card in cards]
node.children_media_class = MediaClass.ALBUM
return node
def _browse_card(self, card: Card) -> BrowseMedia:
"""List a card's chapters, collapsing single-chapter cards to tracks."""
chapters = card.chapters
@@ -366,6 +395,18 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
thumbnail=card.cover_image_large,
)
def _group_node(self, group: Group) -> BrowseMedia:
"""Build a browse node for a group."""
return BrowseMedia(
media_class=MediaClass.PLAYLIST,
media_content_id=_build_group_uri(group.id),
media_content_type=MediaType.PLAYLIST,
title=group.name or group.id,
can_play=False,
can_expand=True,
thumbnail=group.image_url,
)
def _chapter_node(
self, card_id: str, chapter_key: str, chapter: Chapter
) -> BrowseMedia:
@@ -423,19 +464,33 @@ def _build_uri(
return f"{URI_SCHEME}://{'/'.join(segments)}"
def _parse_uri(media_id: str) -> tuple[str, str | None, str | None]:
"""Parse a yoto://card/... URI into card/chapter/track parts.
def _build_group_uri(group_id: str) -> str:
"""Build a yoto://group/... URI."""
return f"{URI_SCHEME}://{URI_GROUP}/{group_id}"
Parsed manually because URL parsers lower-case the authority and Yoto
IDs are case-sensitive.
"""
# URIs parsed manually because URL parsers lower-case the authority and Yoto
# IDs are case-sensitive.
def _parse_card_uri(media_id: str) -> tuple[str, str | None, str | None]:
"""Parse a yoto://card/... URI into card/chapter/track parts."""
prefix = f"{URI_SCHEME}://{URI_CARD}/"
if not media_id.startswith(prefix):
raise ValueError(f"Not a Yoto media identifier: {media_id}")
raise ValueError(f"Not a Yoto card identifier: {media_id}")
parts = media_id[len(prefix) :].split("/")
if not parts or len(parts) > 3 or any(not segment for segment in parts):
raise ValueError(f"Not a Yoto media identifier: {media_id}")
raise ValueError(f"Not a Yoto card identifier: {media_id}")
card_id = parts[0]
chapter_key = parts[1] if len(parts) > 1 else None
track_key = parts[2] if len(parts) > 2 else None
return card_id, chapter_key, track_key
def _parse_group_uri(media_id: str) -> str:
"""Parse a yoto://group/<group_id> URI."""
prefix = f"{URI_SCHEME}://{URI_GROUP}/"
if not media_id.startswith(prefix):
raise ValueError(f"Not a Yoto group identifier: {media_id}")
parts = media_id[len(prefix) :].split("/")
if len(parts) != 1 or not parts[0]:
raise ValueError(f"Not a Yoto group identifier: {media_id}")
return parts[0]
@@ -52,6 +52,9 @@
"unknown_chapter": {
"message": "Unknown chapter {chapter_key} on card {card_id}"
},
"unknown_group": {
"message": "Unknown Yoto group: {group_id}"
},
"unknown_track": {
"message": "Unknown track {track_key} on card {card_id}"
},
@@ -258,8 +258,6 @@ async def _async_get_local_service_info(hass: HomeAssistant) -> AsyncServiceInfo
"internal_url": "",
# Old base URL, for backward compatibility
"base_url": "",
# Always needs authentication
"requires_api_password": True,
}
# Get instance URL's
+9
View File
@@ -22,6 +22,7 @@ from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER
from homeassistant.components.homeassistant import async_should_expose
from homeassistant.components.intent import async_device_supports_timers
from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
from homeassistant.components.sensor import async_rounded_state
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN, TodoServices
from homeassistant.components.weather import INTENT_GET_WEATHER
from homeassistant.const import (
@@ -720,6 +721,10 @@ def _get_exposed_entities(
if include_state:
info["state"] = state.state
# Format numeric states with configured display precision
if state.domain == "sensor":
info["state"] = async_rounded_state(hass, state.entity_id, state)
# Convert timestamp device_class states from UTC to local time
if state.attributes.get("device_class") == "timestamp" and state.state:
if (parsed_utc := dt_util.parse_datetime(state.state)) is not None:
@@ -1301,6 +1306,10 @@ class GetLiveContextTool(Tool):
name=name_filter,
area_name=area_filter,
domains=domain_filter,
# This tool only returns context, so multiple entities
# sharing a name (e.g. "AC" in two areas) should all be
# returned rather than failing as an ambiguous match.
allow_duplicate_names=True,
),
states=exposed_states,
)
+12 -2
View File
@@ -400,10 +400,16 @@ class _ConditionFail(_HaltScript):
class _StopScript(_HaltScript):
"""Throw if script needs to stop."""
def __init__(self, message: str, response: Any) -> None:
def __init__(
self,
message: str,
response: Any,
conversation_response: str | None | UndefinedType = UNDEFINED,
) -> None:
"""Initialize a halt exception."""
super().__init__(message)
self.response = response
self.conversation_response = conversation_response
class _ScriptRun:
@@ -481,6 +487,10 @@ class _ScriptRun:
response = err.response
# Bubble up child conversation response
if err.conversation_response is not UNDEFINED:
self._conversation_response = err.conversation_response
except Exception:
script_execution_set("error")
raise
@@ -979,7 +989,7 @@ class _ScriptRun:
) from ex
else:
response = None
raise _StopScript(stop, response)
raise _StopScript(stop, response, self._conversation_response)
## Variable actions ##

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