mirror of
https://github.com/home-assistant/core.git
synced 2026-06-11 19:51:39 +02:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9688b7fb2 | |||
| c3d6ad029f | |||
| 630f442042 | |||
| 62419789b9 | |||
| f2f5a55165 | |||
| c6a57bc81a | |||
| 4171f566e9 | |||
| 0ac9834d93 | |||
| d7673a08c8 | |||
| 35cb7c6147 | |||
| d098622021 | |||
| f88e757e51 | |||
| 653e6a43fa | |||
| 1462e7a181 | |||
| e34d821f7d | |||
| 02b4442a6c | |||
| 809571443c | |||
| d59398e0ea | |||
| 9c9695d0ba | |||
| 3fbdbb12e2 | |||
| a29f2907f7 | |||
| 83534f286e | |||
| 4fe93f9c64 | |||
| fd8789d599 | |||
| d0b34dfe92 | |||
| 390766ba3a | |||
| 3a46d1088b | |||
| 26d56b8218 | |||
| 6ee819cdc3 | |||
| 1cf8fe4d0b | |||
| c5f93cdd72 | |||
| 42136f1464 | |||
| 34f3452280 | |||
| ba9248cc94 | |||
| 018cd1333e | |||
| c72d723e0d | |||
| b9b36d9e12 | |||
| b6f38c3cbb | |||
| a0162d2ff0 | |||
| b6f018873b | |||
| 43e21322ea | |||
| 86ccc59a5f | |||
| 2fce2547c7 | |||
| 6b40278d08 | |||
| 05bb8b94fa | |||
| 5ac3a8cdde | |||
| 266fccf0cf | |||
| a1e6a6f9a2 | |||
| 2fe406c6ff | |||
| e1249fef8f | |||
| 6f61e97f8e | |||
| b65751e8ac | |||
| ef4bf77b24 | |||
| 977a9ecdd2 | |||
| 9e79eba970 | |||
| 40073e598c | |||
| 627d5cc110 | |||
| b1dbeca9ed | |||
| 059bc8d676 | |||
| 085f794407 | |||
| 3996db289d | |||
| 291585e48e | |||
| d9a125ce9b | |||
| 786c957909 | |||
| dd6830f1c5 | |||
| 4dbe58afc6 | |||
| 6c72d4337d | |||
| fcff5229d9 | |||
| 8edd813d4b | |||
| 509866c0eb | |||
| 9db5860d6b | |||
| 6917223cb3 | |||
| cc4637a703 | |||
| 2b0d14d71e | |||
| d0d85d8844 | |||
| eea3d9d4c4 | |||
| 48a690b267 | |||
| 07dc2346de | |||
| 711830b01f | |||
| f9fea56a8c | |||
| 8aac0c5b6e |
+7
-7
@@ -36,7 +36,7 @@
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
# - github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -961,7 +961,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1100,7 +1100,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1325,7 +1325,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1383,7 +1383,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.14
|
||||
rev: v0.15.15
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""The AirVisual Pro integration."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -25,6 +29,12 @@ class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
|
||||
"""Return device registry information for this entity."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.coordinator.data["serial_number"])},
|
||||
connections={
|
||||
(
|
||||
CONNECTION_NETWORK_MAC,
|
||||
format_mac(self.coordinator.data["status"]["mac_address"]),
|
||||
)
|
||||
},
|
||||
manufacturer="AirVisual",
|
||||
model=self.coordinator.data["status"]["model"],
|
||||
name=self.coordinator.data["settings"]["node_name"],
|
||||
|
||||
@@ -59,7 +59,6 @@ ATTR_EXTERNAL_URL = "external_url"
|
||||
ATTR_INTERNAL_URL = "internal_url"
|
||||
ATTR_LOCATION_NAME = "location_name"
|
||||
ATTR_INSTALLATION_TYPE = "installation_type"
|
||||
ATTR_REQUIRES_API_PASSWORD = "requires_api_password"
|
||||
ATTR_UUID = "uuid"
|
||||
ATTR_VERSION = "version"
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hassil.parse_expression import parse_sentence
|
||||
from hassil.parser import ParseError
|
||||
from hassil.util import (
|
||||
PUNCTUATION_END,
|
||||
PUNCTUATION_END_WORD,
|
||||
@@ -164,6 +166,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
[cv.string],
|
||||
has_one_non_empty_item,
|
||||
has_no_punctuation,
|
||||
is_valid_sentence,
|
||||
),
|
||||
}
|
||||
],
|
||||
@@ -212,6 +215,17 @@ def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
return value
|
||||
|
||||
|
||||
def is_valid_sentence(value: list[str]) -> list[str]:
|
||||
"""Validate result can be parsed by hassil."""
|
||||
for sentence in value:
|
||||
try:
|
||||
parse_sentence(sentence)
|
||||
except ParseError as err:
|
||||
raise vol.Invalid(f"invalid sentence: {err}") from err
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def has_one_non_empty_item(value: list[str]) -> list[str]:
|
||||
"""Validate result has at least one item."""
|
||||
if len(value) < 1:
|
||||
|
||||
@@ -6,6 +6,7 @@ These APIs are the only documented way to interact with the bluetooth integratio
|
||||
import asyncio
|
||||
from asyncio import Future
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import ExitStack
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from bleak import BleakScanner
|
||||
@@ -178,15 +179,20 @@ async def async_process_advertisements(
|
||||
if not done.done() and callback(service_info):
|
||||
done.set_result(service_info)
|
||||
|
||||
unload = _get_manager(hass).async_register_callback(
|
||||
_async_discovered_device, match_dict, mode, scan_duration=timeout
|
||||
)
|
||||
manager = _get_manager(hass)
|
||||
|
||||
with ExitStack() as stack:
|
||||
unload = manager.async_register_callback(
|
||||
_async_discovered_device, match_dict, mode
|
||||
)
|
||||
stack.callback(unload)
|
||||
|
||||
if mode == BluetoothScanningMode.ACTIVE:
|
||||
task = hass.async_create_task(manager.async_request_active_scan(timeout))
|
||||
stack.callback(task.cancel)
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
return await done
|
||||
finally:
|
||||
unload()
|
||||
|
||||
|
||||
@hass_callback
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.16",
|
||||
"habluetooth==6.8.1"
|
||||
"habluetooth==6.8.3"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"""The Brands integration."""
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Container, Mapping
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from random import SystemRandom
|
||||
import time
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, override
|
||||
|
||||
from aiohttp import ClientError, hdrs, web
|
||||
from aiohttp import ClientError, web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant, callback, valid_domain
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -108,23 +109,18 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
|
||||
class _BrandsBaseView(HomeAssistantView):
|
||||
"""Base view for serving brand images."""
|
||||
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the view."""
|
||||
self._hass = hass
|
||||
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
|
||||
|
||||
def _authenticate(self, request: web.Request) -> None:
|
||||
"""Authenticate the request using Bearer token or query token."""
|
||||
access_tokens: deque[str] = self._hass.data[DOMAIN]
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens
|
||||
)
|
||||
if not authenticated:
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
raise web.HTTPForbidden
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
return self._hass.data[DOMAIN]
|
||||
|
||||
async def _serve_from_custom_integration(
|
||||
self,
|
||||
@@ -240,8 +236,6 @@ class BrandsIntegrationView(_BrandsBaseView):
|
||||
image: str,
|
||||
) -> web.Response:
|
||||
"""Handle GET request for an integration brand image."""
|
||||
self._authenticate(request)
|
||||
|
||||
if not valid_domain(domain) or image not in ALLOWED_IMAGES:
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
@@ -274,8 +268,6 @@ class BrandsHardwareView(_BrandsBaseView):
|
||||
image: str,
|
||||
) -> web.Response:
|
||||
"""Handle GET request for a hardware brand image."""
|
||||
self._authenticate(request)
|
||||
|
||||
if not CATEGORY_RE.match(category):
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
# Hardware images have dynamic names like "manufacturer_model.png"
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiostreammagic"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiostreammagic==2.13.1"],
|
||||
"requirements": ["aiostreammagic==2.13.2"],
|
||||
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta
|
||||
@@ -12,16 +12,16 @@ import logging
|
||||
import os
|
||||
from random import SystemRandom
|
||||
import time
|
||||
from typing import Any, Final, final
|
||||
from typing import Any, Final, final, override
|
||||
|
||||
from aiohttp import hdrs, web
|
||||
from aiohttp import web
|
||||
import attr
|
||||
from propcache.api import cached_property, under_cached_property
|
||||
import voluptuous as vol
|
||||
from webrtc_models import RTCIceCandidateInit
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_CONTENT_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE,
|
||||
@@ -776,30 +776,26 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
class CameraView(HomeAssistantView):
|
||||
"""Base CameraView."""
|
||||
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
|
||||
def __init__(self, component: EntityComponent[Camera]) -> None:
|
||||
"""Initialize a basic camera view."""
|
||||
self.component = component
|
||||
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
if (camera := self.component.get_entity(match_info["entity_id"])) is None:
|
||||
return ()
|
||||
|
||||
return camera.access_tokens
|
||||
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
|
||||
"""Start a GET request."""
|
||||
if (camera := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
or request.query.get("token") in camera.access_tokens
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or camera access token
|
||||
raise web.HTTPForbidden
|
||||
|
||||
if not camera.is_on:
|
||||
_LOGGER.debug("Camera is off")
|
||||
raise web.HTTPServiceUnavailable
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from hassil.parse_expression import parse_sentence
|
||||
from hassil.parser import ParseError
|
||||
from hassil.recognize import RecognizeResult
|
||||
from hassil.util import (
|
||||
PUNCTUATION_END,
|
||||
@@ -42,6 +44,17 @@ def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
return value
|
||||
|
||||
|
||||
def is_valid_sentence(value: list[str]) -> list[str]:
|
||||
"""Validate result can be parsed by hassil."""
|
||||
for sentence in value:
|
||||
try:
|
||||
parse_sentence(sentence)
|
||||
except ParseError as err:
|
||||
raise vol.Invalid(f"invalid sentence: {err}") from err
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def has_one_non_empty_item(value: list[str]) -> list[str]:
|
||||
"""Validate result has at least one item."""
|
||||
if len(value) < 1:
|
||||
@@ -58,7 +71,11 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_PLATFORM): DOMAIN,
|
||||
vol.Required(CONF_COMMAND): vol.All(
|
||||
cv.ensure_list, [cv.string], has_one_non_empty_item, has_no_punctuation
|
||||
cv.ensure_list,
|
||||
[cv.string],
|
||||
has_one_non_empty_item,
|
||||
has_no_punctuation,
|
||||
is_valid_sentence,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydaikin"],
|
||||
"requirements": ["pydaikin==2.17.2"],
|
||||
"requirements": ["pydaikin==2.18.1"],
|
||||
"zeroconf": ["_dkapi._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["data-grand-lyon-ha==0.7.0"]
|
||||
"requirements": ["data-grand-lyon-ha==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -27,7 +28,19 @@ async def async_setup_entry(
|
||||
BinarySensorDeviceClass.MOISTURE,
|
||||
),
|
||||
DemoBinarySensor(
|
||||
"binary_2", "Movement Backyard", True, BinarySensorDeviceClass.MOTION
|
||||
"binary_2",
|
||||
"Movement Backyard",
|
||||
True,
|
||||
BinarySensorDeviceClass.MOTION,
|
||||
),
|
||||
DemoBinarySensor(
|
||||
"binary_3",
|
||||
"Outside Temperature",
|
||||
False,
|
||||
BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
device_id="sensor_1",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_name="Battery Charging",
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -46,6 +59,9 @@ class DemoBinarySensor(BinarySensorEntity):
|
||||
device_name: str,
|
||||
state: bool,
|
||||
device_class: BinarySensorDeviceClass,
|
||||
device_id: str | None = None,
|
||||
entity_category: EntityCategory | None = None,
|
||||
entity_name: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the demo sensor."""
|
||||
self._unique_id = unique_id
|
||||
@@ -54,10 +70,12 @@ class DemoBinarySensor(BinarySensorEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
# Serial numbers are unique identifiers within a specific domain
|
||||
(DOMAIN, self.unique_id)
|
||||
(DOMAIN, device_id or unique_id)
|
||||
},
|
||||
name=device_name,
|
||||
)
|
||||
self._attr_entity_category = entity_category
|
||||
self._attr_name = entity_name
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
|
||||
@@ -22,6 +22,7 @@ from .const import ( # noqa: F401
|
||||
ATTR_LOCATION_NAME,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
ATTR_TRACKING_TYPE,
|
||||
CONF_ASSOCIATED_ZONE,
|
||||
CONF_CONSIDER_HOME,
|
||||
CONF_NEW_DEVICE_DEFAULTS,
|
||||
@@ -36,6 +37,7 @@ from .const import ( # noqa: F401
|
||||
PLATFORM_TYPE_LEGACY,
|
||||
SCAN_INTERVAL,
|
||||
SourceType,
|
||||
TrackingType,
|
||||
)
|
||||
from .entity import ( # noqa: F401
|
||||
BaseScannerEntity,
|
||||
|
||||
@@ -25,6 +25,18 @@ class SourceType(StrEnum):
|
||||
BLUETOOTH_LE = "bluetooth_le"
|
||||
|
||||
|
||||
class TrackingType(StrEnum):
|
||||
"""Tracking type for device trackers.
|
||||
|
||||
Describes how the tracker determines presence: by the device's geographic
|
||||
position (e.g. GPS) or by its connection to a known endpoint (e.g. a router
|
||||
or beacon associated with a zone).
|
||||
"""
|
||||
|
||||
CONNECTION = "connection"
|
||||
POSITION = "position"
|
||||
|
||||
|
||||
CONF_SCAN_INTERVAL: Final = "interval_seconds"
|
||||
SCAN_INTERVAL: Final = timedelta(seconds=12)
|
||||
|
||||
@@ -47,6 +59,7 @@ ATTR_IN_ZONES: Final = "in_zones"
|
||||
ATTR_LOCATION_NAME: Final = "location_name"
|
||||
ATTR_MAC: Final = "mac"
|
||||
ATTR_SOURCE_TYPE: Final = "source_type"
|
||||
ATTR_TRACKING_TYPE: Final = "tracking_type"
|
||||
ATTR_CONSIDER_HOME: Final = "consider_home"
|
||||
ATTR_IP: Final = "ip"
|
||||
|
||||
|
||||
@@ -48,11 +48,13 @@ from .const import (
|
||||
ATTR_IP,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
ATTR_TRACKING_TYPE,
|
||||
CONF_ASSOCIATED_ZONE,
|
||||
CONNECTED_DEVICE_REGISTERED,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
SourceType,
|
||||
TrackingType,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -238,6 +240,9 @@ class TrackerEntity(
|
||||
"""Base class for a tracked device."""
|
||||
|
||||
entity_description: TrackerEntityDescription
|
||||
_attr_capability_attributes: dict[str, Any] = {
|
||||
ATTR_TRACKING_TYPE: TrackingType.POSITION
|
||||
}
|
||||
_attr_in_zones: list[str] | None = None
|
||||
_attr_latitude: float | None = None
|
||||
_attr_location_accuracy: float = 0
|
||||
@@ -411,6 +416,9 @@ class BaseScannerEntity(BaseTrackerEntity):
|
||||
addresses being used to identify the device.
|
||||
"""
|
||||
|
||||
_attr_capability_attributes: dict[str, Any] = {
|
||||
ATTR_TRACKING_TYPE: TrackingType.CONNECTION
|
||||
}
|
||||
_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
|
||||
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None
|
||||
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
"gps": "GPS",
|
||||
"router": "Router"
|
||||
}
|
||||
},
|
||||
"tracking_type": {
|
||||
"name": "Tracking type",
|
||||
"state": {
|
||||
"connection": "Connection",
|
||||
"position": "Position"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from duco_connectivity.exceptions import DucoConnectionError
|
||||
from duco_connectivity.exceptions import DucoConnectionError, DucoError
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST
|
||||
@@ -52,6 +52,12 @@ async def async_get_config_entry_diagnostics(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
) from err
|
||||
except DucoError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
|
||||
if api_info_obj.reported_api_version is not None:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Base entity for the Duco integration."""
|
||||
|
||||
from duco_connectivity.models import Node
|
||||
from duco_connectivity.models import Node, NodeType
|
||||
|
||||
from homeassistant.const import ATTR_VIA_DEVICE
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
@@ -25,7 +25,7 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
|
||||
identifiers={(DOMAIN, f"{mac}_{node.node_id}")},
|
||||
manufacturer="Duco",
|
||||
model=coordinator.board_info.box_name
|
||||
if node.general.node_type == "BOX"
|
||||
if node.general.node_type == NodeType.BOX
|
||||
else node.general.node_type,
|
||||
name=node.general.name or f"Node {node.node_id}",
|
||||
)
|
||||
@@ -34,7 +34,7 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
|
||||
"connections": {(CONNECTION_NETWORK_MAC, mac)},
|
||||
"serial_number": coordinator.board_info.serial_board_box,
|
||||
}
|
||||
if node.general.node_type == "BOX"
|
||||
if node.general.node_type == NodeType.BOX
|
||||
else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")}
|
||||
)
|
||||
self._attr_device_info = device_info
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"name": "Target flow level"
|
||||
},
|
||||
"time_state_end": {
|
||||
"name": "Mode end time"
|
||||
"name": "State end time"
|
||||
},
|
||||
"ventilation_state": {
|
||||
"name": "Ventilation state",
|
||||
|
||||
@@ -9,7 +9,6 @@ Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor
|
||||
Wetterwarnungen (Stufe 1)
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
@@ -17,6 +16,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
ADVANCE_WARNING_SENSOR,
|
||||
@@ -100,7 +100,7 @@ class DwdWeatherWarningsSensor(
|
||||
if warnings is None:
|
||||
return []
|
||||
|
||||
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
now = dt_util.utcnow()
|
||||
return [warning for warning in warnings if warning[API_ATTR_WARNING_END] > now]
|
||||
|
||||
@property
|
||||
|
||||
@@ -26,6 +26,7 @@ from homeassistant.helpers.event import (
|
||||
async_track_state_change_event,
|
||||
async_track_time_interval,
|
||||
)
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_DEVICE_NAME,
|
||||
@@ -221,8 +222,7 @@ def update_listeners(hass: HomeAssistant, entry: EnergyIDConfigEntry) -> None:
|
||||
):
|
||||
try:
|
||||
value = float(current_state.state)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
timestamp = current_state.last_updated or dt.datetime.now(dt.UTC)
|
||||
timestamp = current_state.last_updated or dt_util.utcnow()
|
||||
client.get_or_create_sensor(energyid_key).update(value, timestamp)
|
||||
except ValueError, TypeError:
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -166,6 +166,8 @@ class RuntimeEntryData:
|
||||
)
|
||||
loaded_platforms: set[Platform] = field(default_factory=set)
|
||||
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# Set once the first connection has finished scanner setup or teardown.
|
||||
first_connect_done: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
_storage_contents: StoreData | None = None
|
||||
_pending_storage: Callable[[], StoreData] | None = None
|
||||
assist_pipeline_update_callbacks: list[CALLBACK_TYPE] = field(default_factory=list)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Manager for esphome devices."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from functools import partial
|
||||
import logging
|
||||
import secrets
|
||||
import struct
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
from typing import TYPE_CHECKING, Any, Final, NamedTuple
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
@@ -106,6 +107,9 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Max time to wait at startup for a BLE proxy to register its scanner.
|
||||
STARTUP_SCANNER_WAIT: Final = 3.0
|
||||
|
||||
LOG_LEVEL_TO_LOGGER = {
|
||||
LogLevel.LOG_LEVEL_NONE: logging.DEBUG,
|
||||
LogLevel.LOG_LEVEL_ERROR: logging.ERROR,
|
||||
@@ -677,6 +681,8 @@ class ESPHomeManager:
|
||||
hass, device_info.bluetooth_mac_address or device_info.mac_address
|
||||
)
|
||||
|
||||
entry_data.first_connect_done.set()
|
||||
|
||||
if device_info.voice_assistant_feature_flags_compat(api_version) and (
|
||||
Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms
|
||||
):
|
||||
@@ -988,6 +994,21 @@ class ESPHomeManager:
|
||||
|
||||
await reconnect_logic.start()
|
||||
|
||||
# Wait for a cached BLE proxy to register its scanner before finishing setup.
|
||||
if (
|
||||
device_info := entry_data.device_info
|
||||
) is not None and device_info.bluetooth_proxy_feature_flags_compat(
|
||||
entry_data.api_version
|
||||
):
|
||||
try:
|
||||
async with asyncio.timeout(STARTUP_SCANNER_WAIT):
|
||||
await entry_data.first_connect_done.wait()
|
||||
except TimeoutError:
|
||||
_LOGGER.debug(
|
||||
"%s: Timed out waiting for Bluetooth scanner to register",
|
||||
self.host,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_setup_device_registry(
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/forecast_solar",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["forecast-solar==5.0.0"]
|
||||
"requirements": ["forecast-solar==5.0.1"]
|
||||
}
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260527.4"]
|
||||
"requirements": ["home-assistant-frontend==20260527.5"]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"""The Gardena Bluetooth integration."""
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from gardena_bluetooth.client import CachedConnection, Client
|
||||
from gardena_bluetooth.const import ProductType
|
||||
from gardena_bluetooth.scan import async_get_manufacturer_data
|
||||
from gardena_bluetooth.const import ScanService
|
||||
from gardena_bluetooth.parse import ManufacturerData, ProductType
|
||||
from habluetooth import BluetoothServiceInfoBleak
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import CONF_PRODUCT_TYPE
|
||||
from .coordinator import (
|
||||
DeviceUnavailable,
|
||||
GardenaBluetoothConfigEntry,
|
||||
@@ -30,6 +33,79 @@ PLATFORMS: list[Platform] = [
|
||||
]
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
DISCONNECT_DELAY = 5
|
||||
PRODUCTS_SCAN_TIMEOUT = 10
|
||||
PRODUCT_TYPE_TIMEOUT = 30
|
||||
|
||||
|
||||
async def async_get_product(hass: HomeAssistant, address: str) -> ManufacturerData:
|
||||
"""Get manufacturer data for the given address via active scan."""
|
||||
|
||||
data = ManufacturerData()
|
||||
|
||||
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
|
||||
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
|
||||
if info.device.address != address:
|
||||
return False
|
||||
|
||||
data.update(info.manufacturer_data.get(ManufacturerData.company, b""))
|
||||
return data.product_type is not ProductType.UNKNOWN
|
||||
|
||||
with suppress(TimeoutError):
|
||||
await bluetooth.async_process_advertisements(
|
||||
hass,
|
||||
_data_callback,
|
||||
bluetooth.BluetoothCallbackMatcher(
|
||||
address=address, manufacturer_id=ManufacturerData.company
|
||||
),
|
||||
mode=bluetooth.BluetoothScanningMode.ACTIVE,
|
||||
timeout=PRODUCT_TYPE_TIMEOUT,
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def async_get_products(hass: HomeAssistant) -> dict[str, ManufacturerData]:
|
||||
"""Get all products that are currently advertising."""
|
||||
products: dict[str, ManufacturerData] = {}
|
||||
|
||||
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
|
||||
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
|
||||
if ScanService not in info.service_uuids:
|
||||
return False
|
||||
|
||||
raw = info.manufacturer_data.get(ManufacturerData.company, b"")
|
||||
if (data := products.get(info.device.address)) is None:
|
||||
data = ManufacturerData()
|
||||
products[info.device.address] = data
|
||||
|
||||
data.update(raw)
|
||||
return False
|
||||
|
||||
with suppress(TimeoutError):
|
||||
await bluetooth.async_process_advertisements(
|
||||
hass,
|
||||
_data_callback,
|
||||
bluetooth.BluetoothCallbackMatcher(
|
||||
manufacturer_id=ManufacturerData.company
|
||||
),
|
||||
mode=bluetooth.BluetoothScanningMode.ACTIVE,
|
||||
timeout=PRODUCTS_SCAN_TIMEOUT,
|
||||
)
|
||||
return products
|
||||
|
||||
|
||||
async def async_migrate_product_type(
|
||||
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
|
||||
) -> GardenaBluetoothConfigEntry:
|
||||
"""Discover product type for old entries and upgrade them to minor version 2."""
|
||||
mfg = await async_get_product(hass, entry.data[CONF_ADDRESS])
|
||||
if mfg.product_type is ProductType.UNKNOWN:
|
||||
raise ConfigEntryNotReady("Unable to find product type")
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={**entry.data, CONF_PRODUCT_TYPE: mfg.product_type.name},
|
||||
minor_version=2,
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
|
||||
@@ -51,16 +127,11 @@ async def async_setup_entry(
|
||||
) -> bool:
|
||||
"""Set up Gardena Bluetooth from a config entry."""
|
||||
|
||||
if entry.minor_version < 2:
|
||||
entry = await async_migrate_product_type(hass, entry)
|
||||
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
|
||||
try:
|
||||
mfg_data = await async_get_manufacturer_data({address})
|
||||
except TimeoutError as exc:
|
||||
raise ConfigEntryNotReady("Unable to find product type") from exc
|
||||
|
||||
product_type = mfg_data[address].product_type
|
||||
if product_type is ProductType.UNKNOWN:
|
||||
raise ConfigEntryNotReady("Unable to find product type")
|
||||
product_type = ProductType[entry.data[CONF_PRODUCT_TYPE]]
|
||||
|
||||
client = Client(get_connection(hass, address), product_type)
|
||||
|
||||
|
||||
@@ -4,22 +4,18 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from gardena_bluetooth.client import Client
|
||||
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation, ScanService
|
||||
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure
|
||||
from gardena_bluetooth.parse import ManufacturerData, ProductType
|
||||
from gardena_bluetooth.scan import async_get_manufacturer_data
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfo,
|
||||
async_discovered_service_info,
|
||||
)
|
||||
from homeassistant.components.bluetooth import BluetoothServiceInfo
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
|
||||
from . import get_connection
|
||||
from .const import DOMAIN
|
||||
from . import async_get_product, async_get_products, get_connection
|
||||
from .const import CONF_PRODUCT_TYPE, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,26 +29,16 @@ _SUPPORTED_PRODUCT_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
def _is_supported(discovery_info: BluetoothServiceInfo):
|
||||
"""Check if device is supported."""
|
||||
if ScanService not in discovery_info.service_uuids:
|
||||
return False
|
||||
|
||||
if discovery_info.manufacturer_data.get(ManufacturerData.company) is None:
|
||||
_LOGGER.debug("Missing manufacturer data: %s", discovery_info)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Gardena Bluetooth."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.devices: dict[str, str] = {}
|
||||
self.address: str | None
|
||||
self.devices: dict[str, ManufacturerData] = {}
|
||||
|
||||
async def async_read_data(self):
|
||||
"""Try to connect to device and extract information."""
|
||||
@@ -68,20 +54,23 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
return {CONF_ADDRESS: self.address}
|
||||
assert self.address in self.devices
|
||||
return {
|
||||
CONF_ADDRESS: self.address,
|
||||
CONF_PRODUCT_TYPE: self.devices[self.address].product_type.name,
|
||||
}
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the bluetooth discovery step."""
|
||||
_LOGGER.debug("Discovered device: %s", discovery_info)
|
||||
data = await async_get_manufacturer_data({discovery_info.address})
|
||||
product_type = data[discovery_info.address].product_type
|
||||
if product_type not in _SUPPORTED_PRODUCT_TYPES:
|
||||
mfg = await async_get_product(self.hass, discovery_info.address)
|
||||
self.devices[discovery_info.address] = mfg
|
||||
if mfg.product_type not in _SUPPORTED_PRODUCT_TYPES:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
self.address = discovery_info.address
|
||||
self.devices = {discovery_info.address: PRODUCT_NAMES[product_type]}
|
||||
await self.async_set_unique_id(self.address)
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_confirm()
|
||||
@@ -91,7 +80,7 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm discovery."""
|
||||
assert self.address
|
||||
title = self.devices[self.address]
|
||||
title = PRODUCT_NAMES[self.devices[self.address].product_type]
|
||||
|
||||
if user_input is not None:
|
||||
data = await self.async_read_data()
|
||||
@@ -117,31 +106,25 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_confirm()
|
||||
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
candidates = set()
|
||||
for discovery_info in async_discovered_service_info(self.hass):
|
||||
address = discovery_info.address
|
||||
if address in current_addresses or not _is_supported(discovery_info):
|
||||
continue
|
||||
candidates.add(address)
|
||||
|
||||
data = await async_get_manufacturer_data(candidates)
|
||||
for address, mfg_data in data.items():
|
||||
if mfg_data.product_type not in _SUPPORTED_PRODUCT_TYPES:
|
||||
continue
|
||||
self.devices[address] = PRODUCT_NAMES[mfg_data.product_type]
|
||||
current = self._async_current_ids(include_ignore=False)
|
||||
self.devices = await async_get_products(self.hass)
|
||||
|
||||
# Keep selection sorted by address to ensure stable tests
|
||||
self.devices = dict(sorted(self.devices.items(), key=lambda x: x[0]))
|
||||
devices = {
|
||||
address: PRODUCT_NAMES[data.product_type]
|
||||
for address in sorted(self.devices)
|
||||
if address not in current
|
||||
and (data := self.devices[address]).product_type in _SUPPORTED_PRODUCT_TYPES
|
||||
}
|
||||
|
||||
if not self.devices:
|
||||
if not devices:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): vol.In(self.devices),
|
||||
vol.Required(CONF_ADDRESS): vol.In(devices),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"""Constants for the Gardena Bluetooth integration."""
|
||||
|
||||
DOMAIN = "gardena_bluetooth"
|
||||
CONF_PRODUCT_TYPE = "product_type"
|
||||
|
||||
@@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Goodwe config flow."""
|
||||
|
||||
MINOR_VERSION = 2
|
||||
VERSION = 2
|
||||
|
||||
async def async_handle_successful_connection(
|
||||
self,
|
||||
|
||||
@@ -135,6 +135,10 @@
|
||||
"description": "The {integration_title} integration is being removed as it depends on system packages that can only be installed on systems running a deprecated architecture. To resolve this, remove the {domain} entry from your configuration.yaml file and restart Home Assistant.",
|
||||
"title": "The {integration_title} integration is being removed"
|
||||
},
|
||||
"deprecated_trigger_behavior": {
|
||||
"description": "An automation, script or template entity uses the trigger behavior option `{deprecated_behavior}`, which has been renamed to `{new_behavior}`. The old value still works for now, but support for it will be removed in a future release.\n\nTo fix this issue, edit the affected automations and scripts and change the behavior option from `{deprecated_behavior}` to `{new_behavior}`, then restart Home Assistant.",
|
||||
"title": "Deprecated trigger behavior option in use"
|
||||
},
|
||||
"deprecated_yaml": {
|
||||
"description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
|
||||
"title": "The {integration_title} YAML configuration is being removed"
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
from collections.abc import Container, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import os
|
||||
from random import SystemRandom
|
||||
from typing import Final, final
|
||||
from typing import Final, final, override
|
||||
|
||||
from aiohttp import hdrs, web
|
||||
from aiohttp import web
|
||||
import httpx
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import (
|
||||
@@ -314,33 +315,28 @@ class ImageView(HomeAssistantView):
|
||||
"""View to serve an image."""
|
||||
|
||||
name = "api:image:image"
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
url = "/api/image_proxy/{entity_id}"
|
||||
|
||||
def __init__(self, component: EntityComponent[ImageEntity]) -> None:
|
||||
"""Initialize an image view."""
|
||||
self.component = component
|
||||
|
||||
async def _authenticate_request(
|
||||
self, request: web.Request, entity_id: str
|
||||
) -> ImageEntity:
|
||||
"""Authenticate request and return image entity."""
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
if (image_entity := self.component.get_entity(match_info["entity_id"])) is None:
|
||||
return ()
|
||||
|
||||
return image_entity.access_tokens
|
||||
|
||||
@callback
|
||||
def _get_image_entity(self, entity_id: str) -> ImageEntity:
|
||||
"""Get image entity from request."""
|
||||
if (image_entity := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
or request.query.get("token") in image_entity.access_tokens
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or image entity access token
|
||||
raise web.HTTPForbidden
|
||||
|
||||
return image_entity
|
||||
|
||||
async def head(self, request: web.Request, entity_id: str) -> web.Response:
|
||||
@@ -349,7 +345,7 @@ class ImageView(HomeAssistantView):
|
||||
This is sent by some DLNA renderers, like Samsung ones, prior to sending
|
||||
the GET request.
|
||||
"""
|
||||
image_entity = await self._authenticate_request(request, entity_id)
|
||||
image_entity = self._get_image_entity(entity_id)
|
||||
|
||||
# Don't use `handle` as we don't care about the stream case, we only want
|
||||
# to verify that the image exists.
|
||||
@@ -365,7 +361,7 @@ class ImageView(HomeAssistantView):
|
||||
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
|
||||
"""Start a GET request."""
|
||||
image_entity = await self._authenticate_request(request, entity_id)
|
||||
image_entity = self._get_image_entity(entity_id)
|
||||
return await self.handle(request, image_entity)
|
||||
|
||||
async def handle(
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["indevolt-api==1.8.3"],
|
||||
"requirements": ["indevolt-api==1.8.5"],
|
||||
"zeroconf": [{ "name": "igen_fw*", "type": "_http._tcp.local." }]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -51,7 +51,7 @@ class LinkPlayBaseEntity(Entity):
|
||||
)
|
||||
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
configuration_url=bridge.endpoint,
|
||||
configuration_url=str(bridge.endpoint),
|
||||
connections=connections,
|
||||
hw_version=bridge.device.properties["hardware"],
|
||||
identifiers={(DOMAIN, bridge.device.uuid)},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Container, Mapping
|
||||
from contextlib import suppress
|
||||
import datetime as dt
|
||||
from enum import StrEnum
|
||||
@@ -12,7 +12,7 @@ import hashlib
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any, Final, Required, TypedDict, final
|
||||
from typing import Any, Final, Required, TypedDict, final, override
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import aiohttp
|
||||
@@ -24,7 +24,7 @@ import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ( # noqa: F401
|
||||
@@ -50,7 +50,7 @@ from homeassistant.const import ( # noqa: F401
|
||||
STATE_PLAYING,
|
||||
STATE_STANDBY,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, SupportsResponse
|
||||
from homeassistant.core import HomeAssistant, SupportsResponse, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
@@ -1249,7 +1249,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
class MediaPlayerImageView(HomeAssistantView):
|
||||
"""Media player view to serve an image."""
|
||||
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
url = "/api/media_player_proxy/{entity_id}"
|
||||
name = "api:media_player:image"
|
||||
extra_urls = [
|
||||
@@ -1262,6 +1262,15 @@ class MediaPlayerImageView(HomeAssistantView):
|
||||
"""Initialize a media player view."""
|
||||
self.component = component
|
||||
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
if (player := self.component.get_entity(match_info["entity_id"])) is None:
|
||||
return ()
|
||||
|
||||
return (player.access_token,)
|
||||
|
||||
async def get(
|
||||
self,
|
||||
request: web.Request,
|
||||
@@ -1271,21 +1280,9 @@ class MediaPlayerImageView(HomeAssistantView):
|
||||
) -> web.Response:
|
||||
"""Start a get request."""
|
||||
if (player := self.component.get_entity(entity_id)) is None:
|
||||
status = (
|
||||
HTTPStatus.NOT_FOUND
|
||||
if request[KEY_AUTHENTICATED]
|
||||
else HTTPStatus.UNAUTHORIZED
|
||||
)
|
||||
return web.Response(status=status)
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
assert isinstance(player, MediaPlayerEntity)
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
or request.query.get("token") == player.access_token
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
return web.Response(status=HTTPStatus.UNAUTHORIZED)
|
||||
|
||||
if media_content_type and media_content_id:
|
||||
media_image_id = request.query.get("media_image_id")
|
||||
|
||||
@@ -14,9 +14,16 @@ from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionE
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DEFAULT_CONNECT_TIMEOUT, DEFAULT_RESPONSE_TIMEOUT, DOMAIN, PLATFORMS
|
||||
from .const import (
|
||||
CONF_ADDRESSES,
|
||||
DEFAULT_CONNECT_TIMEOUT,
|
||||
DEFAULT_RESPONSE_TIMEOUT,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -25,13 +32,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
def _make_device(
|
||||
info: DeviceInfo,
|
||||
serial: str,
|
||||
address: str,
|
||||
session,
|
||||
) -> IndoorUnit | KumoStation:
|
||||
"""Create the appropriate device instance from DeviceInfo."""
|
||||
cls = IndoorUnit if info.is_indoor_unit else KumoStation
|
||||
return cls(
|
||||
name=info.label,
|
||||
address=info.address,
|
||||
address=address,
|
||||
password_b64=info.password,
|
||||
crypto_serial_hex=info.crypto_serial,
|
||||
serial=serial,
|
||||
@@ -64,12 +72,39 @@ async def async_setup_entry(
|
||||
translation_key="no_devices",
|
||||
)
|
||||
|
||||
# The cloud provides each device's MAC but never its LAN IP. Register every
|
||||
# device with its MAC so the manifest's "registered_devices" DHCP matcher
|
||||
# tracks it; DHCP discovery then supplies the IP via async_step_dhcp.
|
||||
device_registry = dr.async_get(hass)
|
||||
owned_macs = {dr.format_mac(info.mac) for info in devices.values()}
|
||||
for serial, info in devices.items():
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, serial)},
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(info.mac))},
|
||||
manufacturer="Mitsubishi",
|
||||
name=info.label,
|
||||
serial_number=serial,
|
||||
)
|
||||
|
||||
# Resolved IPs are stored keyed by MAC. Drop any for devices that are no
|
||||
# longer on the account.
|
||||
stored: dict[str, str] = entry.data.get(CONF_ADDRESSES, {})
|
||||
addresses = {mac: ip for mac, ip in stored.items() if mac in owned_macs}
|
||||
if addresses != stored:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={**entry.data, CONF_ADDRESSES: addresses}
|
||||
)
|
||||
|
||||
coordinators: dict[str, MitsubishiComfortCoordinator] = {}
|
||||
for serial, info in devices.items():
|
||||
if not info.address or not info.password or not info.crypto_serial:
|
||||
_LOGGER.warning("Device %s missing credentials, skipping", info.label)
|
||||
address = addresses.get(dr.format_mac(info.mac))
|
||||
if not address or not info.password or not info.crypto_serial:
|
||||
# No LAN address yet: the device is registered, so DHCP discovery
|
||||
# supplies its IP and reloads the entry to add it.
|
||||
_LOGGER.debug("Device %s has no known LAN address yet", info.label)
|
||||
continue
|
||||
device = _make_device(info, serial, session)
|
||||
device = _make_device(info, serial, address, session)
|
||||
coordinators[serial] = MitsubishiComfortCoordinator(
|
||||
hass, entry, device, info.mac
|
||||
)
|
||||
|
||||
@@ -9,9 +9,11 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_ADDRESSES, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -71,3 +73,41 @@ class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=USER_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a registered device discovered on the local network via DHCP.
|
||||
|
||||
The cloud API never returns a device's LAN IP, so DHCP discovery is the
|
||||
source of addresses. Each device is registered with its MAC during setup,
|
||||
so "registered_devices" discovery only fires for our own devices: record
|
||||
the IP on the owning entry and reload to set the device up or recover a
|
||||
changed IP.
|
||||
"""
|
||||
mac = dr.format_mac(discovery_info.macaddress)
|
||||
device = dr.async_get(self.hass).async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
|
||||
)
|
||||
entry = next(
|
||||
(
|
||||
entry
|
||||
for entry in self._async_current_entries(include_ignore=False)
|
||||
if device is not None and entry.entry_id in device.config_entries
|
||||
),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
addresses = entry.data.get(CONF_ADDRESSES, {})
|
||||
if addresses.get(mac) != discovery_info.ip:
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_ADDRESSES: {**addresses, mac: discovery_info.ip},
|
||||
},
|
||||
)
|
||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
@@ -7,6 +7,13 @@ from homeassistant.const import Platform
|
||||
|
||||
DOMAIN: Final = "mitsubishi_comfort"
|
||||
PLATFORMS: Final = [Platform.CLIMATE]
|
||||
|
||||
# Config entry data key holding the per-device LAN address cache, keyed by the
|
||||
# device's formatted MAC. The cloud API only returns each device's MAC, never
|
||||
# its LAN IP, so addresses are resolved from DHCP discovery and persisted here
|
||||
# to survive restarts without re-discovery.
|
||||
CONF_ADDRESSES: Final = "addresses"
|
||||
|
||||
DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
|
||||
DEFAULT_CONNECT_TIMEOUT: Final = 1.2
|
||||
DEFAULT_RESPONSE_TIMEOUT: Final = 8.0
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
"name": "Mitsubishi Comfort",
|
||||
"codeowners": ["@nikolairahimi"],
|
||||
"config_flow": true,
|
||||
"dhcp": [{ "registered_devices": true }],
|
||||
"documentation": "https://www.home-assistant.io/integrations/mitsubishi_comfort",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["mitsubishi-comfort==0.3.0"]
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ rules:
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
dynamic-devices: todo
|
||||
discovery-update-info: todo
|
||||
discovery-update-info: done
|
||||
repair-issues: todo
|
||||
docs-use-cases: done
|
||||
docs-supported-devices: done
|
||||
|
||||
@@ -504,6 +504,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# Can be removed with HA Core 2027.1
|
||||
new_entry_data = entry.data.copy()
|
||||
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
|
||||
# Create temporary certificate files from entry
|
||||
await async_create_certificate_temp_files(hass, new_entry_data)
|
||||
# Try the connection with protocol version 5
|
||||
# And update the protocol if successful
|
||||
if await hass.async_add_executor_job(
|
||||
|
||||
@@ -70,13 +70,6 @@ from homeassistant.config_entries import (
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_CONFIGURATION_URL,
|
||||
ATTR_HW_VERSION,
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL,
|
||||
ATTR_MODEL_ID,
|
||||
ATTR_NAME,
|
||||
ATTR_SW_VERSION,
|
||||
CONF_BRIGHTNESS,
|
||||
CONF_CLIENT_ID,
|
||||
CONF_CODE,
|
||||
@@ -87,6 +80,8 @@ from homeassistant.const import (
|
||||
CONF_ENTITY_CATEGORY,
|
||||
CONF_HOST,
|
||||
CONF_MODE,
|
||||
CONF_MODEL,
|
||||
CONF_MODEL_ID,
|
||||
CONF_NAME,
|
||||
CONF_OPTIMISTIC,
|
||||
CONF_OPTIONS,
|
||||
@@ -181,6 +176,7 @@ from .const import (
|
||||
CONF_COMMAND_ON_TEMPLATE,
|
||||
CONF_COMMAND_TEMPLATE,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_CONFIGURATION_URL,
|
||||
CONF_CONTENT_TYPE,
|
||||
CONF_CURRENT_HUMIDITY_TEMPLATE,
|
||||
CONF_CURRENT_HUMIDITY_TOPIC,
|
||||
@@ -221,10 +217,12 @@ from .const import (
|
||||
CONF_HUMIDITY_MIN,
|
||||
CONF_HUMIDITY_STATE_TEMPLATE,
|
||||
CONF_HUMIDITY_STATE_TOPIC,
|
||||
CONF_HW_VERSION,
|
||||
CONF_IMAGE_ENCODING,
|
||||
CONF_IMAGE_TOPIC,
|
||||
CONF_KEEPALIVE,
|
||||
CONF_LAST_RESET_VALUE_TEMPLATE,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MAX,
|
||||
CONF_MAX_KELVIN,
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL,
|
||||
@@ -317,6 +315,7 @@ from .const import (
|
||||
CONF_SUPPORT_VOLUME_SET,
|
||||
CONF_SUPPORTED_COLOR_MODES,
|
||||
CONF_SUPPORTED_FEATURES,
|
||||
CONF_SW_VERSION,
|
||||
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE,
|
||||
CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC,
|
||||
CONF_SWING_HORIZONTAL_MODE_LIST,
|
||||
@@ -3797,17 +3796,17 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
},
|
||||
}
|
||||
MQTT_DEVICE_PLATFORM_FIELDS = {
|
||||
ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
|
||||
ATTR_SW_VERSION: PlatformField(
|
||||
CONF_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
|
||||
CONF_SW_VERSION: PlatformField(
|
||||
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
|
||||
),
|
||||
ATTR_HW_VERSION: PlatformField(
|
||||
CONF_HW_VERSION: PlatformField(
|
||||
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
|
||||
),
|
||||
ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False),
|
||||
ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False),
|
||||
ATTR_MANUFACTURER: PlatformField(selector=TEXT_SELECTOR, required=False),
|
||||
ATTR_CONFIGURATION_URL: PlatformField(
|
||||
CONF_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False),
|
||||
CONF_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False),
|
||||
CONF_MANUFACTURER: PlatformField(selector=TEXT_SELECTOR, required=False),
|
||||
CONF_CONFIGURATION_URL: PlatformField(
|
||||
selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url"
|
||||
),
|
||||
CONF_QOS: PlatformField(
|
||||
|
||||
@@ -2,31 +2,9 @@
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_CLIENT_ID,
|
||||
CONF_DISCOVERY,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_PROTOCOL,
|
||||
CONF_USERNAME,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONF_BIRTH_MESSAGE,
|
||||
CONF_BROKER,
|
||||
CONF_CERTIFICATE,
|
||||
CONF_CLIENT_CERT,
|
||||
CONF_CLIENT_KEY,
|
||||
CONF_DISCOVERY_PREFIX,
|
||||
CONF_KEEPALIVE,
|
||||
CONF_TLS_INSECURE,
|
||||
CONF_WILL_MESSAGE,
|
||||
)
|
||||
|
||||
DEFAULT_TLS_PROTOCOL = "auto"
|
||||
|
||||
CONFIG_SCHEMA_BASE = vol.Schema(
|
||||
{
|
||||
Platform.ALARM_CONTROL_PANEL.value: vol.All(cv.ensure_list, [dict]),
|
||||
@@ -60,29 +38,3 @@ CONFIG_SCHEMA_BASE = vol.Schema(
|
||||
Platform.WATER_HEATER.value: vol.All(cv.ensure_list, [dict]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
CLIENT_KEY_AUTH_MSG = (
|
||||
"client_key and client_cert must both be present in the MQTT broker configuration"
|
||||
)
|
||||
|
||||
DEPRECATED_CONFIG_KEYS = [
|
||||
CONF_BIRTH_MESSAGE,
|
||||
CONF_BROKER,
|
||||
CONF_CLIENT_ID,
|
||||
CONF_DISCOVERY,
|
||||
CONF_DISCOVERY_PREFIX,
|
||||
CONF_KEEPALIVE,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_PROTOCOL,
|
||||
CONF_TLS_INSECURE,
|
||||
CONF_USERNAME,
|
||||
CONF_WILL_MESSAGE,
|
||||
]
|
||||
|
||||
DEPRECATED_CERTIFICATE_CONFIG_KEYS = [
|
||||
CONF_CERTIFICATE,
|
||||
CONF_CLIENT_CERT,
|
||||
CONF_CLIENT_KEY,
|
||||
]
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ollama",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ollama==0.5.1"]
|
||||
"requirements": ["ollama==0.6.2"]
|
||||
}
|
||||
|
||||
@@ -8,13 +8,7 @@ import pyotgw.vars as gw_vars
|
||||
from serial import SerialException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.const import CONF_DEVICE, CONF_ID, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
@@ -100,7 +94,6 @@ class OpenThermGatewayHub:
|
||||
self.hass = hass
|
||||
self.device_path = config_entry.data[CONF_DEVICE]
|
||||
self.hub_id = config_entry.data[CONF_ID]
|
||||
self.name = config_entry.data[CONF_NAME]
|
||||
self.options = config_entry.options
|
||||
self.config_entry_id = config_entry.entry_id
|
||||
self.update_signal = f"{DATA_OPENTHERM_GW}_{self.hub_id}_update"
|
||||
@@ -159,11 +152,14 @@ class OpenThermGatewayHub:
|
||||
_LOGGER.debug("Received report: %s", status)
|
||||
async_dispatcher_send(self.hass, self.update_signal, status)
|
||||
|
||||
boiler_manufacturer = status[OpenThermDataSource.BOILER].get(
|
||||
gw_vars.DATA_SLAVE_MEMBERID
|
||||
)
|
||||
dev_reg.async_update_device(
|
||||
boiler_device.id,
|
||||
manufacturer=status[OpenThermDataSource.BOILER].get(
|
||||
gw_vars.DATA_SLAVE_MEMBERID
|
||||
),
|
||||
manufacturer=str(boiler_manufacturer)
|
||||
if boiler_manufacturer is not None
|
||||
else None,
|
||||
model_id=status[OpenThermDataSource.BOILER].get(
|
||||
gw_vars.DATA_SLAVE_PRODUCT_TYPE
|
||||
),
|
||||
@@ -175,11 +171,14 @@ class OpenThermGatewayHub:
|
||||
),
|
||||
)
|
||||
|
||||
thermostat_manufacturer = status[OpenThermDataSource.THERMOSTAT].get(
|
||||
gw_vars.DATA_MASTER_MEMBERID
|
||||
)
|
||||
dev_reg.async_update_device(
|
||||
thermostat_device.id,
|
||||
manufacturer=status[OpenThermDataSource.THERMOSTAT].get(
|
||||
gw_vars.DATA_MASTER_MEMBERID
|
||||
),
|
||||
manufacturer=str(thermostat_manufacturer)
|
||||
if thermostat_manufacturer is not None
|
||||
else None,
|
||||
model_id=status[OpenThermDataSource.THERMOSTAT].get(
|
||||
gw_vars.DATA_MASTER_PRODUCT_TYPE
|
||||
),
|
||||
|
||||
@@ -17,7 +17,6 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
PRECISION_HALVES,
|
||||
PRECISION_TENTHS,
|
||||
PRECISION_WHOLE,
|
||||
@@ -54,9 +53,8 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle config flow initiation."""
|
||||
if info:
|
||||
name = info[CONF_NAME]
|
||||
device = info[CONF_DEVICE]
|
||||
gw_id = cv.slugify(info.get(CONF_ID, name))
|
||||
gw_id = cv.slugify(info[CONF_ID])
|
||||
|
||||
entries = [e.data for e in self._async_current_entries()]
|
||||
|
||||
@@ -83,7 +81,7 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except ConnectionError, SerialException:
|
||||
return self._show_form({"base": "cannot_connect"})
|
||||
|
||||
return self._create_entry(gw_id, name, device)
|
||||
return self._create_entry(gw_id, device)
|
||||
|
||||
return self._show_form()
|
||||
|
||||
@@ -99,20 +97,17 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=home-assistant-config-flow-name-field
|
||||
vol.Required(CONF_NAME): str,
|
||||
vol.Required(CONF_DEVICE): str,
|
||||
vol.Optional(CONF_ID): str,
|
||||
vol.Required(CONF_ID): str,
|
||||
}
|
||||
),
|
||||
errors=errors or {},
|
||||
)
|
||||
|
||||
def _create_entry(self, gw_id, name, device):
|
||||
def _create_entry(self, gw_id, device):
|
||||
"""Create entry for the OpenTherm Gateway device."""
|
||||
return self.async_create_entry(
|
||||
title=name, data={CONF_ID: gw_id, CONF_DEVICE: device, CONF_NAME: name}
|
||||
title="OpenTherm Gateway", data={CONF_ID: gw_id, CONF_DEVICE: device}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
"init": {
|
||||
"data": {
|
||||
"device": "Path or URL",
|
||||
"id": "ID",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
"id": "ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["opower==0.18.2"]
|
||||
"requirements": ["opower==0.18.3"]
|
||||
}
|
||||
|
||||
@@ -4,18 +4,21 @@ from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiohttp import ClientError
|
||||
from pyoverkiz.client import OverkizClient
|
||||
from pyoverkiz.const import SUPPORTED_SERVERS
|
||||
from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget
|
||||
from pyoverkiz.exceptions import (
|
||||
BadCredentialsException,
|
||||
MaintenanceException,
|
||||
NotAuthenticatedException,
|
||||
NotSuchTokenException,
|
||||
TooManyRequestsException,
|
||||
from pyoverkiz.auth.credentials import (
|
||||
LocalTokenCredentials,
|
||||
UsernamePasswordCredentials,
|
||||
)
|
||||
from pyoverkiz.models import Device, OverkizServer, Scenario
|
||||
from pyoverkiz.utils import generate_local_server
|
||||
from pyoverkiz.client import OverkizClient
|
||||
from pyoverkiz.enums import APIType, OverkizState, Server, UIClass, UIWidget
|
||||
from pyoverkiz.exceptions import (
|
||||
BadCredentialsError,
|
||||
MaintenanceError,
|
||||
NoSuchTokenError,
|
||||
NotAuthenticatedError,
|
||||
TooManyRequestsError,
|
||||
)
|
||||
from pyoverkiz.models import Device, PersistedActionGroup
|
||||
from pyoverkiz.utils import create_local_server_config
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -58,7 +61,7 @@ class HomeAssistantOverkizData:
|
||||
|
||||
coordinator: OverkizDataUpdateCoordinator
|
||||
platforms: defaultdict[Platform, list[Device]]
|
||||
scenarios: list[Scenario]
|
||||
scenarios: list[PersistedActionGroup]
|
||||
|
||||
|
||||
type OverkizDataConfigEntry = ConfigEntry[HomeAssistantOverkizData]
|
||||
@@ -90,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
|
||||
hass,
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
server=SUPPORTED_SERVERS[entry.data[CONF_HUB]],
|
||||
server=entry.data[CONF_HUB],
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -100,20 +103,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
|
||||
# Local API does expose scenarios, but they are not functional.
|
||||
# Tracked in https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/21
|
||||
if api_type == APIType.CLOUD:
|
||||
scenarios = await client.get_scenarios()
|
||||
scenarios = await client.get_action_groups()
|
||||
else:
|
||||
scenarios = []
|
||||
except (
|
||||
BadCredentialsException,
|
||||
NotSuchTokenException,
|
||||
NotAuthenticatedException,
|
||||
BadCredentialsError,
|
||||
NoSuchTokenError,
|
||||
NotAuthenticatedError,
|
||||
) as exception:
|
||||
raise ConfigEntryAuthFailed("Invalid authentication") from exception
|
||||
except TooManyRequestsException as exception:
|
||||
except TooManyRequestsError as exception:
|
||||
raise ConfigEntryNotReady("Too many requests, try again later") from exception
|
||||
except (TimeoutError, ClientError) as exception:
|
||||
raise ConfigEntryNotReady("Failed to connect") from exception
|
||||
except MaintenanceException as exception:
|
||||
except MaintenanceError as exception:
|
||||
raise ConfigEntryNotReady("Server is down for maintenance") from exception
|
||||
|
||||
coordinator = OverkizDataUpdateCoordinator(
|
||||
@@ -173,13 +176,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
|
||||
identifiers={(DOMAIN, gateway.id)},
|
||||
model=gateway.type.beautify_name if gateway.type else None,
|
||||
model_id=str(gateway.type),
|
||||
manufacturer=client.server.manufacturer,
|
||||
manufacturer=client.server_config.manufacturer,
|
||||
name=gateway.type.beautify_name if gateway.type else gateway.id,
|
||||
sw_version=gateway.connectivity.protocol_version,
|
||||
hw_version=f"{gateway.type}:{gateway.sub_type}"
|
||||
if gateway.type and gateway.sub_type
|
||||
else None,
|
||||
configuration_url=client.server.configuration_url,
|
||||
configuration_url=client.server_config.configuration_url,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
@@ -214,6 +217,9 @@ async def _async_migrate_strenum_unique_ids(
|
||||
"""Migrate entities to the StrEnum-style unique IDs."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
# Map enum members renamed in pyoverkiz 2.0 to their current names.
|
||||
renamed_enum_members = {"TSKALARM_CONTROLLER": "TSK_ALARM_CONTROLLER"}
|
||||
|
||||
@callback
|
||||
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:
|
||||
# Python 3.11 treats (str, Enum) and StrEnum
|
||||
@@ -229,6 +235,7 @@ async def _async_migrate_strenum_unique_ids(
|
||||
("OverkizState", "UIWidget", "UIClass")
|
||||
):
|
||||
state = key.split(".")[1]
|
||||
state = renamed_enum_members.get(state, state)
|
||||
new_key = ""
|
||||
|
||||
if key.startswith("UIClass"):
|
||||
@@ -276,17 +283,15 @@ def create_local_client(
|
||||
session = async_create_clientsession(hass, verify_ssl=verify_ssl)
|
||||
|
||||
return OverkizClient(
|
||||
username="",
|
||||
password="",
|
||||
token=token,
|
||||
server=create_local_server_config(host=host),
|
||||
credentials=LocalTokenCredentials(token),
|
||||
session=session,
|
||||
server=generate_local_server(host=host),
|
||||
verify_ssl=verify_ssl,
|
||||
)
|
||||
|
||||
|
||||
def create_cloud_client(
|
||||
hass: HomeAssistant, username: str, password: str, server: OverkizServer
|
||||
hass: HomeAssistant, username: str, password: str, server: Server
|
||||
) -> OverkizClient:
|
||||
"""Create Overkiz cloud client."""
|
||||
# To allow users with multiple accounts/hubs, we create a
|
||||
@@ -294,5 +299,7 @@ def create_cloud_client(
|
||||
session = async_create_clientsession(hass)
|
||||
|
||||
return OverkizClient(
|
||||
username=username, password=password, session=session, server=server
|
||||
server=server,
|
||||
credentials=UsernamePasswordCredentials(username, password),
|
||||
session=session,
|
||||
)
|
||||
|
||||
@@ -144,7 +144,7 @@ ALARM_DESCRIPTIONS: list[OverkizAlarmDescription] = [
|
||||
# Disabled by default since all Overkiz hubs have this
|
||||
# virtual device, but only a few users actually use this.
|
||||
OverkizAlarmDescription(
|
||||
key=UIWidget.TSKALARM_CONTROLLER,
|
||||
key=UIWidget.TSK_ALARM_CONTROLLER,
|
||||
entity_registry_enabled_default=False,
|
||||
supported_features=(
|
||||
AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
|
||||
@@ -165,7 +165,7 @@ async def async_setup_entry(
|
||||
description,
|
||||
)
|
||||
for state in device.definition.states
|
||||
if (description := SUPPORTED_STATES.get(state.qualified_name))
|
||||
if (description := SUPPORTED_STATES.get(state))
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -120,7 +120,7 @@ async def async_setup_entry(
|
||||
description,
|
||||
)
|
||||
for command in device.definition.commands
|
||||
if (description := SUPPORTED_COMMANDS.get(command.command_name))
|
||||
if (description := SUPPORTED_COMMANDS.get(command))
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -115,12 +115,13 @@ async def async_setup_entry(
|
||||
# Match devices based on the widget and protocol.
|
||||
# #ie Hitachi Air To Air Heat Pumps
|
||||
entities_based_on_widget_and_protocol: list[Entity] = [
|
||||
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol](
|
||||
device.device_url, data.coordinator
|
||||
)
|
||||
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][
|
||||
device.identifier.protocol
|
||||
](device.device_url, data.coordinator)
|
||||
for device in data.platforms[Platform.CLIMATE]
|
||||
if device.widget in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY
|
||||
and device.protocol in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget]
|
||||
and device.identifier.protocol
|
||||
in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget]
|
||||
]
|
||||
|
||||
async_add_entities(
|
||||
|
||||
+4
-2
@@ -157,7 +157,7 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature."""
|
||||
if state := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]:
|
||||
if state := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE):
|
||||
return state.value_as_float
|
||||
return None
|
||||
|
||||
@@ -165,7 +165,9 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if self.temperature_device is not None and (
|
||||
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
|
||||
temperature := self.temperature_device.states.get(
|
||||
OverkizState.CORE_TEMPERATURE
|
||||
)
|
||||
):
|
||||
return temperature.value_as_float
|
||||
return None
|
||||
|
||||
@@ -104,7 +104,9 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity):
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if self.temperature_device is not None and (
|
||||
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
|
||||
temperature := self.temperature_device.states.get(
|
||||
OverkizState.CORE_TEMPERATURE
|
||||
)
|
||||
):
|
||||
return cast(float, temperature.value)
|
||||
|
||||
|
||||
@@ -67,7 +67,9 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity):
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if self.temperature_device is not None and (
|
||||
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
|
||||
temperature := self.temperature_device.states.get(
|
||||
OverkizState.CORE_TEMPERATURE
|
||||
)
|
||||
):
|
||||
return cast(float, temperature.value)
|
||||
|
||||
|
||||
@@ -106,7 +106,9 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity):
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if self.temperature_device is not None and (
|
||||
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
|
||||
temperature := self.temperature_device.states.get(
|
||||
OverkizState.CORE_TEMPERATURE
|
||||
)
|
||||
):
|
||||
return cast(float, temperature.value)
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ class EvoHomeController(OverkizEntity, ClimateEntity):
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode, e.g., home, away, temp."""
|
||||
if (
|
||||
state := self.device.states[OverkizState.RAMSES_RAMSES_OPERATING_MODE]
|
||||
state := self.device.states.get(OverkizState.RAMSES_RAMSES_OPERATING_MODE)
|
||||
) and state.value_as_str in OVERKIZ_TO_PRESET_MODES:
|
||||
return OVERKIZ_TO_PRESET_MODES[state.value_as_str]
|
||||
|
||||
|
||||
@@ -114,13 +114,13 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return hvac operation ie. heat, cool mode."""
|
||||
if (
|
||||
main_op_state := self.device.states[MAIN_OPERATION_STATE]
|
||||
main_op_state := self.device.states.get(MAIN_OPERATION_STATE)
|
||||
) and main_op_state.value_as_str:
|
||||
if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF:
|
||||
return HVACMode.OFF
|
||||
|
||||
if (
|
||||
mode_change_state := self.device.states[MODE_CHANGE_STATE]
|
||||
mode_change_state := self.device.states.get(MODE_CHANGE_STATE)
|
||||
) and mode_change_state.value_as_str:
|
||||
sanitized_value = mode_change_state.value_as_str.lower()
|
||||
return OVERKIZ_TO_HVAC_MODES[sanitized_value]
|
||||
@@ -140,7 +140,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the fan setting."""
|
||||
if (state := self.device.states[FAN_SPEED_STATE]) and state.value_as_str:
|
||||
if (state := self.device.states.get(FAN_SPEED_STATE)) and state.value_as_str:
|
||||
return OVERKIZ_TO_FAN_MODES[state.value_as_str]
|
||||
|
||||
return None
|
||||
@@ -157,7 +157,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
|
||||
@property
|
||||
def swing_mode(self) -> str | None:
|
||||
"""Return the swing setting."""
|
||||
if (state := self.device.states[SWING_STATE]) and state.value_as_str:
|
||||
if (state := self.device.states.get(SWING_STATE)) and state.value_as_str:
|
||||
return OVERKIZ_TO_SWING_MODES[state.value_as_str]
|
||||
|
||||
return None
|
||||
@@ -170,7 +170,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
|
||||
def target_temperature(self) -> int | None:
|
||||
"""Return the temperature."""
|
||||
if (
|
||||
temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
|
||||
temperature := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE)
|
||||
) and temperature.value_as_int:
|
||||
return temperature.value_as_int
|
||||
|
||||
@@ -179,7 +179,9 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
|
||||
@property
|
||||
def current_temperature(self) -> int | None:
|
||||
"""Return current temperature."""
|
||||
if (state := self.device.states[ROOM_TEMPERATURE_STATE]) and state.value_as_int:
|
||||
if (
|
||||
state := self.device.states.get(ROOM_TEMPERATURE_STATE)
|
||||
) and state.value_as_int:
|
||||
return state.value_as_int
|
||||
|
||||
return None
|
||||
@@ -192,7 +194,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode, e.g., home, away, temp."""
|
||||
if (state := self.device.states[LEAVE_HOME_STATE]) and state.value_as_str:
|
||||
if (state := self.device.states.get(LEAVE_HOME_STATE)) and state.value_as_str:
|
||||
if state.value_as_str == OverkizCommandParam.ON:
|
||||
return PRESET_HOLIDAY_MODE
|
||||
|
||||
@@ -222,7 +224,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
|
||||
"""
|
||||
if value:
|
||||
return value
|
||||
state = self.device.states[state_name]
|
||||
state = self.device.states.get(state_name)
|
||||
if state and state.value_as_str:
|
||||
return state.value_as_str
|
||||
return fallback_value
|
||||
|
||||
@@ -118,13 +118,13 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return hvac operation ie. heat, cool mode."""
|
||||
if (
|
||||
main_op_state := self.device.states[OverkizState.OVP_MAIN_OPERATION]
|
||||
main_op_state := self.device.states.get(OverkizState.OVP_MAIN_OPERATION)
|
||||
) and main_op_state.value_as_str:
|
||||
if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF:
|
||||
return HVACMode.OFF
|
||||
|
||||
if (
|
||||
mode_change_state := self.device.states[OverkizState.OVP_MODE_CHANGE]
|
||||
mode_change_state := self.device.states.get(OverkizState.OVP_MODE_CHANGE)
|
||||
) and mode_change_state.value_as_str:
|
||||
# The OVP protocol has 'auto cooling' and 'auto heating' values
|
||||
# that are equivalent to the HLRRWIFI protocol without spaces
|
||||
@@ -147,7 +147,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the fan setting."""
|
||||
if (
|
||||
state := self.device.states[OverkizState.OVP_FAN_SPEED]
|
||||
state := self.device.states.get(OverkizState.OVP_FAN_SPEED)
|
||||
) and state.value_as_str:
|
||||
return OVERKIZ_TO_FAN_MODES[state.value_as_str]
|
||||
|
||||
@@ -160,7 +160,9 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
|
||||
@property
|
||||
def swing_mode(self) -> str | None:
|
||||
"""Return the swing setting."""
|
||||
if (state := self.device.states[OverkizState.OVP_SWING]) and state.value_as_str:
|
||||
if (
|
||||
state := self.device.states.get(OverkizState.OVP_SWING)
|
||||
) and state.value_as_str:
|
||||
return OVERKIZ_TO_SWING_MODES[state.value_as_str]
|
||||
|
||||
return None
|
||||
@@ -173,7 +175,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
|
||||
def target_temperature(self) -> int | None:
|
||||
"""Return the target temperature."""
|
||||
if (
|
||||
temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
|
||||
temperature := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE)
|
||||
) and temperature.value_as_int:
|
||||
return temperature.value_as_int
|
||||
|
||||
@@ -183,7 +185,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
|
||||
def current_temperature(self) -> int | None:
|
||||
"""Return current temperature."""
|
||||
if (
|
||||
state := self.device.states[OverkizState.OVP_ROOM_TEMPERATURE]
|
||||
state := self.device.states.get(OverkizState.OVP_ROOM_TEMPERATURE)
|
||||
) and state.value_as_int:
|
||||
return state.value_as_int
|
||||
|
||||
@@ -197,7 +199,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode, e.g., home, away, temp."""
|
||||
if (
|
||||
state := self.device.states[OverkizState.CORE_HOLIDAYS_MODE]
|
||||
state := self.device.states.get(OverkizState.CORE_HOLIDAYS_MODE)
|
||||
) and state.value_as_str:
|
||||
if state.value_as_str == OverkizCommandParam.ON:
|
||||
return PRESET_HOLIDAY_MODE
|
||||
@@ -225,7 +227,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
|
||||
def auto_manu_mode(self) -> str | None:
|
||||
"""Return auto/manu mode."""
|
||||
if (
|
||||
state := self.device.states[OverkizState.CORE_AUTO_MANU_MODE]
|
||||
state := self.device.states.get(OverkizState.CORE_AUTO_MANU_MODE)
|
||||
) and state.value_as_str:
|
||||
return state.value_as_str
|
||||
return None
|
||||
@@ -235,7 +237,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
|
||||
def temperature_change(self) -> int | None:
|
||||
"""Return temperature change state."""
|
||||
if (
|
||||
state := self.device.states[OverkizState.OVP_TEMPERATURE_CHANGE]
|
||||
state := self.device.states.get(OverkizState.OVP_TEMPERATURE_CHANGE)
|
||||
) and state.value_as_int:
|
||||
return state.value_as_int
|
||||
|
||||
@@ -266,7 +268,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
|
||||
"""
|
||||
if value:
|
||||
return value
|
||||
if (state := self.device.states[state_name]) is not None and (
|
||||
if (state := self.device.states.get(state_name)) is not None and (
|
||||
value := state.value_as_str
|
||||
) is not None:
|
||||
return value
|
||||
|
||||
@@ -60,7 +60,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return hvac operation ie. heat, cool mode."""
|
||||
if (
|
||||
state := self.device.states[OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1]
|
||||
state := self.device.states.get(OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1)
|
||||
) and state.value_as_str:
|
||||
return OVERKIZ_TO_HVAC_MODE[state.value_as_str]
|
||||
|
||||
@@ -76,7 +76,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode, e.g., home, away, temp."""
|
||||
if (
|
||||
state := self.device.states[OverkizState.MODBUS_YUTAKI_TARGET_MODE]
|
||||
state := self.device.states.get(OverkizState.MODBUS_YUTAKI_TARGET_MODE)
|
||||
) and state.value_as_str:
|
||||
return OVERKIZ_TO_PRESET_MODE[state.value_as_str]
|
||||
|
||||
@@ -91,9 +91,9 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
current_temperature = self.device.states[
|
||||
current_temperature = self.device.states.get(
|
||||
OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1
|
||||
]
|
||||
)
|
||||
|
||||
if current_temperature:
|
||||
return current_temperature.value_as_float
|
||||
@@ -103,9 +103,9 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
target_temperature = self.device.states[
|
||||
target_temperature = self.device.states.get(
|
||||
OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE_1
|
||||
]
|
||||
)
|
||||
|
||||
if target_temperature:
|
||||
return target_temperature.value_as_float
|
||||
|
||||
@@ -99,14 +99,14 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return hvac operation i.e. heat, cool mode."""
|
||||
state = self.device.states[OverkizState.CORE_ON_OFF]
|
||||
state = self.device.states.get(OverkizState.CORE_ON_OFF)
|
||||
if state and state.value_as_str == OverkizCommandParam.OFF:
|
||||
return HVACMode.OFF
|
||||
|
||||
if (
|
||||
state := self.device.states[
|
||||
state := self.device.states.get(
|
||||
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_ACTIVE_MODE
|
||||
]
|
||||
)
|
||||
) and state.value_as_str:
|
||||
return OVERKIZ_TO_HVAC_MODES[state.value_as_str]
|
||||
|
||||
@@ -127,9 +127,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode, e.g., home, away, temp."""
|
||||
if (
|
||||
state := self.device.states[
|
||||
state := self.device.states.get(
|
||||
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE
|
||||
]
|
||||
)
|
||||
) and state.value_as_str:
|
||||
return OVERKIZ_TO_PRESET_MODES[state.value_as_str]
|
||||
return None
|
||||
@@ -145,9 +145,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the current running hvac operation if supported."""
|
||||
if (
|
||||
current_operation := self.device.states[
|
||||
current_operation := self.device.states.get(
|
||||
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_OPERATING_MODE
|
||||
]
|
||||
)
|
||||
) and current_operation.value_as_str:
|
||||
return OVERKIZ_TO_HVAC_ACTION[current_operation.value_as_str]
|
||||
|
||||
@@ -167,7 +167,7 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
|
||||
if mode not in MAP_PRESET_TEMPERATURES:
|
||||
return None
|
||||
|
||||
if state := self.device.states[MAP_PRESET_TEMPERATURES[mode]]:
|
||||
if state := self.device.states.get(MAP_PRESET_TEMPERATURES[mode]):
|
||||
return state.value_as_float
|
||||
return None
|
||||
|
||||
@@ -175,7 +175,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if self.temperature_device is not None and (
|
||||
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
|
||||
temperature := self.temperature_device.states.get(
|
||||
OverkizState.CORE_TEMPERATURE
|
||||
)
|
||||
):
|
||||
return temperature.value_as_float
|
||||
return None
|
||||
@@ -185,9 +187,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
|
||||
temperature = kwargs[ATTR_TEMPERATURE]
|
||||
|
||||
if (
|
||||
mode := self.device.states[
|
||||
mode := self.device.states.get(
|
||||
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE
|
||||
]
|
||||
)
|
||||
) and mode.value_as_str:
|
||||
await self.executor.async_execute_command(
|
||||
SETPOINT_MODE_TO_OVERKIZ_COMMAND[mode.value_as_str], temperature
|
||||
|
||||
@@ -40,10 +40,10 @@ OVERKIZ_TO_PRESET_MODES: dict[OverkizCommandParam, str] = {
|
||||
|
||||
PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()}
|
||||
TARGET_TEMP_TO_OVERKIZ = {
|
||||
PRESET_HOME: OverkizState.SOMFY_THERMOSTAT_AT_HOME_TARGET_TEMPERATURE,
|
||||
PRESET_AWAY: OverkizState.SOMFY_THERMOSTAT_AWAY_MODE_TARGET_TEMPERATURE,
|
||||
PRESET_FREEZE: OverkizState.SOMFY_THERMOSTAT_FREEZE_MODE_TARGET_TEMPERATURE,
|
||||
PRESET_NIGHT: OverkizState.SOMFY_THERMOSTAT_SLEEPING_MODE_TARGET_TEMPERATURE,
|
||||
PRESET_HOME: OverkizState.SOMFYTHERMOSTAT_AT_HOME_TARGET_TEMPERATURE,
|
||||
PRESET_AWAY: OverkizState.SOMFYTHERMOSTAT_AWAY_MODE_TARGET_TEMPERATURE,
|
||||
PRESET_FREEZE: OverkizState.SOMFYTHERMOSTAT_FREEZE_MODE_TARGET_TEMPERATURE,
|
||||
PRESET_NIGHT: OverkizState.SOMFYTHERMOSTAT_SLEEPING_MODE_TARGET_TEMPERATURE,
|
||||
}
|
||||
|
||||
# controllableName is somfythermostat:SomfyThermostatTemperatureSensor
|
||||
@@ -88,9 +88,9 @@ class SomfyThermostat(OverkizEntity, ClimateEntity):
|
||||
def preset_mode(self) -> str:
|
||||
"""Return the current preset mode, e.g., home, away, temp."""
|
||||
if self.hvac_mode == HVACMode.AUTO:
|
||||
state_key = OverkizState.SOMFY_THERMOSTAT_HEATING_MODE
|
||||
state_key = OverkizState.SOMFYTHERMOSTAT_HEATING_MODE
|
||||
else:
|
||||
state_key = OverkizState.SOMFY_THERMOSTAT_DEROGATION_HEATING_MODE
|
||||
state_key = OverkizState.SOMFYTHERMOSTAT_DEROGATION_HEATING_MODE
|
||||
|
||||
if state := self.executor.select_state(state_key):
|
||||
return OVERKIZ_TO_PRESET_MODES[OverkizCommandParam(cast(str, state))]
|
||||
@@ -101,7 +101,9 @@ class SomfyThermostat(OverkizEntity, ClimateEntity):
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if self.temperature_device is not None and (
|
||||
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
|
||||
temperature := self.temperature_device.states.get(
|
||||
OverkizState.CORE_TEMPERATURE
|
||||
)
|
||||
):
|
||||
return cast(float, temperature.value)
|
||||
return None
|
||||
|
||||
@@ -91,7 +91,9 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if self.temperature_device is not None and (
|
||||
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
|
||||
temperature := self.temperature_device.states.get(
|
||||
OverkizState.CORE_TEMPERATURE
|
||||
)
|
||||
):
|
||||
return temperature.value_as_float
|
||||
|
||||
|
||||
@@ -4,21 +4,25 @@ from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohttp import ClientConnectorCertificateError, ClientError
|
||||
from pyoverkiz.auth.credentials import (
|
||||
LocalTokenCredentials,
|
||||
UsernamePasswordCredentials,
|
||||
)
|
||||
from pyoverkiz.client import OverkizClient
|
||||
from pyoverkiz.const import SERVERS_WITH_LOCAL_API, SUPPORTED_SERVERS
|
||||
from pyoverkiz.enums import APIType, Server
|
||||
from pyoverkiz.exceptions import (
|
||||
BadCredentialsException,
|
||||
CozyTouchBadCredentialsException,
|
||||
MaintenanceException,
|
||||
NotAuthenticatedException,
|
||||
NotSuchTokenException,
|
||||
TooManyAttemptsBannedException,
|
||||
TooManyRequestsException,
|
||||
UnknownUserException,
|
||||
BadCredentialsError,
|
||||
CozyTouchBadCredentialsError,
|
||||
MaintenanceError,
|
||||
NoSuchTokenError,
|
||||
NotAuthenticatedError,
|
||||
TooManyAttemptsBannedError,
|
||||
TooManyRequestsError,
|
||||
UnknownUserError,
|
||||
)
|
||||
from pyoverkiz.obfuscate import obfuscate_id
|
||||
from pyoverkiz.utils import generate_local_server, is_overkiz_gateway
|
||||
from pyoverkiz.utils import create_local_server_config, is_overkiz_gateway
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
@@ -58,19 +62,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.hass, verify_ssl=user_input[CONF_VERIFY_SSL]
|
||||
)
|
||||
client = OverkizClient(
|
||||
username="",
|
||||
password="",
|
||||
token=user_input[CONF_TOKEN],
|
||||
server=create_local_server_config(host=user_input[CONF_HOST]),
|
||||
credentials=LocalTokenCredentials(user_input[CONF_TOKEN]),
|
||||
session=session,
|
||||
server=generate_local_server(host=user_input[CONF_HOST]),
|
||||
verify_ssl=user_input[CONF_VERIFY_SSL],
|
||||
)
|
||||
else: # APIType.CLOUD
|
||||
session = async_create_clientsession(self.hass)
|
||||
client = OverkizClient(
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
server=SUPPORTED_SERVERS[user_input[CONF_HUB]],
|
||||
server=user_input[CONF_HUB],
|
||||
credentials=UsernamePasswordCredentials(
|
||||
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
),
|
||||
session=session,
|
||||
)
|
||||
|
||||
@@ -149,9 +152,9 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
await self.async_validate_input(user_input)
|
||||
except TooManyRequestsException:
|
||||
except TooManyRequestsError:
|
||||
errors["base"] = "too_many_requests"
|
||||
except (BadCredentialsException, NotAuthenticatedException) as exception:
|
||||
except (BadCredentialsError, NotAuthenticatedError) as exception:
|
||||
# If authentication with CozyTouch auth server is
|
||||
# valid, but token is invalid for Overkiz API
|
||||
# server, the hardware is not supported.
|
||||
@@ -159,18 +162,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
Server.ATLANTIC_COZYTOUCH,
|
||||
Server.SAUTER_COZYTOUCH,
|
||||
Server.THERMOR_COZYTOUCH,
|
||||
} and not isinstance(exception, CozyTouchBadCredentialsException):
|
||||
} and not isinstance(exception, CozyTouchBadCredentialsError):
|
||||
description_placeholders["unsupported_device"] = "CozyTouch"
|
||||
errors["base"] = "unsupported_hardware"
|
||||
else:
|
||||
errors["base"] = "invalid_auth"
|
||||
except TimeoutError, ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except MaintenanceException:
|
||||
except MaintenanceError:
|
||||
errors["base"] = "server_in_maintenance"
|
||||
except TooManyAttemptsBannedException:
|
||||
except TooManyAttemptsBannedError:
|
||||
errors["base"] = "too_many_attempts"
|
||||
except UnknownUserException:
|
||||
except UnknownUserError:
|
||||
# If the user has no supported CozyTouch devices on
|
||||
# the Overkiz API server. Login will return unknown user.
|
||||
if user_input[CONF_HUB] in {
|
||||
@@ -239,12 +242,12 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
user_input = await self.async_validate_input(user_input)
|
||||
except TooManyRequestsException:
|
||||
except TooManyRequestsError:
|
||||
errors["base"] = "too_many_requests"
|
||||
except (
|
||||
BadCredentialsException,
|
||||
NotSuchTokenException,
|
||||
NotAuthenticatedException,
|
||||
BadCredentialsError,
|
||||
NoSuchTokenError,
|
||||
NotAuthenticatedError,
|
||||
):
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientConnectorCertificateError as exception:
|
||||
@@ -253,11 +256,11 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except (TimeoutError, ClientError) as exception:
|
||||
errors["base"] = "cannot_connect"
|
||||
LOGGER.debug(exception)
|
||||
except MaintenanceException:
|
||||
except MaintenanceError:
|
||||
errors["base"] = "server_in_maintenance"
|
||||
except TooManyAttemptsBannedException:
|
||||
except TooManyAttemptsBannedError:
|
||||
errors["base"] = "too_many_attempts"
|
||||
except UnknownUserException:
|
||||
except UnknownUserError:
|
||||
# Somfy Protect accounts are not supported since they don't use
|
||||
# the Overkiz API server. Login will return unknown user.
|
||||
description_placeholders["unsupported_device"] = "Somfy Protect"
|
||||
|
||||
@@ -118,7 +118,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = {
|
||||
UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH,
|
||||
UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
|
||||
UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH,
|
||||
UIWidget.TSKALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
|
||||
UIWidget.TSK_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
|
||||
UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE,
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,23 @@ from aiohttp import ClientConnectorError, ServerDisconnectedError
|
||||
from pyoverkiz.client import OverkizClient
|
||||
from pyoverkiz.enums import EventName, ExecutionState, Protocol
|
||||
from pyoverkiz.exceptions import (
|
||||
BadCredentialsException,
|
||||
InvalidEventListenerIdException,
|
||||
MaintenanceException,
|
||||
NotAuthenticatedException,
|
||||
ServiceUnavailableException,
|
||||
TooManyConcurrentRequestsException,
|
||||
TooManyRequestsException,
|
||||
BadCredentialsError,
|
||||
InvalidEventListenerIdError,
|
||||
MaintenanceError,
|
||||
NotAuthenticatedError,
|
||||
ServiceUnavailableError,
|
||||
TooManyConcurrentRequestsError,
|
||||
TooManyRequestsError,
|
||||
)
|
||||
from pyoverkiz.models import (
|
||||
Device,
|
||||
DeviceEvent,
|
||||
DeviceRemovedEvent,
|
||||
DeviceStateChangedEvent,
|
||||
ExecutionRegisteredEvent,
|
||||
ExecutionStateChangedEvent,
|
||||
Place,
|
||||
)
|
||||
from pyoverkiz.models import Device, Event, Place
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
@@ -30,8 +38,9 @@ if TYPE_CHECKING:
|
||||
|
||||
from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, LOGGER, UPDATE_INTERVAL
|
||||
|
||||
# Events are a discriminated union; each handler narrows to its own subtype.
|
||||
EVENT_HANDLERS: Registry[
|
||||
str, Callable[[OverkizDataUpdateCoordinator, Event], Coroutine[Any, Any, None]]
|
||||
str, Callable[[OverkizDataUpdateCoordinator, Any], Coroutine[Any, Any, None]]
|
||||
] = Registry()
|
||||
|
||||
|
||||
@@ -68,7 +77,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
self._default_update_interval = UPDATE_INTERVAL
|
||||
|
||||
self.is_stateless = all(
|
||||
device.protocol in (Protocol.RTS, Protocol.INTERNAL)
|
||||
device.identifier.protocol in (Protocol.RTS, Protocol.INTERNAL)
|
||||
for device in devices
|
||||
if device.widget not in IGNORED_OVERKIZ_DEVICES
|
||||
and device.ui_class not in IGNORED_OVERKIZ_DEVICES
|
||||
@@ -78,17 +87,17 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
"""Fetch Overkiz data via event listener."""
|
||||
try:
|
||||
events = await self.client.fetch_events()
|
||||
except (BadCredentialsException, NotAuthenticatedException) as exception:
|
||||
except (BadCredentialsError, NotAuthenticatedError) as exception:
|
||||
raise ConfigEntryAuthFailed("Invalid authentication.") from exception
|
||||
except TooManyConcurrentRequestsException as exception:
|
||||
except TooManyConcurrentRequestsError as exception:
|
||||
raise UpdateFailed("Too many concurrent requests.") from exception
|
||||
except TooManyRequestsException as exception:
|
||||
except TooManyRequestsError as exception:
|
||||
raise UpdateFailed("Too many requests, try again later.") from exception
|
||||
except MaintenanceException as exception:
|
||||
except MaintenanceError as exception:
|
||||
raise UpdateFailed("Server is down for maintenance.") from exception
|
||||
except ServiceUnavailableException as exception:
|
||||
except ServiceUnavailableError as exception:
|
||||
raise UpdateFailed("Server is unavailable.") from exception
|
||||
except InvalidEventListenerIdException as exception:
|
||||
except InvalidEventListenerIdError as exception:
|
||||
raise UpdateFailed(exception) from exception
|
||||
except (TimeoutError, ClientConnectorError) as exception:
|
||||
LOGGER.debug("Failed to connect", exc_info=True)
|
||||
@@ -100,9 +109,9 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
try:
|
||||
await self.client.login()
|
||||
self.devices = await self._get_devices()
|
||||
except (BadCredentialsException, NotAuthenticatedException) as exception:
|
||||
except (BadCredentialsError, NotAuthenticatedError) as exception:
|
||||
raise ConfigEntryAuthFailed("Invalid authentication.") from exception
|
||||
except TooManyRequestsException as exception:
|
||||
except TooManyRequestsError as exception:
|
||||
raise UpdateFailed("Too many requests, try again later.") from exception
|
||||
|
||||
return self.devices
|
||||
@@ -144,27 +153,27 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
|
||||
@EVENT_HANDLERS.register(EventName.DEVICE_AVAILABLE)
|
||||
async def on_device_available(
|
||||
coordinator: OverkizDataUpdateCoordinator, event: Event
|
||||
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
|
||||
) -> None:
|
||||
"""Handle device available event."""
|
||||
if event.device_url and event.device_url in coordinator.devices:
|
||||
if event.device_url in coordinator.devices:
|
||||
coordinator.devices[event.device_url].available = True
|
||||
|
||||
|
||||
@EVENT_HANDLERS.register(EventName.DEVICE_UNAVAILABLE)
|
||||
@EVENT_HANDLERS.register(EventName.DEVICE_DISABLED)
|
||||
async def on_device_unavailable_disabled(
|
||||
coordinator: OverkizDataUpdateCoordinator, event: Event
|
||||
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
|
||||
) -> None:
|
||||
"""Handle device unavailable / disabled event."""
|
||||
if event.device_url and event.device_url in coordinator.devices:
|
||||
if event.device_url in coordinator.devices:
|
||||
coordinator.devices[event.device_url].available = False
|
||||
|
||||
|
||||
@EVENT_HANDLERS.register(EventName.DEVICE_CREATED)
|
||||
@EVENT_HANDLERS.register(EventName.DEVICE_UPDATED)
|
||||
async def on_device_created_updated(
|
||||
coordinator: OverkizDataUpdateCoordinator, event: Event
|
||||
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
|
||||
) -> None:
|
||||
"""Handle device unavailable / disabled event."""
|
||||
coordinator.hass.async_create_task(
|
||||
@@ -174,10 +183,10 @@ async def on_device_created_updated(
|
||||
|
||||
@EVENT_HANDLERS.register(EventName.DEVICE_STATE_CHANGED)
|
||||
async def on_device_state_changed(
|
||||
coordinator: OverkizDataUpdateCoordinator, event: Event
|
||||
coordinator: OverkizDataUpdateCoordinator, event: DeviceStateChangedEvent
|
||||
) -> None:
|
||||
"""Handle device state changed event."""
|
||||
if not event.device_url or event.device_url not in coordinator.devices:
|
||||
if event.device_url not in coordinator.devices:
|
||||
return
|
||||
|
||||
for state in event.device_states:
|
||||
@@ -187,12 +196,9 @@ async def on_device_state_changed(
|
||||
|
||||
@EVENT_HANDLERS.register(EventName.DEVICE_REMOVED)
|
||||
async def on_device_removed(
|
||||
coordinator: OverkizDataUpdateCoordinator, event: Event
|
||||
coordinator: OverkizDataUpdateCoordinator, event: DeviceRemovedEvent
|
||||
) -> None:
|
||||
"""Handle device removed event."""
|
||||
if not event.device_url:
|
||||
return
|
||||
|
||||
base_device_url = event.device_url.split("#")[0]
|
||||
registry = dr.async_get(coordinator.hass)
|
||||
|
||||
@@ -201,16 +207,16 @@ async def on_device_removed(
|
||||
):
|
||||
registry.async_remove_device(registered_device.id)
|
||||
|
||||
if event.device_url and event.device_url in coordinator.devices:
|
||||
if event.device_url in coordinator.devices:
|
||||
del coordinator.devices[event.device_url]
|
||||
|
||||
|
||||
@EVENT_HANDLERS.register(EventName.EXECUTION_REGISTERED)
|
||||
async def on_execution_registered(
|
||||
coordinator: OverkizDataUpdateCoordinator, event: Event
|
||||
coordinator: OverkizDataUpdateCoordinator, event: ExecutionRegisteredEvent
|
||||
) -> None:
|
||||
"""Handle execution registered event."""
|
||||
if event.exec_id and event.exec_id not in coordinator.executions:
|
||||
if event.exec_id not in coordinator.executions:
|
||||
coordinator.executions[event.exec_id] = {}
|
||||
|
||||
if not coordinator.is_stateless:
|
||||
@@ -219,7 +225,7 @@ async def on_execution_registered(
|
||||
|
||||
@EVENT_HANDLERS.register(EventName.EXECUTION_STATE_CHANGED)
|
||||
async def on_execution_state_changed(
|
||||
coordinator: OverkizDataUpdateCoordinator, event: Event
|
||||
coordinator: OverkizDataUpdateCoordinator, event: ExecutionStateChangedEvent
|
||||
) -> None:
|
||||
"""Handle execution changed event."""
|
||||
if event.exec_id in coordinator.executions and event.new_state in [
|
||||
|
||||
@@ -631,7 +631,7 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
|
||||
"""
|
||||
state_name = self.entity_description.current_position_state
|
||||
|
||||
if not state_name or not (state := self.device.states[state_name]):
|
||||
if not state_name or not (state := self.device.states.get(state_name)):
|
||||
return None
|
||||
|
||||
position = state.value_as_int
|
||||
@@ -645,9 +645,9 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
|
||||
state_name,
|
||||
)
|
||||
|
||||
if fallback_state := self.device.states[
|
||||
if fallback_state := self.device.states.get(
|
||||
OverkizState.CORE_MEMORIZED_1_POSITION
|
||||
]:
|
||||
):
|
||||
position = fallback_state.value_as_int
|
||||
else:
|
||||
return None
|
||||
@@ -661,7 +661,9 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
|
||||
state_name,
|
||||
)
|
||||
|
||||
if fallback_state := self.device.states[OverkizState.CORE_TARGET_CLOSURE]:
|
||||
if fallback_state := self.device.states.get(
|
||||
OverkizState.CORE_TARGET_CLOSURE
|
||||
):
|
||||
position = fallback_state.value_as_int
|
||||
else:
|
||||
return None
|
||||
@@ -707,7 +709,7 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
|
||||
"""
|
||||
state_name = self.entity_description.current_tilt_position_state
|
||||
|
||||
if state_name and (state := self.device.states[state_name]):
|
||||
if state_name and (state := self.device.states.get(state_name)):
|
||||
position = state.value_as_int
|
||||
if position is None:
|
||||
return None
|
||||
|
||||
@@ -19,13 +19,13 @@ async def async_get_config_entry_diagnostics(
|
||||
client = entry.runtime_data.coordinator.client
|
||||
|
||||
data = {
|
||||
"setup": await client.get_diagnostic_data(),
|
||||
**await client.get_diagnostic_data(),
|
||||
"server": entry.data[CONF_HUB],
|
||||
"api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD),
|
||||
}
|
||||
|
||||
# Only Overkiz cloud servers expose an endpoint with execution history
|
||||
if client.api_type == APIType.CLOUD:
|
||||
if client.server_config.api_type == APIType.CLOUD:
|
||||
execution_history = [
|
||||
repr(execution) for execution in await client.get_execution_history()
|
||||
]
|
||||
@@ -49,13 +49,13 @@ async def async_get_device_diagnostics(
|
||||
"device_url": obfuscate_id(device_url),
|
||||
"model": device.model,
|
||||
},
|
||||
"setup": await client.get_diagnostic_data(),
|
||||
**await client.get_diagnostic_data(),
|
||||
"server": entry.data[CONF_HUB],
|
||||
"api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD),
|
||||
}
|
||||
|
||||
# Only Overkiz cloud servers expose an endpoint with execution history
|
||||
if client.api_type == APIType.CLOUD:
|
||||
if client.server_config.api_type == APIType.CLOUD:
|
||||
data["execution_history"] = [
|
||||
repr(execution)
|
||||
for execution in await client.get_execution_history()
|
||||
|
||||
@@ -49,7 +49,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
|
||||
|
||||
# Workaround: local API may incorrectly report
|
||||
# available=False (Somfy-TaHoma-Developer-Mode#217)
|
||||
if self.coordinator.client.api_type != APIType.LOCAL:
|
||||
if self.coordinator.client.server_config.api_type != APIType.LOCAL:
|
||||
return False
|
||||
|
||||
if status_state := self.device.states.get(OverkizState.CORE_STATUS):
|
||||
@@ -85,7 +85,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
|
||||
manufacturer = (
|
||||
self.executor.select_attribute(OverkizAttribute.CORE_MANUFACTURER)
|
||||
or self.executor.select_state(OverkizState.CORE_MANUFACTURER_NAME)
|
||||
or self.coordinator.client.server.manufacturer
|
||||
or self.coordinator.client.server_config.manufacturer
|
||||
)
|
||||
|
||||
model = (
|
||||
@@ -116,7 +116,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
|
||||
hw_version=self.device.controllable_name,
|
||||
suggested_area=suggested_area,
|
||||
via_device=(DOMAIN, self.executor.get_gateway_id()),
|
||||
configuration_url=self.coordinator.client.server.configuration_url,
|
||||
configuration_url=self.coordinator.client.server_config.configuration_url,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Class for helpers and communication with the OverKiz API."""
|
||||
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pyoverkiz.enums import OverkizCommand, Protocol
|
||||
from pyoverkiz.exceptions import BaseOverkizException
|
||||
from pyoverkiz.models import Command, Device, StateDefinition
|
||||
from pyoverkiz.exceptions import BaseOverkizError
|
||||
from pyoverkiz.models import Action, Command, Device, StateDefinition
|
||||
from pyoverkiz.types import StateType as OverkizStateType
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -56,15 +56,15 @@ class OverkizExecutor:
|
||||
|
||||
def select_definition_state(self, *states: str) -> StateDefinition | None:
|
||||
"""Select first existing definition state in a list of states."""
|
||||
for existing_state in self.device.definition.states:
|
||||
if existing_state.qualified_name in states:
|
||||
return existing_state
|
||||
for state_name in states:
|
||||
if state_name in self.device.definition.states:
|
||||
return self.device.definition.states[state_name]
|
||||
return None
|
||||
|
||||
def select_state(self, *states: str) -> OverkizStateType:
|
||||
"""Select first existing active state in a list of states."""
|
||||
for state in states:
|
||||
if current_state := self.device.states[state]:
|
||||
if current_state := self.device.states.get(state):
|
||||
return current_state.value
|
||||
|
||||
return None
|
||||
@@ -76,7 +76,7 @@ class OverkizExecutor:
|
||||
def select_attribute(self, *attributes: str) -> OverkizStateType:
|
||||
"""Select first existing active state in a list of states."""
|
||||
for attribute in attributes:
|
||||
if current_attribute := self.device.attributes[attribute]:
|
||||
if current_attribute := self.device.attributes.get(attribute):
|
||||
return current_attribute.value
|
||||
|
||||
return None
|
||||
@@ -94,19 +94,23 @@ class OverkizExecutor:
|
||||
# Set the execution duration to 0 seconds for RTS devices on supported commands
|
||||
# Default execution duration is 30 seconds and will block consecutive commands
|
||||
if (
|
||||
self.device.protocol == Protocol.RTS
|
||||
self.device.identifier.protocol == Protocol.RTS
|
||||
and command_name not in COMMANDS_WITHOUT_DELAY
|
||||
):
|
||||
parameters.append(0)
|
||||
|
||||
try:
|
||||
exec_id = await self.coordinator.client.execute_command(
|
||||
self.device.device_url,
|
||||
Command(command_name, parameters),
|
||||
"Home Assistant",
|
||||
exec_id = await self.coordinator.client.execute_action_group(
|
||||
label="Home Assistant",
|
||||
actions=[
|
||||
Action(
|
||||
device_url=self.device.device_url,
|
||||
commands=[Command(name=command_name, parameters=parameters)],
|
||||
)
|
||||
],
|
||||
)
|
||||
# Catch Overkiz exceptions to support `continue_on_error` functionality
|
||||
except BaseOverkizException as exception:
|
||||
except BaseOverkizError as exception:
|
||||
raise HomeAssistantError(exception) from exception
|
||||
|
||||
# ExecutionRegisteredEvent doesn't contain the
|
||||
@@ -142,18 +146,16 @@ class OverkizExecutor:
|
||||
return True
|
||||
|
||||
# Retrieve executions initiated outside Home Assistant via API
|
||||
executions = cast(Any, await self.coordinator.client.get_current_executions())
|
||||
# executions.action_group is typed incorrectly in the upstream library
|
||||
# or the below code is incorrect.
|
||||
executions = await self.coordinator.client.get_current_executions()
|
||||
exec_id = next(
|
||||
(
|
||||
execution.id
|
||||
for execution in executions
|
||||
# Reverse dictionary to cancel the last added execution
|
||||
for action in reversed(execution.action_group.get("actions"))
|
||||
for command in action.get("commands")
|
||||
if action.get("device_url") == self.device.device_url
|
||||
and command.get("name") in commands_to_cancel
|
||||
if execution.action_group
|
||||
for action in reversed(execution.action_group.actions)
|
||||
for command in action.commands
|
||||
if action.device_url == self.device.device_url
|
||||
and command.name in commands_to_cancel
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -166,7 +168,7 @@ class OverkizExecutor:
|
||||
|
||||
async def async_cancel_execution(self, exec_id: str) -> None:
|
||||
"""Cancel running execution via execution id."""
|
||||
await self.coordinator.client.cancel_command(exec_id)
|
||||
await self.coordinator.client.cancel_execution(exec_id)
|
||||
|
||||
def get_gateway_id(self) -> str:
|
||||
"""Retrieve gateway id from device url.
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/overkiz",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
|
||||
"requirements": ["pyoverkiz==1.20.4"],
|
||||
"loggers": ["boto3", "botocore", "pyoverkiz", "s3transfer"],
|
||||
"requirements": ["pyoverkiz[nexity]==2.0.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "gateway*",
|
||||
|
||||
@@ -213,7 +213,7 @@ async def async_setup_entry(
|
||||
description,
|
||||
)
|
||||
for state in device.definition.states
|
||||
if (description := SUPPORTED_STATES.get(state.qualified_name))
|
||||
if (description := SUPPORTED_STATES.get(state))
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import Any
|
||||
|
||||
from pyoverkiz.client import OverkizClient
|
||||
from pyoverkiz.models import Scenario
|
||||
from pyoverkiz.models import PersistedActionGroup
|
||||
|
||||
from homeassistant.components.scene import Scene
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -28,7 +28,7 @@ async def async_setup_entry(
|
||||
class OverkizScene(Scene):
|
||||
"""Representation of an Overkiz Scene."""
|
||||
|
||||
def __init__(self, scenario: Scenario, client: OverkizClient) -> None:
|
||||
def __init__(self, scenario: PersistedActionGroup, client: OverkizClient) -> None:
|
||||
"""Initialize the scene."""
|
||||
self.scenario = scenario
|
||||
self.client = client
|
||||
@@ -37,4 +37,4 @@ class OverkizScene(Scene):
|
||||
|
||||
async def async_activate(self, **kwargs: Any) -> None:
|
||||
"""Activate the scene."""
|
||||
await self.client.execute_scenario(self.scenario.oid)
|
||||
await self.client.execute_persisted_action_group(self.scenario.oid)
|
||||
|
||||
@@ -144,7 +144,7 @@ async def async_setup_entry(
|
||||
description,
|
||||
)
|
||||
for state in device.definition.states
|
||||
if (description := SUPPORTED_STATES.get(state.qualified_name))
|
||||
if (description := SUPPORTED_STATES.get(state))
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -550,7 +550,7 @@ async def async_setup_entry(
|
||||
description,
|
||||
)
|
||||
for state in device.definition.states
|
||||
if (description := SUPPORTED_STATES.get(state.qualified_name))
|
||||
if (description := SUPPORTED_STATES.get(state))
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
@@ -597,12 +597,12 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity):
|
||||
return default_unit
|
||||
|
||||
attrs = self.device.attributes
|
||||
if (unit := attrs[f"{state.name}MeasuredValueType"]) and (
|
||||
if (unit := attrs.get(f"{state.name}MeasuredValueType")) and (
|
||||
unit_value := unit.value_as_str
|
||||
):
|
||||
return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
|
||||
|
||||
if (unit := attrs[OverkizAttribute.CORE_MEASURED_VALUE_TYPE]) and (
|
||||
if (unit := attrs.get(OverkizAttribute.CORE_MEASURED_VALUE_TYPE)) and (
|
||||
unit_value := unit.value_as_str
|
||||
):
|
||||
ha_unit = OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
|
||||
|
||||
+6
-2
@@ -48,7 +48,9 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater
|
||||
def min_temp(self) -> float:
|
||||
"""Return the minimum temperature."""
|
||||
|
||||
min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE]
|
||||
min_temp = self.device.states.get(
|
||||
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
|
||||
)
|
||||
if min_temp:
|
||||
return cast(float, min_temp.value_as_float)
|
||||
return DEFAULT_MIN_TEMP
|
||||
@@ -57,7 +59,9 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater
|
||||
def max_temp(self) -> float:
|
||||
"""Return the maximum temperature."""
|
||||
|
||||
max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE]
|
||||
max_temp = self.device.states.get(
|
||||
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
|
||||
)
|
||||
if max_temp:
|
||||
return cast(float, max_temp.value_as_float)
|
||||
return DEFAULT_MAX_TEMP
|
||||
|
||||
@@ -156,7 +156,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the minimum temperature."""
|
||||
min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE]
|
||||
min_temp = self.device.states.get(
|
||||
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
|
||||
)
|
||||
if min_temp:
|
||||
return cast(float, min_temp.value_as_float)
|
||||
return DEFAULT_MIN_TEMP
|
||||
@@ -164,7 +166,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
"""Return the maximum temperature."""
|
||||
max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE]
|
||||
max_temp = self.device.states.get(
|
||||
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
|
||||
)
|
||||
if max_temp:
|
||||
return cast(float, max_temp.value_as_float)
|
||||
return DEFAULT_MAX_TEMP
|
||||
@@ -172,14 +176,14 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
current_temperature = self.device.states[
|
||||
current_temperature = self.device.states.get(
|
||||
OverkizState.IO_MIDDLE_WATER_TEMPERATURE
|
||||
]
|
||||
)
|
||||
if current_temperature:
|
||||
return current_temperature.value_as_float
|
||||
current_temperature = self.device.states[
|
||||
current_temperature = self.device.states.get(
|
||||
OverkizState.MODBUSLINK_MIDDLE_WATER_TEMPERATURE
|
||||
]
|
||||
)
|
||||
if current_temperature:
|
||||
return current_temperature.value_as_float
|
||||
return None
|
||||
@@ -188,19 +192,21 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
|
||||
target_temperature = self.device.states[
|
||||
target_temperature = self.device.states.get(
|
||||
OverkizState.CORE_WATER_TARGET_TEMPERATURE
|
||||
]
|
||||
)
|
||||
if target_temperature:
|
||||
return target_temperature.value_as_float
|
||||
|
||||
target_temperature = self.device.states[
|
||||
target_temperature = self.device.states.get(
|
||||
OverkizState.CORE_TARGET_DWH_TEMPERATURE
|
||||
]
|
||||
)
|
||||
if target_temperature:
|
||||
return target_temperature.value_as_float
|
||||
|
||||
target_temperature = self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
|
||||
target_temperature = self.device.states.get(
|
||||
OverkizState.CORE_TARGET_TEMPERATURE
|
||||
)
|
||||
if target_temperature:
|
||||
return target_temperature.value_as_float
|
||||
|
||||
@@ -209,9 +215,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
|
||||
@property
|
||||
def target_temperature_high(self) -> float | None:
|
||||
"""Return the highbound target temperature we try to reach."""
|
||||
target_temperature_high = self.device.states[
|
||||
target_temperature_high = self.device.states.get(
|
||||
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
|
||||
]
|
||||
)
|
||||
if target_temperature_high:
|
||||
return target_temperature_high.value_as_float
|
||||
return None
|
||||
@@ -219,9 +225,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
|
||||
@property
|
||||
def target_temperature_low(self) -> float | None:
|
||||
"""Return the lowbound target temperature we try to reach."""
|
||||
target_temperature_low = self.device.states[
|
||||
target_temperature_low = self.device.states.get(
|
||||
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
|
||||
]
|
||||
)
|
||||
if target_temperature_low:
|
||||
return target_temperature_low.value_as_float
|
||||
return None
|
||||
|
||||
@@ -45,7 +45,7 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
current_temperature = self.device.states[OverkizState.CORE_DHW_TEMPERATURE]
|
||||
current_temperature = self.device.states.get(OverkizState.CORE_DHW_TEMPERATURE)
|
||||
|
||||
if current_temperature and current_temperature.value_as_int:
|
||||
return float(current_temperature.value_as_int)
|
||||
@@ -55,9 +55,9 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
target_temperature = self.device.states[
|
||||
target_temperature = self.device.states.get(
|
||||
OverkizState.MODBUS_CONTROL_DHW_SETTING_TEMPERATURE
|
||||
]
|
||||
)
|
||||
|
||||
if target_temperature and target_temperature.value_as_int:
|
||||
return float(target_temperature.value_as_int)
|
||||
@@ -74,11 +74,11 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
|
||||
@property
|
||||
def current_operation(self) -> str | None:
|
||||
"""Return current operation ie. eco, electric, performance, ..."""
|
||||
modbus_control = self.device.states[OverkizState.MODBUS_CONTROL_DHW]
|
||||
modbus_control = self.device.states.get(OverkizState.MODBUS_CONTROL_DHW)
|
||||
if modbus_control and modbus_control.value_as_str == OverkizCommandParam.STOP:
|
||||
return STATE_OFF
|
||||
|
||||
current_mode = self.device.states[OverkizState.MODBUS_DHW_MODE]
|
||||
current_mode = self.device.states.get(OverkizState.MODBUS_DHW_MODE)
|
||||
if current_mode and current_mode.value_as_str in OVERKIZ_TO_OPERATION_MODE:
|
||||
return OVERKIZ_TO_OPERATION_MODE[current_mode.value_as_str]
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ from homeassistant.components import persistent_notification, websocket_api
|
||||
from homeassistant.components.device_tracker import (
|
||||
ATTR_IN_ZONES,
|
||||
ATTR_SOURCE_TYPE,
|
||||
ATTR_TRACKING_TYPE,
|
||||
DOMAIN as DEVICE_TRACKER_DOMAIN,
|
||||
SourceType,
|
||||
TrackingType,
|
||||
)
|
||||
from homeassistant.components.zone import ENTITY_ID_HOME
|
||||
from homeassistant.const import (
|
||||
@@ -460,7 +462,7 @@ class Person(
|
||||
"""Register device trackers."""
|
||||
await super().async_added_to_hass()
|
||||
if state := await self.async_get_last_state():
|
||||
self._parse_source_state(state, state)
|
||||
self._parse_source_state(state)
|
||||
|
||||
if self.hass.is_running:
|
||||
# Update person now if hass is already running.
|
||||
@@ -510,39 +512,32 @@ class Person(
|
||||
@callback
|
||||
def _update_state(self) -> None:
|
||||
"""Update the state."""
|
||||
latest_non_gps_home = latest_not_home = latest_gps = latest = coordinates = None
|
||||
latest_connected = latest_legacy_home = latest_not_home = latest_gps = None
|
||||
for entity_id in self._config[CONF_DEVICE_TRACKERS]:
|
||||
state = self.hass.states.get(entity_id)
|
||||
|
||||
if not state or state.state in IGNORE_STATES:
|
||||
continue
|
||||
|
||||
if state.attributes.get(ATTR_SOURCE_TYPE) == SourceType.GPS:
|
||||
if state.attributes.get(
|
||||
ATTR_TRACKING_TYPE
|
||||
) == TrackingType.CONNECTION and state.attributes.get(ATTR_IN_ZONES):
|
||||
latest_connected = _get_latest(latest_connected, state)
|
||||
elif state.attributes.get(ATTR_SOURCE_TYPE) == SourceType.GPS:
|
||||
latest_gps = _get_latest(latest_gps, state)
|
||||
elif state.state == STATE_HOME:
|
||||
latest_non_gps_home = _get_latest(latest_non_gps_home, state)
|
||||
# Legacy scanner without tracking type
|
||||
latest_legacy_home = _get_latest(latest_legacy_home, state)
|
||||
else:
|
||||
latest_not_home = _get_latest(latest_not_home, state)
|
||||
|
||||
if latest_non_gps_home:
|
||||
latest = latest_non_gps_home
|
||||
if (
|
||||
latest_non_gps_home.attributes.get(ATTR_LATITUDE) is None
|
||||
and latest_non_gps_home.attributes.get(ATTR_LONGITUDE) is None
|
||||
and (home_zone := self.hass.states.get(ENTITY_ID_HOME))
|
||||
):
|
||||
coordinates = home_zone
|
||||
else:
|
||||
coordinates = latest_non_gps_home
|
||||
elif latest_gps:
|
||||
latest = latest_gps
|
||||
coordinates = latest_gps
|
||||
else:
|
||||
latest = latest_not_home
|
||||
coordinates = latest_not_home
|
||||
# A scanner (e.g. a router or beacon) that reports
|
||||
# being in a zone is the most reliable presence signal, so it
|
||||
# takes precedence over everything else.
|
||||
latest = latest_connected or latest_legacy_home or latest_gps or latest_not_home
|
||||
|
||||
if latest and coordinates:
|
||||
self._parse_source_state(latest, coordinates)
|
||||
if latest:
|
||||
self._parse_source_state(latest)
|
||||
else:
|
||||
self._attr_state = None
|
||||
self._source = None
|
||||
@@ -555,18 +550,33 @@ class Person(
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _parse_source_state(self, state: State, coordinates: State) -> None:
|
||||
def _parse_source_state(self, state: State) -> None:
|
||||
"""Parse source state and set person attributes.
|
||||
|
||||
This is a device tracker state or the restored person state.
|
||||
"""
|
||||
self._attr_state = state.state
|
||||
self._source = state.entity_id
|
||||
self._latitude = coordinates.attributes.get(ATTR_LATITUDE)
|
||||
self._longitude = coordinates.attributes.get(ATTR_LONGITUDE)
|
||||
self._gps_accuracy = coordinates.attributes.get(ATTR_GPS_ACCURACY)
|
||||
self._latitude = state.attributes.get(ATTR_LATITUDE)
|
||||
self._longitude = state.attributes.get(ATTR_LONGITUDE)
|
||||
self._gps_accuracy = state.attributes.get(ATTR_GPS_ACCURACY)
|
||||
self._in_zones = state.attributes.get(ATTR_IN_ZONES, [])
|
||||
|
||||
# A legacy scanner (one that doesn't report in_zones) reports "home"
|
||||
# without coordinates. Use the home zone's coordinates for backwards
|
||||
# compatibility with legacy zone conditions and triggers. Modern
|
||||
# trackers report in_zones and keep their own (possibly absent)
|
||||
# coordinates.
|
||||
if (
|
||||
ATTR_IN_ZONES not in state.attributes
|
||||
and state.state == STATE_HOME
|
||||
and self._latitude is None
|
||||
and self._longitude is None
|
||||
and (home_zone := self.hass.states.get(ENTITY_ID_HOME)) is not None
|
||||
):
|
||||
self._latitude = home_zone.attributes.get(ATTR_LATITUDE)
|
||||
self._longitude = home_zone.attributes.get(ATTR_LONGITUDE)
|
||||
|
||||
@callback
|
||||
def _update_extra_state_attributes(self) -> None:
|
||||
"""Update extra state attributes."""
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
|
||||
api_url=entry.data[CONF_URL],
|
||||
api_key=entry.data[CONF_API_TOKEN],
|
||||
session=session,
|
||||
request_timeout=60,
|
||||
request_timeout=120,
|
||||
max_retries=API_MAX_RETRIES,
|
||||
)
|
||||
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["tesla_powerwall"],
|
||||
"requirements": ["tesla-powerwall==0.5.2"]
|
||||
"requirements": ["tesla-powerwall==0.5.3"]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ rules:
|
||||
status: exempt
|
||||
comment: Integration is polling and does not subscribe to events.
|
||||
unique-config-entry: done
|
||||
entity-unique-id: done
|
||||
entity-unique-id: todo
|
||||
docs-installation-instructions:
|
||||
status: todo
|
||||
comment: |
|
||||
|
||||
@@ -7,7 +7,7 @@ from random import uniform
|
||||
from time import time
|
||||
from typing import Any
|
||||
|
||||
from reolink_aio.api import RETRY_ATTEMPTS
|
||||
from reolink_aio.api import DUAL_LENS_DUAL_MOTION_MODELS, RETRY_ATTEMPTS
|
||||
from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError
|
||||
|
||||
from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
@@ -210,6 +210,19 @@ async def async_setup_entry(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, host.api.mac_address)},
|
||||
)
|
||||
|
||||
if host.api.is_nvr and host.api.model in DUAL_LENS_DUAL_MOTION_MODELS:
|
||||
# ensure the camera device is setup before
|
||||
# the lens sub-devices that use via_device
|
||||
if host.api.supported(0, "UID"):
|
||||
camera_dev_id = f"{host.unique_id}_{host.api.camera_uid(0)}"
|
||||
else:
|
||||
camera_dev_id = f"{host.unique_id}_ch0"
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
identifiers={(DOMAIN, camera_dev_id)},
|
||||
via_device=(DOMAIN, host.unique_id),
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
@@ -423,8 +436,8 @@ def migrate_entity_ids(
|
||||
device_reg.async_update_device(device.id, new_identifiers=new_identifiers)
|
||||
break
|
||||
|
||||
if ch is None or is_chime:
|
||||
continue # Do not consider the NVR itself or chimes
|
||||
if ch is None or is_chime or device_uid[1].startswith("lens"):
|
||||
continue # Do not consider the NVR itself, chimes or lens sub-devices
|
||||
|
||||
# Check for wrongfully added MAC of the NVR/Hub to the camera
|
||||
# Can be removed in HA 2025.12
|
||||
|
||||
@@ -4,7 +4,6 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from reolink_aio.api import (
|
||||
DUAL_LENS_DUAL_MOTION_MODELS,
|
||||
FACE_DETECTION_TYPE,
|
||||
PACKAGE_DETECTION_TYPE,
|
||||
PERSON_DETECTION_TYPE,
|
||||
@@ -71,6 +70,7 @@ BINARY_PUSH_SENSORS = (
|
||||
key="motion",
|
||||
cmd_id=33,
|
||||
device_class=BinarySensorDeviceClass.MOTION,
|
||||
lens_entity=True,
|
||||
value=lambda api, ch: api.motion_detected(ch),
|
||||
supported=lambda api, ch: api.supported(ch, "motion_detection"),
|
||||
),
|
||||
@@ -78,6 +78,7 @@ BINARY_PUSH_SENSORS = (
|
||||
key=FACE_DETECTION_TYPE,
|
||||
cmd_id=33,
|
||||
translation_key="face",
|
||||
lens_entity=True,
|
||||
value=lambda api, ch: api.ai_detected(ch, FACE_DETECTION_TYPE),
|
||||
supported=lambda api, ch: api.ai_supported(ch, FACE_DETECTION_TYPE),
|
||||
),
|
||||
@@ -85,6 +86,7 @@ BINARY_PUSH_SENSORS = (
|
||||
key=PERSON_DETECTION_TYPE,
|
||||
cmd_id=[33, 600, 696],
|
||||
translation_key="person",
|
||||
lens_entity=True,
|
||||
value=lambda api, ch: api.ai_detected(ch, PERSON_DETECTION_TYPE),
|
||||
supported=lambda api, ch: api.ai_supported(ch, PERSON_DETECTION_TYPE),
|
||||
),
|
||||
@@ -92,6 +94,7 @@ BINARY_PUSH_SENSORS = (
|
||||
key=VEHICLE_DETECTION_TYPE,
|
||||
cmd_id=[33, 600, 696],
|
||||
translation_key="vehicle",
|
||||
lens_entity=True,
|
||||
value=lambda api, ch: api.ai_detected(ch, VEHICLE_DETECTION_TYPE),
|
||||
supported=lambda api, ch: api.ai_supported(ch, VEHICLE_DETECTION_TYPE),
|
||||
),
|
||||
@@ -99,6 +102,7 @@ BINARY_PUSH_SENSORS = (
|
||||
key="non-motor_vehicle",
|
||||
cmd_id=[600, 696],
|
||||
translation_key="non-motor_vehicle",
|
||||
lens_entity=True,
|
||||
value=lambda api, ch: api.ai_detected(ch, "non-motor vehicle"),
|
||||
supported=lambda api, ch: api.supported(ch, "ai_non-motor vehicle"),
|
||||
),
|
||||
@@ -106,6 +110,7 @@ BINARY_PUSH_SENSORS = (
|
||||
key=PET_DETECTION_TYPE,
|
||||
cmd_id=[33, 600, 696],
|
||||
translation_key="pet",
|
||||
lens_entity=True,
|
||||
value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE),
|
||||
supported=lambda api, ch: (
|
||||
api.ai_supported(ch, PET_DETECTION_TYPE)
|
||||
@@ -116,6 +121,7 @@ BINARY_PUSH_SENSORS = (
|
||||
key=PET_DETECTION_TYPE,
|
||||
cmd_id=[33, 600, 696],
|
||||
translation_key="animal",
|
||||
lens_entity=True,
|
||||
value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE),
|
||||
supported=lambda api, ch: api.supported(ch, "ai_animal"),
|
||||
),
|
||||
@@ -123,6 +129,7 @@ BINARY_PUSH_SENSORS = (
|
||||
key=PACKAGE_DETECTION_TYPE,
|
||||
cmd_id=[33, 600, 696],
|
||||
translation_key="package",
|
||||
lens_entity=True,
|
||||
value=lambda api, ch: api.ai_detected(ch, PACKAGE_DETECTION_TYPE),
|
||||
supported=lambda api, ch: api.ai_supported(ch, PACKAGE_DETECTION_TYPE),
|
||||
),
|
||||
@@ -355,13 +362,6 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt
|
||||
self.entity_description = entity_description
|
||||
super().__init__(reolink_data, channel)
|
||||
|
||||
if self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS:
|
||||
if entity_description.translation_key is not None:
|
||||
key = entity_description.translation_key
|
||||
else:
|
||||
key = entity_description.key
|
||||
self._attr_translation_key = f"{key}_lens_{self._channel}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""State of the sensor."""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user