Compare commits

...

88 Commits

Author SHA1 Message Date
Paul Bottein 683cc61415 Remove async_remove_config_entry_device from Yoto
The coordinator already removes players that left the account, so this handler could never allow a deletion. Automatic removal alone satisfies the stale-devices rule.
2026-06-08 20:41:36 +02:00
Paul Bottein 12d9f25da6 Add dynamic and stale device handling to Yoto 2026-06-08 19:11:03 +02:00
Diogo Gomes c27e43c570 Moves V2C InstallationVoltage from Sensor to Number (#169771)
Co-authored-by: Samuel Cabrero <scabrero@suse.com>
Co-authored-by: Samuel Cabrero <samuel@orica.es>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Samuel Cabrero <samuel@orica.es>
2026-06-08 11:47:12 +02:00
cb2206 4f4aeff2b4 Lutron caseta prev brightness (#164080)
Co-authored-by: Daniel O'Connor <daniel.oconnor@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:41:06 +02:00
Diogo Gomes 850cc27824 Bump pytrydan to v1.0.1 (#173047) 2026-06-08 11:24:47 +02:00
Erik Montnemery e19c063ef1 Improve tests of humanized error messages (#173256) 2026-06-08 11:08:05 +02:00
Colin 707742f720 Bump python-openevse-http to 1.0.1 (#172982) 2026-06-08 10:46:27 +02:00
Ronald van der Meer f58e0e5234 Fix Duco box device removal on partial node refreshes (#173186) 2026-06-08 09:48:27 +02:00
robotsnh c3d6ad029f refactor(energyid): replace datetime.now with dt_util.utcnow (#173241) 2026-06-08 09:11:48 +02:00
Mark Purcell 630f442042 Bump pydaikin to 2.18.1 (#173249) 2026-06-08 09:05:28 +02:00
Manu 62419789b9 Add version to Uptime Kuma diagnostics (#173254) 2026-06-08 08:46:55 +02:00
Joakim Plate f2f5a55165 Store product type in gardena_bluetooth config entry (#173223) 2026-06-08 08:20:47 +02:00
Mick Vleeshouwer c6a57bc81a Bump pyOverkiz to 2.0.0 in Overkiz (#173212) 2026-06-07 22:09:52 -04:00
Raphael Hehl 4171f566e9 Bump uiprotect to 11.8.0 (#173227) 2026-06-07 22:08:49 -04:00
renovate[bot] 0ac9834d93 Update syrupy to 5.3.1 (#173245)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-07 22:07:48 -04:00
Paul Bottein d7673a08c8 Bump yoto-api to 4.0.2 (#173238) 2026-06-07 22:07:36 -04:00
J. Nick Koston 35cb7c6147 Bump aiohttp to 3.14.1 (#173242) 2026-06-07 22:07:05 -04:00
Paulus Schoutsen d098622021 Return all matches for duplicate names in GetLiveContext (#173157)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-07 21:50:15 -04:00
karwosts f88e757e51 Add a battery charging sensor to demo device (#173219) 2026-06-07 23:00:22 +02:00
Pierre Pinon 653e6a43fa fix(indevolt): unable to discharge at 0 (#173085) 2026-06-07 21:55:10 +02:00
Bram Kragten 1462e7a181 Update frontend to 20260527.5 (#173236) 2026-06-07 21:39:34 +02:00
Martin Claesson e34d821f7d Add Kiosker Clear Blackout Button (#173225) 2026-06-07 21:29:51 +02:00
G Johansson 02b4442a6c Fix config flow version in goodwe (#173235) 2026-06-07 21:26:40 +02:00
J. Nick Koston 809571443c Bump habluetooth to 6.8.3 (#173194) 2026-06-07 19:17:52 +02:00
mvn23 d59398e0ea Remove name fields from opentherm_gw config flow (#173159) 2026-06-07 18:21:21 +02:00
Raphael Hehl 9c9695d0ba Bump uiprotect to 11.3.0 (#173024)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-06-07 14:19:26 +02:00
Allen Porter 3fbdbb12e2 Support streaming updates for V1 Roborock devices (#173182) 2026-06-07 14:12:20 +02:00
Ronald van der Meer a29f2907f7 Use NodeType enum in Duco entity (#173189) 2026-06-07 14:07:48 +02:00
mvn23 83534f286e Ensure opentherm_gw boiler and thermostat manufacturers are strings (#173162) 2026-06-07 12:23:09 +02:00
Ronald van der Meer 4fe93f9c64 Fix uncaught Duco diagnostics client errors (#173191) 2026-06-07 07:27:02 +02:00
Shay Levy fd8789d599 Fix Shelly virtual component unit retrieval (#173183) 2026-06-07 00:34:18 +03:00
Joost Lekkerkerker d0b34dfe92 Have Plugwise handle unavailable temperature measurements (#173173) 2026-06-07 00:19:29 +03:00
Tomer 390766ba3a Bump victron-mqtt to 2026.6.1.1 (#173142) 2026-06-07 00:15:39 +03:00
Vincent Knoop Pathuis 3a46d1088b Refactor Landis+Gyr heat meter to use the HA standard SerialPortSelector (#173170) 2026-06-06 15:16:41 -04:00
epenet 26d56b8218 Use DOMAIN constant in test (async_setup_component o-z) (#173018) 2026-06-06 12:14:46 -07:00
Vincent Knoop Pathuis 6ee819cdc3 Bump to Ultraheat 0.6.1 (#173175) 2026-06-06 15:14:01 -04:00
Stefan Agner 1cf8fe4d0b Drop legacy requires_api_password from discovery announcement (#173090)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:54:36 -04:00
Michael Hansen c5f93cdd72 Validate sentences and answers (#173127)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2026-06-06 13:48:07 -04:00
Michael Hansen 42136f1464 Bubble up conversation response in script run (#173131)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-06 13:47:49 -04:00
J. Nick Koston 34f3452280 Wait for Shelly bluetooth proxy connection at startup (#173165) 2026-06-06 11:17:49 -05:00
Michael Hansen ba9248cc94 LLM: format numeric states with display precision (#173128) 2026-06-06 12:15:48 -04:00
Michael Hansen 018cd1333e Bump ollama library (#173129) 2026-06-06 12:14:59 -04:00
J. Nick Koston c72d723e0d Wait for ESPHome bluetooth proxy connection at startup (#173164) 2026-06-06 11:13:03 -05:00
Paul Bottein b9b36d9e12 Add card group browsing to the Yoto media browser (#173152) 2026-06-06 12:12:58 -04:00
Klaas Schoute b6f38c3cbb Update forecast_solar integration to v5.0.1 (#173151) 2026-06-06 15:26:14 +02:00
Bouwe Westerdijk a0162d2ff0 Bump plugwise to v1.11.4 (#173147) 2026-06-06 12:03:32 +02:00
robotsnh b6f018873b refactor(dwd_weather_warnings): change datetime.now to dt_util.utcnow (#173149) 2026-06-06 11:58:06 +02:00
Crocmagnon 43e21322ea Bump data-grand-lyon-ha to 0.8.0 (#173108) 2026-06-06 11:08:43 +02:00
tronikos 86ccc59a5f Bump opower to 0.18.3 (#173141) 2026-06-06 08:53:52 +02:00
Luke Lashley 2fce2547c7 Close the connection for disabled Roborock devices (#172277) 2026-06-06 08:19:23 +02:00
Luke Lashley 6b40278d08 Allow using a custom server for Roborock setup. (#171645)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-05 21:36:39 -07:00
jasonjhofmann 05bb8b94fa Add network MAC connection to AirVisual Pro devices (#173071)
Co-authored-by: jasonjhofmann <16144532+jasonjhofmann@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 06:29:32 +02:00
Joakim Plate 5ac3a8cdde Switch to active scanner for gardena (#173062) 2026-06-06 04:08:10 +02:00
Paulus Schoutsen 266fccf0cf Use SerialPortSelector for DSMR serial port configuration (#171103)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 21:35:07 -04:00
Joakim Plate a1e6a6f9a2 Fix process advertisement for active scans (#173116) 2026-06-05 19:42:45 +02:00
renovate[bot] 2fe406c6ff Update uv to 0.11.17 (#173060) 2026-06-05 19:34:33 +02:00
Paul Bottein e1249fef8f Bump yoto-api to 3.2.0 (#173119) 2026-06-05 19:33:13 +02:00
Michael Hansen 6f61e97f8e Bump voip-utils to 0.4.0 (#173118) 2026-06-05 19:21:49 +02:00
Noah Husby b65751e8ac Bump aiostreammagic to 2.13.2 (#173114) 2026-06-05 19:11:55 +02:00
dependabot[bot] ef4bf77b24 Bump github/gh-aw-actions from 0.77.0 to 0.77.3 (#173073)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-05 19:10:35 +02:00
Markus Tuominen 977a9ecdd2 Add entity-unique-id pylint quality scale checker (#172815)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
2026-06-05 17:35:48 +02:00
Erik Montnemery 9e79eba970 Give any connected scanner highest priority when deriving person state (#173107) 2026-06-05 17:30:39 +02:00
Martin Hjelmare 40073e598c Fix pylint utcnow checker for dt_util.UTC (#173083) 2026-06-05 16:14:57 +02:00
Erik Montnemery 627d5cc110 Do not use home zone coordinates for person when detected home by scanner (#173042) 2026-06-05 16:05:58 +02:00
Paul Bottein b1dbeca9ed Bump yoto-api to 3.1.6 (#173104) 2026-06-05 15:58:11 +02:00
Robert Resch 059bc8d676 Unify query token auth in http views (#173082) 2026-06-05 15:57:16 +02:00
Erik Montnemery 085f794407 Add test showing zone.async_active_zone prefers zone closest to center (#173099) 2026-06-05 15:27:44 +02:00
Jan Bouwhuis 3996db289d Clean up unused MQTT constants (#173095) 2026-06-05 14:33:14 +02:00
Ronald van der Meer 291585e48e Fix Duco mode end time sensor name (#173045) 2026-06-05 14:23:13 +02:00
Erwin Douna d9a125ce9b Portainer extend timeout for disk space coordinator (#173032) 2026-06-05 14:21:09 +02:00
Erik Montnemery 786c957909 Teach legacy zone condition and trigger about in_zones state attribute (#173074) 2026-06-05 14:04:23 +02:00
Markus Tuominen dd6830f1c5 Add sub-devices for Reolink dual lens cameras with per-lens sensors (#173037) 2026-06-05 14:04:10 +02:00
epenet 4dbe58afc6 Use explicit DOMAIN import in mqtt tests (#173093) 2026-06-05 14:01:28 +02:00
Nikolai Rahimi 6c72d4337d Fix Mitsubishi Comfort devices skipped due to unresolved local address (#172959) 2026-06-05 13:53:13 +02:00
epenet fcff5229d9 Fix incorrect constant usage in mqtt config flow (#172557) 2026-06-05 13:53:10 +02:00
Joost Lekkerkerker 8edd813d4b Bump pySmartThings to 4.0.1 (#173092) 2026-06-05 13:38:32 +02:00
Robert Resch 509866c0eb Bump wheels to 2026.06.0 (#173089) 2026-06-05 13:28:31 +02:00
EnjoyingM 9db5860d6b Wolflink Fix state_class for long term statistics (#173048) 2026-06-05 12:52:06 +02:00
Erwin Douna 6917223cb3 Tado refactor to utilize get_zone_states (#173075) 2026-06-05 12:39:43 +02:00
Jan Bouwhuis cc4637a703 Create certificate files before trying to migrate the MQTT config entry (#173087)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-05 12:12:34 +02:00
A. Gideonse 2b0d14d71e Bump api-indevolt to 1.8.5 (#173078) 2026-06-05 10:57:08 +02:00
renovate[bot] d0d85d8844 Update ruff (#173059)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-05 09:48:36 +02:00
BrettLynch123 eea3d9d4c4 Bump tesla-powerwall to 0.5.3 (#173058) 2026-06-05 09:29:13 +02:00
Erik Montnemery 48a690b267 Derive zone entity state from person in_zones state attribute (#172942) 2026-06-05 08:12:22 +02:00
Michael 07dc2346de Bump py-synologydsm-api to 2.9.0 (#173041) 2026-06-04 22:26:18 +02:00
Erik Montnemery 711830b01f Add tracking_type capability attribute to device tracker (#173027) 2026-06-04 21:19:19 +02:00
Erik Montnemery f9fea56a8c Add tests of legacy device tracker states to person tests (#173023) 2026-06-04 21:12:24 +02:00
Franck Nijhof 8aac0c5b6e Convert LinkPlay configuration_url to string for device registry (#173034) 2026-06-04 20:17:50 +02:00
373 changed files with 8782 additions and 3861 deletions
+7 -7
View File
@@ -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 }}
+2 -2
View File
@@ -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 -1
View File
@@ -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"],
-1
View File
@@ -59,7 +59,6 @@ ATTR_EXTERNAL_URL = "external_url"
ATTR_INTERNAL_URL = "internal_url"
ATTR_LOCATION_NAME = "location_name"
ATTR_INSTALLATION_TYPE = "installation_type"
ATTR_REQUIRES_API_PASSWORD = "requires_api_password"
ATTR_UUID = "uuid"
ATTR_VERSION = "version"
@@ -5,6 +5,8 @@ import logging
from pathlib import Path
from typing import Any
from hassil.parse_expression import parse_sentence
from hassil.parser import ParseError
from hassil.util import (
PUNCTUATION_END,
PUNCTUATION_END_WORD,
@@ -164,6 +166,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
[cv.string],
has_one_non_empty_item,
has_no_punctuation,
is_valid_sentence,
),
}
],
@@ -212,6 +215,17 @@ def has_no_punctuation(value: list[str]) -> list[str]:
return value
def is_valid_sentence(value: list[str]) -> list[str]:
"""Validate result can be parsed by hassil."""
for sentence in value:
try:
parse_sentence(sentence)
except ParseError as err:
raise vol.Invalid(f"invalid sentence: {err}") from err
return value
def has_one_non_empty_item(value: list[str]) -> list[str]:
"""Validate result has at least one item."""
if len(value) < 1:
+12 -6
View File
@@ -6,6 +6,7 @@ These APIs are the only documented way to interact with the bluetooth integratio
import asyncio
from asyncio import Future
from collections.abc import Callable, Iterable
from contextlib import ExitStack
from typing import TYPE_CHECKING, cast
from bleak import BleakScanner
@@ -178,15 +179,20 @@ async def async_process_advertisements(
if not done.done() and callback(service_info):
done.set_result(service_info)
unload = _get_manager(hass).async_register_callback(
_async_discovered_device, match_dict, mode, scan_duration=timeout
)
manager = _get_manager(hass)
with ExitStack() as stack:
unload = manager.async_register_callback(
_async_discovered_device, match_dict, mode
)
stack.callback(unload)
if mode == BluetoothScanningMode.ACTIVE:
task = hass.async_create_task(manager.async_request_active_scan(timeout))
stack.callback(task.cancel)
try:
async with asyncio.timeout(timeout):
return await done
finally:
unload()
@hass_callback
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.16",
"habluetooth==6.8.1"
"habluetooth==6.8.3"
]
}
+10 -18
View File
@@ -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."]
}
+14 -18
View File
@@ -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"]
}
+20 -2
View File
@@ -5,6 +5,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -27,7 +28,19 @@ async def async_setup_entry(
BinarySensorDeviceClass.MOISTURE,
),
DemoBinarySensor(
"binary_2", "Movement Backyard", True, BinarySensorDeviceClass.MOTION
"binary_2",
"Movement Backyard",
True,
BinarySensorDeviceClass.MOTION,
),
DemoBinarySensor(
"binary_3",
"Outside Temperature",
False,
BinarySensorDeviceClass.BATTERY_CHARGING,
device_id="sensor_1",
entity_category=EntityCategory.DIAGNOSTIC,
entity_name="Battery Charging",
),
]
)
@@ -46,6 +59,9 @@ class DemoBinarySensor(BinarySensorEntity):
device_name: str,
state: bool,
device_class: BinarySensorDeviceClass,
device_id: str | None = None,
entity_category: EntityCategory | None = None,
entity_name: str | None = None,
) -> None:
"""Initialize the demo sensor."""
self._unique_id = unique_id
@@ -54,10 +70,12 @@ class DemoBinarySensor(BinarySensorEntity):
self._attr_device_info = DeviceInfo(
identifiers={
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, self.unique_id)
(DOMAIN, device_id or unique_id)
},
name=device_name,
)
self._attr_entity_category = entity_category
self._attr_name = entity_name
@property
def unique_id(self) -> str:
@@ -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"
}
}
}
}
+3 -49
View File
@@ -13,7 +13,6 @@ from dsmr_parser.clients.rfxtrx_protocol import (
from dsmr_parser.objects import DSMRObject
import voluptuous as vol
from homeassistant.components import usb
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -23,6 +22,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_TYPE
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import SerialPortSelector
from .const import (
CONF_DSMR_VERSION,
@@ -37,8 +37,6 @@ from .const import (
RFXTRX_DSMR_PROTOCOL,
)
CONF_MANUAL_PATH = "Enter Manually"
class DSMRConnection:
"""Test the connection to DSMR and receive telegram to read serial ids."""
@@ -165,8 +163,6 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
_dsmr_version: str | None = None
@staticmethod
@callback
def async_get_options_flow(
@@ -222,34 +218,13 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
"""Step when setting up serial configuration."""
errors: dict[str, str] = {}
if user_input is not None:
user_selection = user_input[CONF_PORT]
if user_selection == CONF_MANUAL_PATH:
self._dsmr_version = user_input[CONF_DSMR_VERSION]
return await self.async_step_setup_serial_manual_path()
dev_path = user_selection
validate_data = {
CONF_PORT: dev_path,
CONF_DSMR_VERSION: user_input[CONF_DSMR_VERSION],
}
data = await self.async_validate_dsmr(validate_data, errors)
data = await self.async_validate_dsmr(user_input, errors)
if not errors:
return self.async_create_entry(title=data[CONF_PORT], data=data)
ports = await usb.async_scan_serial_ports(self.hass)
list_of_ports = {
port.device: f"{port.device} - {port.description or 'n/a'}"
f", s/n: {port.serial_number or 'n/a'}"
+ (f" - {port.manufacturer}" if port.manufacturer else "")
for port in ports
}
list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH
schema = vol.Schema(
{
vol.Required(CONF_PORT): vol.In(list_of_ports),
vol.Required(CONF_PORT): SerialPortSelector(),
vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS),
}
)
@@ -259,27 +234,6 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_setup_serial_manual_path(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Select path manually."""
if user_input is not None:
validate_data = {
CONF_PORT: user_input[CONF_PORT],
CONF_DSMR_VERSION: self._dsmr_version,
}
errors: dict[str, str] = {}
data = await self.async_validate_dsmr(validate_data, errors)
if not errors:
return self.async_create_entry(title=data[CONF_PORT], data=data)
schema = vol.Schema({vol.Required(CONF_PORT): str})
return self.async_show_form(
step_id="setup_serial_manual_path",
data_schema=schema,
)
async def async_validate_dsmr(
self, input_data: dict[str, Any], errors: dict[str, str]
) -> dict[str, Any]:
@@ -26,12 +26,6 @@
},
"title": "[%key:common::config_flow::data::device%]"
},
"setup_serial_manual_path": {
"data": {
"port": "[%key:common::config_flow::data::usb_path%]"
},
"title": "[%key:common::config_flow::data::path%]"
},
"user": {
"data": {
"type": "Connection type"
+1
View File
@@ -7,3 +7,4 @@ from homeassistant.const import Platform
DOMAIN = "duco"
PLATFORMS = [Platform.FAN, Platform.SENSOR]
SCAN_INTERVAL = timedelta(seconds=10)
BOX_NODE_ID = 1
+7 -1
View File
@@ -3,7 +3,7 @@
from dataclasses import asdict
from typing import Any
from duco_connectivity.exceptions import DucoConnectionError
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
@@ -52,6 +52,12 @@ async def async_get_config_entry_diagnostics(
translation_domain=DOMAIN,
translation_key="connection_error",
) from err
except DucoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
+3 -3
View File
@@ -1,6 +1,6 @@
"""Base entity for the Duco integration."""
from duco_connectivity.models import Node
from duco_connectivity.models import Node, NodeType
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -25,7 +25,7 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
identifiers={(DOMAIN, f"{mac}_{node.node_id}")},
manufacturer="Duco",
model=coordinator.board_info.box_name
if node.general.node_type == "BOX"
if node.general.node_type == NodeType.BOX
else node.general.node_type,
name=node.general.name or f"Node {node.node_id}",
)
@@ -34,7 +34,7 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
"connections": {(CONNECTION_NETWORK_MAC, mac)},
"serial_number": coordinator.board_info.serial_board_box,
}
if node.general.node_type == "BOX"
if node.general.node_type == NodeType.BOX
else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")}
)
self._attr_device_info = device_info
+8 -2
View File
@@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .const import BOX_NODE_ID, DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
from .entity import DucoEntity
@@ -158,7 +158,13 @@ async def async_setup_entry(
# The firmware removes deregistered RF/wired nodes automatically.
# BSRH box sensors that are physically unplugged from the PCB are
# not deregistered by the firmware and will never appear here as stale.
stale_node_ids = known_nodes - coordinator.data.nodes.keys()
# The BOX node can transiently disappear from the API response, so keep
# node 1 to avoid removing the main controller device.
stale_node_ids = {
node_id
for node_id in known_nodes - coordinator.data.nodes.keys()
if node_id != BOX_NODE_ID
}
if stale_node_ids:
device_reg = dr.async_get(hass)
mac = entry.unique_id
+1 -1
View File
@@ -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)
+22 -1
View File
@@ -1,11 +1,12 @@
"""Manager for esphome devices."""
import asyncio
import base64
from functools import partial
import logging
import secrets
import struct
from typing import TYPE_CHECKING, Any, NamedTuple
from typing import TYPE_CHECKING, Any, Final, NamedTuple
from aioesphomeapi import (
APIClient,
@@ -106,6 +107,9 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
# Max time to wait at startup for a BLE proxy to register its scanner.
STARTUP_SCANNER_WAIT: Final = 3.0
LOG_LEVEL_TO_LOGGER = {
LogLevel.LOG_LEVEL_NONE: logging.DEBUG,
LogLevel.LOG_LEVEL_ERROR: logging.ERROR,
@@ -677,6 +681,8 @@ class ESPHomeManager:
hass, device_info.bluetooth_mac_address or device_info.mac_address
)
entry_data.first_connect_done.set()
if device_info.voice_assistant_feature_flags_compat(api_version) and (
Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms
):
@@ -988,6 +994,21 @@ class ESPHomeManager:
await reconnect_logic.start()
# Wait for a cached BLE proxy to register its scanner before finishing setup.
if (
device_info := entry_data.device_info
) is not None and device_info.bluetooth_proxy_feature_flags_compat(
entry_data.api_version
):
try:
async with asyncio.timeout(STARTUP_SCANNER_WAIT):
await entry_data.first_connect_done.wait()
except TimeoutError:
_LOGGER.debug(
"%s: Timed out waiting for Bluetooth scanner to register",
self.host,
)
@callback
def _async_setup_device_registry(
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/forecast_solar",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["forecast-solar==5.0.0"]
"requirements": ["forecast-solar==5.0.1"]
}
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260527.4"]
"requirements": ["home-assistant-frontend==20260527.5"]
}
@@ -1,17 +1,20 @@
"""The Gardena Bluetooth integration."""
from contextlib import suppress
import logging
from bleak.backends.device import BLEDevice
from gardena_bluetooth.client import CachedConnection, Client
from gardena_bluetooth.const import ProductType
from gardena_bluetooth.scan import async_get_manufacturer_data
from gardena_bluetooth.const import ScanService
from gardena_bluetooth.parse import ManufacturerData, ProductType
from habluetooth import BluetoothServiceInfoBleak
from homeassistant.components import bluetooth
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_PRODUCT_TYPE
from .coordinator import (
DeviceUnavailable,
GardenaBluetoothConfigEntry,
@@ -30,6 +33,79 @@ PLATFORMS: list[Platform] = [
]
LOGGER = logging.getLogger(__name__)
DISCONNECT_DELAY = 5
PRODUCTS_SCAN_TIMEOUT = 10
PRODUCT_TYPE_TIMEOUT = 30
async def async_get_product(hass: HomeAssistant, address: str) -> ManufacturerData:
"""Get manufacturer data for the given address via active scan."""
data = ManufacturerData()
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
if info.device.address != address:
return False
data.update(info.manufacturer_data.get(ManufacturerData.company, b""))
return data.product_type is not ProductType.UNKNOWN
with suppress(TimeoutError):
await bluetooth.async_process_advertisements(
hass,
_data_callback,
bluetooth.BluetoothCallbackMatcher(
address=address, manufacturer_id=ManufacturerData.company
),
mode=bluetooth.BluetoothScanningMode.ACTIVE,
timeout=PRODUCT_TYPE_TIMEOUT,
)
return data
async def async_get_products(hass: HomeAssistant) -> dict[str, ManufacturerData]:
"""Get all products that are currently advertising."""
products: dict[str, ManufacturerData] = {}
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
if ScanService not in info.service_uuids:
return False
raw = info.manufacturer_data.get(ManufacturerData.company, b"")
if (data := products.get(info.device.address)) is None:
data = ManufacturerData()
products[info.device.address] = data
data.update(raw)
return False
with suppress(TimeoutError):
await bluetooth.async_process_advertisements(
hass,
_data_callback,
bluetooth.BluetoothCallbackMatcher(
manufacturer_id=ManufacturerData.company
),
mode=bluetooth.BluetoothScanningMode.ACTIVE,
timeout=PRODUCTS_SCAN_TIMEOUT,
)
return products
async def async_migrate_product_type(
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
) -> GardenaBluetoothConfigEntry:
"""Discover product type for old entries and upgrade them to minor version 2."""
mfg = await async_get_product(hass, entry.data[CONF_ADDRESS])
if mfg.product_type is ProductType.UNKNOWN:
raise ConfigEntryNotReady("Unable to find product type")
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_PRODUCT_TYPE: mfg.product_type.name},
minor_version=2,
)
return entry
def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
@@ -51,16 +127,11 @@ async def async_setup_entry(
) -> bool:
"""Set up Gardena Bluetooth from a config entry."""
if entry.minor_version < 2:
entry = await async_migrate_product_type(hass, entry)
address = entry.data[CONF_ADDRESS]
try:
mfg_data = await async_get_manufacturer_data({address})
except TimeoutError as exc:
raise ConfigEntryNotReady("Unable to find product type") from exc
product_type = mfg_data[address].product_type
if product_type is ProductType.UNKNOWN:
raise ConfigEntryNotReady("Unable to find product type")
product_type = ProductType[entry.data[CONF_PRODUCT_TYPE]]
client = Client(get_connection(hass, address), product_type)
@@ -4,22 +4,18 @@ import logging
from typing import Any
from gardena_bluetooth.client import Client
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation, ScanService
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation
from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure
from gardena_bluetooth.parse import ManufacturerData, ProductType
from gardena_bluetooth.scan import async_get_manufacturer_data
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfo,
async_discovered_service_info,
)
from homeassistant.components.bluetooth import BluetoothServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from homeassistant.data_entry_flow import AbortFlow
from . import get_connection
from .const import DOMAIN
from . import async_get_product, async_get_products, get_connection
from .const import CONF_PRODUCT_TYPE, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -33,26 +29,16 @@ _SUPPORTED_PRODUCT_TYPES = {
}
def _is_supported(discovery_info: BluetoothServiceInfo):
"""Check if device is supported."""
if ScanService not in discovery_info.service_uuids:
return False
if discovery_info.manufacturer_data.get(ManufacturerData.company) is None:
_LOGGER.debug("Missing manufacturer data: %s", discovery_info)
return False
return True
class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Gardena Bluetooth."""
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow."""
self.devices: dict[str, str] = {}
self.address: str | None
self.devices: dict[str, ManufacturerData] = {}
async def async_read_data(self):
"""Try to connect to device and extract information."""
@@ -68,20 +54,23 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
finally:
await client.disconnect()
return {CONF_ADDRESS: self.address}
assert self.address in self.devices
return {
CONF_ADDRESS: self.address,
CONF_PRODUCT_TYPE: self.devices[self.address].product_type.name,
}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("Discovered device: %s", discovery_info)
data = await async_get_manufacturer_data({discovery_info.address})
product_type = data[discovery_info.address].product_type
if product_type not in _SUPPORTED_PRODUCT_TYPES:
mfg = await async_get_product(self.hass, discovery_info.address)
self.devices[discovery_info.address] = mfg
if mfg.product_type not in _SUPPORTED_PRODUCT_TYPES:
return self.async_abort(reason="no_devices_found")
self.address = discovery_info.address
self.devices = {discovery_info.address: PRODUCT_NAMES[product_type]}
await self.async_set_unique_id(self.address)
self._abort_if_unique_id_configured()
return await self.async_step_confirm()
@@ -91,7 +80,7 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Confirm discovery."""
assert self.address
title = self.devices[self.address]
title = PRODUCT_NAMES[self.devices[self.address].product_type]
if user_input is not None:
data = await self.async_read_data()
@@ -117,31 +106,25 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return await self.async_step_confirm()
current_addresses = self._async_current_ids(include_ignore=False)
candidates = set()
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if address in current_addresses or not _is_supported(discovery_info):
continue
candidates.add(address)
data = await async_get_manufacturer_data(candidates)
for address, mfg_data in data.items():
if mfg_data.product_type not in _SUPPORTED_PRODUCT_TYPES:
continue
self.devices[address] = PRODUCT_NAMES[mfg_data.product_type]
current = self._async_current_ids(include_ignore=False)
self.devices = await async_get_products(self.hass)
# Keep selection sorted by address to ensure stable tests
self.devices = dict(sorted(self.devices.items(), key=lambda x: x[0]))
devices = {
address: PRODUCT_NAMES[data.product_type]
for address in sorted(self.devices)
if address not in current
and (data := self.devices[address]).product_type in _SUPPORTED_PRODUCT_TYPES
}
if not self.devices:
if not devices:
return self.async_abort(reason="no_devices_found")
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(self.devices),
vol.Required(CONF_ADDRESS): vol.In(devices),
},
),
)
@@ -1,3 +1,4 @@
"""Constants for the Gardena Bluetooth integration."""
DOMAIN = "gardena_bluetooth"
CONF_PRODUCT_TYPE = "product_type"
@@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__)
class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Goodwe config flow."""
MINOR_VERSION = 2
VERSION = 2
async def async_handle_successful_connection(
self,
+19 -23
View File
@@ -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"
+1 -1
View File
@@ -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)},
+51 -21
View File
@@ -88,13 +88,10 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity):
"""
_attr_supported_features = LightEntityFeature.TRANSITION
_prev_brightness: int | None = None
def __init__(self, light: dict[str, Any], data: LutronCasetaData) -> None:
"""Initialize the light and set the supported color modes.
:param light: The lutron light device to initialize.
:param data: The integration data
"""
"""Initialize the light and set the supported color modes."""
super().__init__(light, data)
self._attr_min_color_temp_kelvin = self._get_min_color_temp_kelvin(light)
@@ -115,28 +112,23 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity):
DEVICE_TYPE_COLOR_TUNE,
)
def _get_min_color_temp_kelvin(self, light: dict[str, Any]) -> int:
"""Return minimum supported color temperature.
# Capture the initial brightness so _prev_brightness is correct on startup
self._sync_prev_brightness_from_device()
:param light: The light to get the minimum color temperature for.
"""
def _get_min_color_temp_kelvin(self, light: dict[str, Any]) -> int:
"""Return minimum supported color temperature."""
white_tune_range = light.get("white_tuning_range")
# Default to 1.4k if not found
if white_tune_range is None or "Min" not in white_tune_range:
return 1400
return white_tune_range.get("Min")
def _get_max_color_temp_kelvin(self, light: dict[str, Any]) -> int:
"""Return maximum supported color temperature.
:param light: The light to get the maximum color temperature for.
"""
"""Return maximum supported color temperature."""
white_tune_range = light.get("white_tuning_range")
# Default to 10k if not found
if white_tune_range is None or "Max" not in white_tune_range:
return 10000
return white_tune_range.get("Max")
@property
@@ -144,20 +136,42 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity):
"""Return the brightness of the light."""
return to_hass_level(self._device["current_state"])
def _sync_prev_brightness_from_device(self) -> None:
"""Keep previous brightness in sync with device state."""
current_level = self._device.get("current_state")
if current_level is None:
return
hass_brightness = to_hass_level(current_level)
if hass_brightness > 0:
# Any non-zero brightness (HA or physical) becomes the new last level
self._prev_brightness = hass_brightness
async def async_update(self) -> None:
"""Update when forcing a refresh of the device."""
await super().async_update()
self._sync_prev_brightness_from_device()
def _handle_bridge_update(self) -> None:
"""Handle updated data from the bridge."""
self._sync_prev_brightness_from_device()
super()._handle_bridge_update()
async def _async_set_brightness(
self, brightness: int | None, color_value: LutronColorMode | None, **kwargs: Any
) -> None:
args = {}
args: dict[str, Any] = {}
if ATTR_TRANSITION in kwargs:
args["fade_time"] = timedelta(seconds=kwargs[ATTR_TRANSITION])
if brightness is not None:
brightness = to_lutron_level(brightness)
await self._smartbridge.set_value(
self.device_id, value=brightness, color_value=color_value, **args
)
async def _async_set_warm_dim(self, brightness: int | None, **kwargs: Any):
async def _async_set_warm_dim(self, brightness: int | None, **kwargs: Any) -> None:
"""Set the light to warm dim mode."""
set_warm_dim_kwargs: dict[str, Any] = {}
if ATTR_TRANSITION in kwargs:
@@ -176,10 +190,13 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity):
"""Turn the light on."""
# first check for "white mode" (WarmDim)
if (white_color := kwargs.get(ATTR_WHITE)) is not None:
# Only remember non-zero levels (see brightness handling below)
if white_color:
self._prev_brightness = white_color
await self._async_set_warm_dim(white_color)
return
brightness = kwargs.pop(ATTR_BRIGHTNESS, None)
# Parse the color first, so a color-only call can leave brightness alone
color: LutronColorMode | None = None
hs_color: tuple[float, float] | None = kwargs.pop(ATTR_HS_COLOR, None)
kelvin_color: int | None = kwargs.pop(ATTR_COLOR_TEMP_KELVIN, None)
@@ -189,20 +206,33 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity):
elif kelvin_color is not None:
color = WarmCoolColorValue(kelvin_color)
# if user is pressing on button nothing is set, so set brightness to 255
if color is None and brightness is None:
brightness: int | None
if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs.pop(ATTR_BRIGHTNESS)
# Only remember non-zero levels, so a later turn-on without an
# explicit brightness never restores the light to "off"
if brightness:
self._prev_brightness = brightness
elif color is not None:
# Color-only change: pass None so the device keeps its brightness
brightness = None
elif self._prev_brightness is None:
# No history at all: default to full brightness
brightness = 255
else:
# Restore the last known non-zero brightness
brightness = self._prev_brightness
await self._async_set_brightness(brightness, color, **kwargs)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
# Do not touch _prev_brightness here; we want the last non-zero level to survive.
await self._async_set_brightness(0, None, **kwargs)
@property
def color_mode(self) -> ColorMode:
"""Return the current color mode of the light."""
currently_warm_dim = self._device.get("warm_dim", False)
if self.supports_warm_dim and currently_warm_dim:
return ColorMode.WHITE
@@ -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(
+13 -14
View File
@@ -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"]
}
@@ -40,7 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) ->
await coordinator.async_config_entry_first_refresh()
# Start websocket listener for push updates
coordinator.start_websocket()
await coordinator.async_start_websocket()
entry.runtime_data = coordinator
@@ -48,9 +48,9 @@ class OpenEVSEDataUpdateCoordinator(DataUpdateCoordinator[None]):
"""Handle websocket data update."""
self.async_set_updated_data(None)
def start_websocket(self) -> None:
async def async_start_websocket(self) -> None:
"""Start the websocket listener."""
self.charger.ws_start()
await self.charger.ws_start()
async def async_stop_websocket(self) -> None:
"""Stop the websocket listener."""
@@ -9,6 +9,6 @@
"iot_class": "local_push",
"loggers": ["openevsehttp"],
"quality_scale": "bronze",
"requirements": ["python-openevse-http==0.3.4"],
"requirements": ["python-openevse-http==1.0.1"],
"zeroconf": ["_openevse._tcp.local."]
}
+4 -4
View File
@@ -43,10 +43,10 @@ NUMBER_TYPES: tuple[OpenEVSENumberDescription, ...] = (
OpenEVSENumberDescription(
key="charge_rate",
translation_key="charge_rate",
value_fn=lambda ev: ev.max_current_soft,
min_value_fn=lambda ev: ev.min_amps,
max_value_fn=lambda ev: ev.max_amps,
set_value_fn=lambda ev, value: ev.set_current(value),
value_fn=lambda ev: ev.max_current_soft or 0,
min_value_fn=lambda ev: ev.min_amps or 0,
max_value_fn=lambda ev: ev.max_amps or 0,
set_value_fn=lambda ev, value: ev.set_current(int(value)),
native_step=1.0,
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+1 -1
View File
@@ -75,7 +75,7 @@ SENSOR_TYPES: tuple[OpenEVSESensorDescription, ...] = (
"1": "level_1",
"2": "level_2",
"a": "automatic",
}.get(ev.service_level.lower()),
}.get(str(ev.service_level).lower()),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
@@ -8,13 +8,7 @@ import pyotgw.vars as gw_vars
from serial import SerialException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE,
CONF_ID,
CONF_NAME,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.const import CONF_DEVICE, CONF_ID, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
@@ -100,7 +94,6 @@ class OpenThermGatewayHub:
self.hass = hass
self.device_path = config_entry.data[CONF_DEVICE]
self.hub_id = config_entry.data[CONF_ID]
self.name = config_entry.data[CONF_NAME]
self.options = config_entry.options
self.config_entry_id = config_entry.entry_id
self.update_signal = f"{DATA_OPENTHERM_GW}_{self.hub_id}_update"
@@ -159,11 +152,14 @@ class OpenThermGatewayHub:
_LOGGER.debug("Received report: %s", status)
async_dispatcher_send(self.hass, self.update_signal, status)
boiler_manufacturer = status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_MEMBERID
)
dev_reg.async_update_device(
boiler_device.id,
manufacturer=status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_MEMBERID
),
manufacturer=str(boiler_manufacturer)
if boiler_manufacturer is not None
else None,
model_id=status[OpenThermDataSource.BOILER].get(
gw_vars.DATA_SLAVE_PRODUCT_TYPE
),
@@ -175,11 +171,14 @@ class OpenThermGatewayHub:
),
)
thermostat_manufacturer = status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_MEMBERID
)
dev_reg.async_update_device(
thermostat_device.id,
manufacturer=status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_MEMBERID
),
manufacturer=str(thermostat_manufacturer)
if thermostat_manufacturer is not None
else None,
model_id=status[OpenThermDataSource.THERMOSTAT].get(
gw_vars.DATA_MASTER_PRODUCT_TYPE
),
@@ -17,7 +17,6 @@ from homeassistant.config_entries import (
from homeassistant.const import (
CONF_DEVICE,
CONF_ID,
CONF_NAME,
PRECISION_HALVES,
PRECISION_TENTHS,
PRECISION_WHOLE,
@@ -54,9 +53,8 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle config flow initiation."""
if info:
name = info[CONF_NAME]
device = info[CONF_DEVICE]
gw_id = cv.slugify(info.get(CONF_ID, name))
gw_id = cv.slugify(info[CONF_ID])
entries = [e.data for e in self._async_current_entries()]
@@ -83,7 +81,7 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN):
except ConnectionError, SerialException:
return self._show_form({"base": "cannot_connect"})
return self._create_entry(gw_id, name, device)
return self._create_entry(gw_id, device)
return self._show_form()
@@ -99,20 +97,17 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="init",
data_schema=vol.Schema(
{
# Name field is no longer allowed in config flow schemas
# pylint: disable-next=home-assistant-config-flow-name-field
vol.Required(CONF_NAME): str,
vol.Required(CONF_DEVICE): str,
vol.Optional(CONF_ID): str,
vol.Required(CONF_ID): str,
}
),
errors=errors or {},
)
def _create_entry(self, gw_id, name, device):
def _create_entry(self, gw_id, device):
"""Create entry for the OpenTherm Gateway device."""
return self.async_create_entry(
title=name, data={CONF_ID: gw_id, CONF_DEVICE: device, CONF_NAME: name}
title="OpenTherm Gateway", data={CONF_ID: gw_id, CONF_DEVICE: device}
)
@@ -14,8 +14,7 @@
"init": {
"data": {
"device": "Path or URL",
"id": "ID",
"name": "[%key:common::config_flow::data::name%]"
"id": "ID"
}
}
}
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "platinum",
"requirements": ["opower==0.18.2"]
"requirements": ["opower==0.18.3"]
}
+34 -27
View File
@@ -4,18 +4,21 @@ from collections import defaultdict
from dataclasses import dataclass
from aiohttp import ClientError
from pyoverkiz.client import OverkizClient
from pyoverkiz.const import SUPPORTED_SERVERS
from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget
from pyoverkiz.exceptions import (
BadCredentialsException,
MaintenanceException,
NotAuthenticatedException,
NotSuchTokenException,
TooManyRequestsException,
from pyoverkiz.auth.credentials import (
LocalTokenCredentials,
UsernamePasswordCredentials,
)
from pyoverkiz.models import Device, OverkizServer, Scenario
from pyoverkiz.utils import generate_local_server
from pyoverkiz.client import OverkizClient
from pyoverkiz.enums import APIType, OverkizState, Server, UIClass, UIWidget
from pyoverkiz.exceptions import (
BadCredentialsError,
MaintenanceError,
NoSuchTokenError,
NotAuthenticatedError,
TooManyRequestsError,
)
from pyoverkiz.models import Device, PersistedActionGroup
from pyoverkiz.utils import create_local_server_config
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -58,7 +61,7 @@ class HomeAssistantOverkizData:
coordinator: OverkizDataUpdateCoordinator
platforms: defaultdict[Platform, list[Device]]
scenarios: list[Scenario]
scenarios: list[PersistedActionGroup]
type OverkizDataConfigEntry = ConfigEntry[HomeAssistantOverkizData]
@@ -90,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
hass,
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
server=SUPPORTED_SERVERS[entry.data[CONF_HUB]],
server=entry.data[CONF_HUB],
)
try:
@@ -100,20 +103,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
# Local API does expose scenarios, but they are not functional.
# Tracked in https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/21
if api_type == APIType.CLOUD:
scenarios = await client.get_scenarios()
scenarios = await client.get_action_groups()
else:
scenarios = []
except (
BadCredentialsException,
NotSuchTokenException,
NotAuthenticatedException,
BadCredentialsError,
NoSuchTokenError,
NotAuthenticatedError,
) as exception:
raise ConfigEntryAuthFailed("Invalid authentication") from exception
except TooManyRequestsException as exception:
except TooManyRequestsError as exception:
raise ConfigEntryNotReady("Too many requests, try again later") from exception
except (TimeoutError, ClientError) as exception:
raise ConfigEntryNotReady("Failed to connect") from exception
except MaintenanceException as exception:
except MaintenanceError as exception:
raise ConfigEntryNotReady("Server is down for maintenance") from exception
coordinator = OverkizDataUpdateCoordinator(
@@ -173,13 +176,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
identifiers={(DOMAIN, gateway.id)},
model=gateway.type.beautify_name if gateway.type else None,
model_id=str(gateway.type),
manufacturer=client.server.manufacturer,
manufacturer=client.server_config.manufacturer,
name=gateway.type.beautify_name if gateway.type else gateway.id,
sw_version=gateway.connectivity.protocol_version,
hw_version=f"{gateway.type}:{gateway.sub_type}"
if gateway.type and gateway.sub_type
else None,
configuration_url=client.server.configuration_url,
configuration_url=client.server_config.configuration_url,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -214,6 +217,9 @@ async def _async_migrate_strenum_unique_ids(
"""Migrate entities to the StrEnum-style unique IDs."""
entity_registry = er.async_get(hass)
# Map enum members renamed in pyoverkiz 2.0 to their current names.
renamed_enum_members = {"TSKALARM_CONTROLLER": "TSK_ALARM_CONTROLLER"}
@callback
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:
# Python 3.11 treats (str, Enum) and StrEnum
@@ -229,6 +235,7 @@ async def _async_migrate_strenum_unique_ids(
("OverkizState", "UIWidget", "UIClass")
):
state = key.split(".")[1]
state = renamed_enum_members.get(state, state)
new_key = ""
if key.startswith("UIClass"):
@@ -276,17 +283,15 @@ def create_local_client(
session = async_create_clientsession(hass, verify_ssl=verify_ssl)
return OverkizClient(
username="",
password="",
token=token,
server=create_local_server_config(host=host),
credentials=LocalTokenCredentials(token),
session=session,
server=generate_local_server(host=host),
verify_ssl=verify_ssl,
)
def create_cloud_client(
hass: HomeAssistant, username: str, password: str, server: OverkizServer
hass: HomeAssistant, username: str, password: str, server: Server
) -> OverkizClient:
"""Create Overkiz cloud client."""
# To allow users with multiple accounts/hubs, we create a
@@ -294,5 +299,7 @@ def create_cloud_client(
session = async_create_clientsession(hass)
return OverkizClient(
username=username, password=password, session=session, server=server
server=server,
credentials=UsernamePasswordCredentials(username, password),
session=session,
)
@@ -144,7 +144,7 @@ ALARM_DESCRIPTIONS: list[OverkizAlarmDescription] = [
# Disabled by default since all Overkiz hubs have this
# virtual device, but only a few users actually use this.
OverkizAlarmDescription(
key=UIWidget.TSKALARM_CONTROLLER,
key=UIWidget.TSK_ALARM_CONTROLLER,
entity_registry_enabled_default=False,
supported_features=(
AlarmControlPanelEntityFeature.ARM_AWAY
@@ -165,7 +165,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state.qualified_name))
if (description := SUPPORTED_STATES.get(state))
)
async_add_entities(entities)
+1 -1
View File
@@ -120,7 +120,7 @@ async def async_setup_entry(
description,
)
for command in device.definition.commands
if (description := SUPPORTED_COMMANDS.get(command.command_name))
if (description := SUPPORTED_COMMANDS.get(command))
)
async_add_entities(entities)
@@ -115,12 +115,13 @@ async def async_setup_entry(
# Match devices based on the widget and protocol.
# #ie Hitachi Air To Air Heat Pumps
entities_based_on_widget_and_protocol: list[Entity] = [
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol](
device.device_url, data.coordinator
)
WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][
device.identifier.protocol
](device.device_url, data.coordinator)
for device in data.platforms[Platform.CLIMATE]
if device.widget in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY
and device.protocol in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget]
and device.identifier.protocol
in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget]
]
async_add_entities(
@@ -157,7 +157,7 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
@property
def target_temperature(self) -> float | None:
"""Return the temperature."""
if state := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]:
if state := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE):
return state.value_as_float
return None
@@ -165,7 +165,9 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return temperature.value_as_float
return None
@@ -104,7 +104,9 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return cast(float, temperature.value)
@@ -67,7 +67,9 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return cast(float, temperature.value)
@@ -106,7 +106,9 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return cast(float, temperature.value)
@@ -74,7 +74,7 @@ class EvoHomeController(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states[OverkizState.RAMSES_RAMSES_OPERATING_MODE]
state := self.device.states.get(OverkizState.RAMSES_RAMSES_OPERATING_MODE)
) and state.value_as_str in OVERKIZ_TO_PRESET_MODES:
return OVERKIZ_TO_PRESET_MODES[state.value_as_str]
@@ -114,13 +114,13 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if (
main_op_state := self.device.states[MAIN_OPERATION_STATE]
main_op_state := self.device.states.get(MAIN_OPERATION_STATE)
) and main_op_state.value_as_str:
if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF:
return HVACMode.OFF
if (
mode_change_state := self.device.states[MODE_CHANGE_STATE]
mode_change_state := self.device.states.get(MODE_CHANGE_STATE)
) and mode_change_state.value_as_str:
sanitized_value = mode_change_state.value_as_str.lower()
return OVERKIZ_TO_HVAC_MODES[sanitized_value]
@@ -140,7 +140,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def fan_mode(self) -> str | None:
"""Return the fan setting."""
if (state := self.device.states[FAN_SPEED_STATE]) and state.value_as_str:
if (state := self.device.states.get(FAN_SPEED_STATE)) and state.value_as_str:
return OVERKIZ_TO_FAN_MODES[state.value_as_str]
return None
@@ -157,7 +157,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def swing_mode(self) -> str | None:
"""Return the swing setting."""
if (state := self.device.states[SWING_STATE]) and state.value_as_str:
if (state := self.device.states.get(SWING_STATE)) and state.value_as_str:
return OVERKIZ_TO_SWING_MODES[state.value_as_str]
return None
@@ -170,7 +170,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
def target_temperature(self) -> int | None:
"""Return the temperature."""
if (
temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
temperature := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE)
) and temperature.value_as_int:
return temperature.value_as_int
@@ -179,7 +179,9 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def current_temperature(self) -> int | None:
"""Return current temperature."""
if (state := self.device.states[ROOM_TEMPERATURE_STATE]) and state.value_as_int:
if (
state := self.device.states.get(ROOM_TEMPERATURE_STATE)
) and state.value_as_int:
return state.value_as_int
return None
@@ -192,7 +194,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (state := self.device.states[LEAVE_HOME_STATE]) and state.value_as_str:
if (state := self.device.states.get(LEAVE_HOME_STATE)) and state.value_as_str:
if state.value_as_str == OverkizCommandParam.ON:
return PRESET_HOLIDAY_MODE
@@ -222,7 +224,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity):
"""
if value:
return value
state = self.device.states[state_name]
state = self.device.states.get(state_name)
if state and state.value_as_str:
return state.value_as_str
return fallback_value
@@ -118,13 +118,13 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if (
main_op_state := self.device.states[OverkizState.OVP_MAIN_OPERATION]
main_op_state := self.device.states.get(OverkizState.OVP_MAIN_OPERATION)
) and main_op_state.value_as_str:
if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF:
return HVACMode.OFF
if (
mode_change_state := self.device.states[OverkizState.OVP_MODE_CHANGE]
mode_change_state := self.device.states.get(OverkizState.OVP_MODE_CHANGE)
) and mode_change_state.value_as_str:
# The OVP protocol has 'auto cooling' and 'auto heating' values
# that are equivalent to the HLRRWIFI protocol without spaces
@@ -147,7 +147,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def fan_mode(self) -> str | None:
"""Return the fan setting."""
if (
state := self.device.states[OverkizState.OVP_FAN_SPEED]
state := self.device.states.get(OverkizState.OVP_FAN_SPEED)
) and state.value_as_str:
return OVERKIZ_TO_FAN_MODES[state.value_as_str]
@@ -160,7 +160,9 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
@property
def swing_mode(self) -> str | None:
"""Return the swing setting."""
if (state := self.device.states[OverkizState.OVP_SWING]) and state.value_as_str:
if (
state := self.device.states.get(OverkizState.OVP_SWING)
) and state.value_as_str:
return OVERKIZ_TO_SWING_MODES[state.value_as_str]
return None
@@ -173,7 +175,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def target_temperature(self) -> int | None:
"""Return the target temperature."""
if (
temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
temperature := self.device.states.get(OverkizState.CORE_TARGET_TEMPERATURE)
) and temperature.value_as_int:
return temperature.value_as_int
@@ -183,7 +185,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def current_temperature(self) -> int | None:
"""Return current temperature."""
if (
state := self.device.states[OverkizState.OVP_ROOM_TEMPERATURE]
state := self.device.states.get(OverkizState.OVP_ROOM_TEMPERATURE)
) and state.value_as_int:
return state.value_as_int
@@ -197,7 +199,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states[OverkizState.CORE_HOLIDAYS_MODE]
state := self.device.states.get(OverkizState.CORE_HOLIDAYS_MODE)
) and state.value_as_str:
if state.value_as_str == OverkizCommandParam.ON:
return PRESET_HOLIDAY_MODE
@@ -225,7 +227,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def auto_manu_mode(self) -> str | None:
"""Return auto/manu mode."""
if (
state := self.device.states[OverkizState.CORE_AUTO_MANU_MODE]
state := self.device.states.get(OverkizState.CORE_AUTO_MANU_MODE)
) and state.value_as_str:
return state.value_as_str
return None
@@ -235,7 +237,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
def temperature_change(self) -> int | None:
"""Return temperature change state."""
if (
state := self.device.states[OverkizState.OVP_TEMPERATURE_CHANGE]
state := self.device.states.get(OverkizState.OVP_TEMPERATURE_CHANGE)
) and state.value_as_int:
return state.value_as_int
@@ -266,7 +268,7 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity):
"""
if value:
return value
if (state := self.device.states[state_name]) is not None and (
if (state := self.device.states.get(state_name)) is not None and (
value := state.value_as_str
) is not None:
return value
@@ -60,7 +60,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if (
state := self.device.states[OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1]
state := self.device.states.get(OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1)
) and state.value_as_str:
return OVERKIZ_TO_HVAC_MODE[state.value_as_str]
@@ -76,7 +76,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states[OverkizState.MODBUS_YUTAKI_TARGET_MODE]
state := self.device.states.get(OverkizState.MODBUS_YUTAKI_TARGET_MODE)
) and state.value_as_str:
return OVERKIZ_TO_PRESET_MODE[state.value_as_str]
@@ -91,9 +91,9 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
current_temperature = self.device.states[
current_temperature = self.device.states.get(
OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1
]
)
if current_temperature:
return current_temperature.value_as_float
@@ -103,9 +103,9 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
target_temperature = self.device.states[
target_temperature = self.device.states.get(
OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE_1
]
)
if target_temperature:
return target_temperature.value_as_float
@@ -99,14 +99,14 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation i.e. heat, cool mode."""
state = self.device.states[OverkizState.CORE_ON_OFF]
state = self.device.states.get(OverkizState.CORE_ON_OFF)
if state and state.value_as_str == OverkizCommandParam.OFF:
return HVACMode.OFF
if (
state := self.device.states[
state := self.device.states.get(
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_ACTIVE_MODE
]
)
) and state.value_as_str:
return OVERKIZ_TO_HVAC_MODES[state.value_as_str]
@@ -127,9 +127,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
if (
state := self.device.states[
state := self.device.states.get(
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE
]
)
) and state.value_as_str:
return OVERKIZ_TO_PRESET_MODES[state.value_as_str]
return None
@@ -145,9 +145,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac operation if supported."""
if (
current_operation := self.device.states[
current_operation := self.device.states.get(
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_OPERATING_MODE
]
)
) and current_operation.value_as_str:
return OVERKIZ_TO_HVAC_ACTION[current_operation.value_as_str]
@@ -167,7 +167,7 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
if mode not in MAP_PRESET_TEMPERATURES:
return None
if state := self.device.states[MAP_PRESET_TEMPERATURES[mode]]:
if state := self.device.states.get(MAP_PRESET_TEMPERATURES[mode]):
return state.value_as_float
return None
@@ -175,7 +175,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return temperature.value_as_float
return None
@@ -185,9 +187,9 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
temperature = kwargs[ATTR_TEMPERATURE]
if (
mode := self.device.states[
mode := self.device.states.get(
OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_SETPOINT_MODE
]
)
) and mode.value_as_str:
await self.executor.async_execute_command(
SETPOINT_MODE_TO_OVERKIZ_COMMAND[mode.value_as_str], temperature
@@ -40,10 +40,10 @@ OVERKIZ_TO_PRESET_MODES: dict[OverkizCommandParam, str] = {
PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()}
TARGET_TEMP_TO_OVERKIZ = {
PRESET_HOME: OverkizState.SOMFY_THERMOSTAT_AT_HOME_TARGET_TEMPERATURE,
PRESET_AWAY: OverkizState.SOMFY_THERMOSTAT_AWAY_MODE_TARGET_TEMPERATURE,
PRESET_FREEZE: OverkizState.SOMFY_THERMOSTAT_FREEZE_MODE_TARGET_TEMPERATURE,
PRESET_NIGHT: OverkizState.SOMFY_THERMOSTAT_SLEEPING_MODE_TARGET_TEMPERATURE,
PRESET_HOME: OverkizState.SOMFYTHERMOSTAT_AT_HOME_TARGET_TEMPERATURE,
PRESET_AWAY: OverkizState.SOMFYTHERMOSTAT_AWAY_MODE_TARGET_TEMPERATURE,
PRESET_FREEZE: OverkizState.SOMFYTHERMOSTAT_FREEZE_MODE_TARGET_TEMPERATURE,
PRESET_NIGHT: OverkizState.SOMFYTHERMOSTAT_SLEEPING_MODE_TARGET_TEMPERATURE,
}
# controllableName is somfythermostat:SomfyThermostatTemperatureSensor
@@ -88,9 +88,9 @@ class SomfyThermostat(OverkizEntity, ClimateEntity):
def preset_mode(self) -> str:
"""Return the current preset mode, e.g., home, away, temp."""
if self.hvac_mode == HVACMode.AUTO:
state_key = OverkizState.SOMFY_THERMOSTAT_HEATING_MODE
state_key = OverkizState.SOMFYTHERMOSTAT_HEATING_MODE
else:
state_key = OverkizState.SOMFY_THERMOSTAT_DEROGATION_HEATING_MODE
state_key = OverkizState.SOMFYTHERMOSTAT_DEROGATION_HEATING_MODE
if state := self.executor.select_state(state_key):
return OVERKIZ_TO_PRESET_MODES[OverkizCommandParam(cast(str, state))]
@@ -101,7 +101,9 @@ class SomfyThermostat(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return cast(float, temperature.value)
return None
@@ -91,7 +91,9 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.temperature_device is not None and (
temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]
temperature := self.temperature_device.states.get(
OverkizState.CORE_TEMPERATURE
)
):
return temperature.value_as_float
+32 -29
View File
@@ -4,21 +4,25 @@ from collections.abc import Mapping
from typing import Any, cast
from aiohttp import ClientConnectorCertificateError, ClientError
from pyoverkiz.auth.credentials import (
LocalTokenCredentials,
UsernamePasswordCredentials,
)
from pyoverkiz.client import OverkizClient
from pyoverkiz.const import SERVERS_WITH_LOCAL_API, SUPPORTED_SERVERS
from pyoverkiz.enums import APIType, Server
from pyoverkiz.exceptions import (
BadCredentialsException,
CozyTouchBadCredentialsException,
MaintenanceException,
NotAuthenticatedException,
NotSuchTokenException,
TooManyAttemptsBannedException,
TooManyRequestsException,
UnknownUserException,
BadCredentialsError,
CozyTouchBadCredentialsError,
MaintenanceError,
NoSuchTokenError,
NotAuthenticatedError,
TooManyAttemptsBannedError,
TooManyRequestsError,
UnknownUserError,
)
from pyoverkiz.obfuscate import obfuscate_id
from pyoverkiz.utils import generate_local_server, is_overkiz_gateway
from pyoverkiz.utils import create_local_server_config, is_overkiz_gateway
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
@@ -58,19 +62,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
self.hass, verify_ssl=user_input[CONF_VERIFY_SSL]
)
client = OverkizClient(
username="",
password="",
token=user_input[CONF_TOKEN],
server=create_local_server_config(host=user_input[CONF_HOST]),
credentials=LocalTokenCredentials(user_input[CONF_TOKEN]),
session=session,
server=generate_local_server(host=user_input[CONF_HOST]),
verify_ssl=user_input[CONF_VERIFY_SSL],
)
else: # APIType.CLOUD
session = async_create_clientsession(self.hass)
client = OverkizClient(
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
server=SUPPORTED_SERVERS[user_input[CONF_HUB]],
server=user_input[CONF_HUB],
credentials=UsernamePasswordCredentials(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
),
session=session,
)
@@ -149,9 +152,9 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
try:
await self.async_validate_input(user_input)
except TooManyRequestsException:
except TooManyRequestsError:
errors["base"] = "too_many_requests"
except (BadCredentialsException, NotAuthenticatedException) as exception:
except (BadCredentialsError, NotAuthenticatedError) as exception:
# If authentication with CozyTouch auth server is
# valid, but token is invalid for Overkiz API
# server, the hardware is not supported.
@@ -159,18 +162,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
Server.ATLANTIC_COZYTOUCH,
Server.SAUTER_COZYTOUCH,
Server.THERMOR_COZYTOUCH,
} and not isinstance(exception, CozyTouchBadCredentialsException):
} and not isinstance(exception, CozyTouchBadCredentialsError):
description_placeholders["unsupported_device"] = "CozyTouch"
errors["base"] = "unsupported_hardware"
else:
errors["base"] = "invalid_auth"
except TimeoutError, ClientError:
errors["base"] = "cannot_connect"
except MaintenanceException:
except MaintenanceError:
errors["base"] = "server_in_maintenance"
except TooManyAttemptsBannedException:
except TooManyAttemptsBannedError:
errors["base"] = "too_many_attempts"
except UnknownUserException:
except UnknownUserError:
# If the user has no supported CozyTouch devices on
# the Overkiz API server. Login will return unknown user.
if user_input[CONF_HUB] in {
@@ -239,12 +242,12 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
try:
user_input = await self.async_validate_input(user_input)
except TooManyRequestsException:
except TooManyRequestsError:
errors["base"] = "too_many_requests"
except (
BadCredentialsException,
NotSuchTokenException,
NotAuthenticatedException,
BadCredentialsError,
NoSuchTokenError,
NotAuthenticatedError,
):
errors["base"] = "invalid_auth"
except ClientConnectorCertificateError as exception:
@@ -253,11 +256,11 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
except (TimeoutError, ClientError) as exception:
errors["base"] = "cannot_connect"
LOGGER.debug(exception)
except MaintenanceException:
except MaintenanceError:
errors["base"] = "server_in_maintenance"
except TooManyAttemptsBannedException:
except TooManyAttemptsBannedError:
errors["base"] = "too_many_attempts"
except UnknownUserException:
except UnknownUserError:
# Somfy Protect accounts are not supported since they don't use
# the Overkiz API server. Login will return unknown user.
description_placeholders["unsupported_device"] = "Somfy Protect"
+1 -1
View File
@@ -118,7 +118,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = {
UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH,
UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH,
UIWidget.TSKALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
UIWidget.TSK_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL,
UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE,
}
+39 -33
View File
@@ -9,15 +9,23 @@ from aiohttp import ClientConnectorError, ServerDisconnectedError
from pyoverkiz.client import OverkizClient
from pyoverkiz.enums import EventName, ExecutionState, Protocol
from pyoverkiz.exceptions import (
BadCredentialsException,
InvalidEventListenerIdException,
MaintenanceException,
NotAuthenticatedException,
ServiceUnavailableException,
TooManyConcurrentRequestsException,
TooManyRequestsException,
BadCredentialsError,
InvalidEventListenerIdError,
MaintenanceError,
NotAuthenticatedError,
ServiceUnavailableError,
TooManyConcurrentRequestsError,
TooManyRequestsError,
)
from pyoverkiz.models import (
Device,
DeviceEvent,
DeviceRemovedEvent,
DeviceStateChangedEvent,
ExecutionRegisteredEvent,
ExecutionStateChangedEvent,
Place,
)
from pyoverkiz.models import Device, Event, Place
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -30,8 +38,9 @@ if TYPE_CHECKING:
from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, LOGGER, UPDATE_INTERVAL
# Events are a discriminated union; each handler narrows to its own subtype.
EVENT_HANDLERS: Registry[
str, Callable[[OverkizDataUpdateCoordinator, Event], Coroutine[Any, Any, None]]
str, Callable[[OverkizDataUpdateCoordinator, Any], Coroutine[Any, Any, None]]
] = Registry()
@@ -68,7 +77,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
self._default_update_interval = UPDATE_INTERVAL
self.is_stateless = all(
device.protocol in (Protocol.RTS, Protocol.INTERNAL)
device.identifier.protocol in (Protocol.RTS, Protocol.INTERNAL)
for device in devices
if device.widget not in IGNORED_OVERKIZ_DEVICES
and device.ui_class not in IGNORED_OVERKIZ_DEVICES
@@ -78,17 +87,17 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
"""Fetch Overkiz data via event listener."""
try:
events = await self.client.fetch_events()
except (BadCredentialsException, NotAuthenticatedException) as exception:
except (BadCredentialsError, NotAuthenticatedError) as exception:
raise ConfigEntryAuthFailed("Invalid authentication.") from exception
except TooManyConcurrentRequestsException as exception:
except TooManyConcurrentRequestsError as exception:
raise UpdateFailed("Too many concurrent requests.") from exception
except TooManyRequestsException as exception:
except TooManyRequestsError as exception:
raise UpdateFailed("Too many requests, try again later.") from exception
except MaintenanceException as exception:
except MaintenanceError as exception:
raise UpdateFailed("Server is down for maintenance.") from exception
except ServiceUnavailableException as exception:
except ServiceUnavailableError as exception:
raise UpdateFailed("Server is unavailable.") from exception
except InvalidEventListenerIdException as exception:
except InvalidEventListenerIdError as exception:
raise UpdateFailed(exception) from exception
except (TimeoutError, ClientConnectorError) as exception:
LOGGER.debug("Failed to connect", exc_info=True)
@@ -100,9 +109,9 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
try:
await self.client.login()
self.devices = await self._get_devices()
except (BadCredentialsException, NotAuthenticatedException) as exception:
except (BadCredentialsError, NotAuthenticatedError) as exception:
raise ConfigEntryAuthFailed("Invalid authentication.") from exception
except TooManyRequestsException as exception:
except TooManyRequestsError as exception:
raise UpdateFailed("Too many requests, try again later.") from exception
return self.devices
@@ -144,27 +153,27 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
@EVENT_HANDLERS.register(EventName.DEVICE_AVAILABLE)
async def on_device_available(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
) -> None:
"""Handle device available event."""
if event.device_url and event.device_url in coordinator.devices:
if event.device_url in coordinator.devices:
coordinator.devices[event.device_url].available = True
@EVENT_HANDLERS.register(EventName.DEVICE_UNAVAILABLE)
@EVENT_HANDLERS.register(EventName.DEVICE_DISABLED)
async def on_device_unavailable_disabled(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
) -> None:
"""Handle device unavailable / disabled event."""
if event.device_url and event.device_url in coordinator.devices:
if event.device_url in coordinator.devices:
coordinator.devices[event.device_url].available = False
@EVENT_HANDLERS.register(EventName.DEVICE_CREATED)
@EVENT_HANDLERS.register(EventName.DEVICE_UPDATED)
async def on_device_created_updated(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceEvent
) -> None:
"""Handle device unavailable / disabled event."""
coordinator.hass.async_create_task(
@@ -174,10 +183,10 @@ async def on_device_created_updated(
@EVENT_HANDLERS.register(EventName.DEVICE_STATE_CHANGED)
async def on_device_state_changed(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceStateChangedEvent
) -> None:
"""Handle device state changed event."""
if not event.device_url or event.device_url not in coordinator.devices:
if event.device_url not in coordinator.devices:
return
for state in event.device_states:
@@ -187,12 +196,9 @@ async def on_device_state_changed(
@EVENT_HANDLERS.register(EventName.DEVICE_REMOVED)
async def on_device_removed(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: DeviceRemovedEvent
) -> None:
"""Handle device removed event."""
if not event.device_url:
return
base_device_url = event.device_url.split("#")[0]
registry = dr.async_get(coordinator.hass)
@@ -201,16 +207,16 @@ async def on_device_removed(
):
registry.async_remove_device(registered_device.id)
if event.device_url and event.device_url in coordinator.devices:
if event.device_url in coordinator.devices:
del coordinator.devices[event.device_url]
@EVENT_HANDLERS.register(EventName.EXECUTION_REGISTERED)
async def on_execution_registered(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: ExecutionRegisteredEvent
) -> None:
"""Handle execution registered event."""
if event.exec_id and event.exec_id not in coordinator.executions:
if event.exec_id not in coordinator.executions:
coordinator.executions[event.exec_id] = {}
if not coordinator.is_stateless:
@@ -219,7 +225,7 @@ async def on_execution_registered(
@EVENT_HANDLERS.register(EventName.EXECUTION_STATE_CHANGED)
async def on_execution_state_changed(
coordinator: OverkizDataUpdateCoordinator, event: Event
coordinator: OverkizDataUpdateCoordinator, event: ExecutionStateChangedEvent
) -> None:
"""Handle execution changed event."""
if event.exec_id in coordinator.executions and event.new_state in [
+7 -5
View File
@@ -631,7 +631,7 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
"""
state_name = self.entity_description.current_position_state
if not state_name or not (state := self.device.states[state_name]):
if not state_name or not (state := self.device.states.get(state_name)):
return None
position = state.value_as_int
@@ -645,9 +645,9 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
state_name,
)
if fallback_state := self.device.states[
if fallback_state := self.device.states.get(
OverkizState.CORE_MEMORIZED_1_POSITION
]:
):
position = fallback_state.value_as_int
else:
return None
@@ -661,7 +661,9 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
state_name,
)
if fallback_state := self.device.states[OverkizState.CORE_TARGET_CLOSURE]:
if fallback_state := self.device.states.get(
OverkizState.CORE_TARGET_CLOSURE
):
position = fallback_state.value_as_int
else:
return None
@@ -707,7 +709,7 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
"""
state_name = self.entity_description.current_tilt_position_state
if state_name and (state := self.device.states[state_name]):
if state_name and (state := self.device.states.get(state_name)):
position = state.value_as_int
if position is None:
return None
@@ -19,13 +19,13 @@ async def async_get_config_entry_diagnostics(
client = entry.runtime_data.coordinator.client
data = {
"setup": await client.get_diagnostic_data(),
**await client.get_diagnostic_data(),
"server": entry.data[CONF_HUB],
"api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD),
}
# Only Overkiz cloud servers expose an endpoint with execution history
if client.api_type == APIType.CLOUD:
if client.server_config.api_type == APIType.CLOUD:
execution_history = [
repr(execution) for execution in await client.get_execution_history()
]
@@ -49,13 +49,13 @@ async def async_get_device_diagnostics(
"device_url": obfuscate_id(device_url),
"model": device.model,
},
"setup": await client.get_diagnostic_data(),
**await client.get_diagnostic_data(),
"server": entry.data[CONF_HUB],
"api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD),
}
# Only Overkiz cloud servers expose an endpoint with execution history
if client.api_type == APIType.CLOUD:
if client.server_config.api_type == APIType.CLOUD:
data["execution_history"] = [
repr(execution)
for execution in await client.get_execution_history()
+3 -3
View File
@@ -49,7 +49,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
# Workaround: local API may incorrectly report
# available=False (Somfy-TaHoma-Developer-Mode#217)
if self.coordinator.client.api_type != APIType.LOCAL:
if self.coordinator.client.server_config.api_type != APIType.LOCAL:
return False
if status_state := self.device.states.get(OverkizState.CORE_STATUS):
@@ -85,7 +85,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
manufacturer = (
self.executor.select_attribute(OverkizAttribute.CORE_MANUFACTURER)
or self.executor.select_state(OverkizState.CORE_MANUFACTURER_NAME)
or self.coordinator.client.server.manufacturer
or self.coordinator.client.server_config.manufacturer
)
model = (
@@ -116,7 +116,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
hw_version=self.device.controllable_name,
suggested_area=suggested_area,
via_device=(DOMAIN, self.executor.get_gateway_id()),
configuration_url=self.coordinator.client.server.configuration_url,
configuration_url=self.coordinator.client.server_config.configuration_url,
)
+25 -23
View File
@@ -1,11 +1,11 @@
"""Class for helpers and communication with the OverKiz API."""
from typing import Any, cast
from typing import Any
from urllib.parse import urlparse
from pyoverkiz.enums import OverkizCommand, Protocol
from pyoverkiz.exceptions import BaseOverkizException
from pyoverkiz.models import Command, Device, StateDefinition
from pyoverkiz.exceptions import BaseOverkizError
from pyoverkiz.models import Action, Command, Device, StateDefinition
from pyoverkiz.types import StateType as OverkizStateType
from homeassistant.exceptions import HomeAssistantError
@@ -56,15 +56,15 @@ class OverkizExecutor:
def select_definition_state(self, *states: str) -> StateDefinition | None:
"""Select first existing definition state in a list of states."""
for existing_state in self.device.definition.states:
if existing_state.qualified_name in states:
return existing_state
for state_name in states:
if state_name in self.device.definition.states:
return self.device.definition.states[state_name]
return None
def select_state(self, *states: str) -> OverkizStateType:
"""Select first existing active state in a list of states."""
for state in states:
if current_state := self.device.states[state]:
if current_state := self.device.states.get(state):
return current_state.value
return None
@@ -76,7 +76,7 @@ class OverkizExecutor:
def select_attribute(self, *attributes: str) -> OverkizStateType:
"""Select first existing active state in a list of states."""
for attribute in attributes:
if current_attribute := self.device.attributes[attribute]:
if current_attribute := self.device.attributes.get(attribute):
return current_attribute.value
return None
@@ -94,19 +94,23 @@ class OverkizExecutor:
# Set the execution duration to 0 seconds for RTS devices on supported commands
# Default execution duration is 30 seconds and will block consecutive commands
if (
self.device.protocol == Protocol.RTS
self.device.identifier.protocol == Protocol.RTS
and command_name not in COMMANDS_WITHOUT_DELAY
):
parameters.append(0)
try:
exec_id = await self.coordinator.client.execute_command(
self.device.device_url,
Command(command_name, parameters),
"Home Assistant",
exec_id = await self.coordinator.client.execute_action_group(
label="Home Assistant",
actions=[
Action(
device_url=self.device.device_url,
commands=[Command(name=command_name, parameters=parameters)],
)
],
)
# Catch Overkiz exceptions to support `continue_on_error` functionality
except BaseOverkizException as exception:
except BaseOverkizError as exception:
raise HomeAssistantError(exception) from exception
# ExecutionRegisteredEvent doesn't contain the
@@ -142,18 +146,16 @@ class OverkizExecutor:
return True
# Retrieve executions initiated outside Home Assistant via API
executions = cast(Any, await self.coordinator.client.get_current_executions())
# executions.action_group is typed incorrectly in the upstream library
# or the below code is incorrect.
executions = await self.coordinator.client.get_current_executions()
exec_id = next(
(
execution.id
for execution in executions
# Reverse dictionary to cancel the last added execution
for action in reversed(execution.action_group.get("actions"))
for command in action.get("commands")
if action.get("device_url") == self.device.device_url
and command.get("name") in commands_to_cancel
if execution.action_group
for action in reversed(execution.action_group.actions)
for command in action.commands
if action.device_url == self.device.device_url
and command.name in commands_to_cancel
),
None,
)
@@ -166,7 +168,7 @@ class OverkizExecutor:
async def async_cancel_execution(self, exec_id: str) -> None:
"""Cancel running execution via execution id."""
await self.coordinator.client.cancel_command(exec_id)
await self.coordinator.client.cancel_execution(exec_id)
def get_gateway_id(self) -> str:
"""Retrieve gateway id from device url.
@@ -12,8 +12,8 @@
"documentation": "https://www.home-assistant.io/integrations/overkiz",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.20.4"],
"loggers": ["boto3", "botocore", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz[nexity]==2.0.0"],
"zeroconf": [
{
"name": "gateway*",
+1 -1
View File
@@ -213,7 +213,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state.qualified_name))
if (description := SUPPORTED_STATES.get(state))
)
async_add_entities(entities)
+3 -3
View File
@@ -3,7 +3,7 @@
from typing import Any
from pyoverkiz.client import OverkizClient
from pyoverkiz.models import Scenario
from pyoverkiz.models import PersistedActionGroup
from homeassistant.components.scene import Scene
from homeassistant.core import HomeAssistant
@@ -28,7 +28,7 @@ async def async_setup_entry(
class OverkizScene(Scene):
"""Representation of an Overkiz Scene."""
def __init__(self, scenario: Scenario, client: OverkizClient) -> None:
def __init__(self, scenario: PersistedActionGroup, client: OverkizClient) -> None:
"""Initialize the scene."""
self.scenario = scenario
self.client = client
@@ -37,4 +37,4 @@ class OverkizScene(Scene):
async def async_activate(self, **kwargs: Any) -> None:
"""Activate the scene."""
await self.client.execute_scenario(self.scenario.oid)
await self.client.execute_persisted_action_group(self.scenario.oid)
+1 -1
View File
@@ -144,7 +144,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state.qualified_name))
if (description := SUPPORTED_STATES.get(state))
)
async_add_entities(entities)
+3 -3
View File
@@ -550,7 +550,7 @@ async def async_setup_entry(
description,
)
for state in device.definition.states
if (description := SUPPORTED_STATES.get(state.qualified_name))
if (description := SUPPORTED_STATES.get(state))
)
async_add_entities(entities)
@@ -597,12 +597,12 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity):
return default_unit
attrs = self.device.attributes
if (unit := attrs[f"{state.name}MeasuredValueType"]) and (
if (unit := attrs.get(f"{state.name}MeasuredValueType")) and (
unit_value := unit.value_as_str
):
return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
if (unit := attrs[OverkizAttribute.CORE_MEASURED_VALUE_TYPE]) and (
if (unit := attrs.get(OverkizAttribute.CORE_MEASURED_VALUE_TYPE)) and (
unit_value := unit.value_as_str
):
ha_unit = OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
@@ -48,7 +48,9 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater
def min_temp(self) -> float:
"""Return the minimum temperature."""
min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE]
min_temp = self.device.states.get(
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
)
if min_temp:
return cast(float, min_temp.value_as_float)
return DEFAULT_MIN_TEMP
@@ -57,7 +59,9 @@ class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeater
def max_temp(self) -> float:
"""Return the maximum temperature."""
max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE]
max_temp = self.device.states.get(
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
)
if max_temp:
return cast(float, max_temp.value_as_float)
return DEFAULT_MAX_TEMP
@@ -156,7 +156,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE]
min_temp = self.device.states.get(
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
)
if min_temp:
return cast(float, min_temp.value_as_float)
return DEFAULT_MIN_TEMP
@@ -164,7 +166,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE]
max_temp = self.device.states.get(
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
)
if max_temp:
return cast(float, max_temp.value_as_float)
return DEFAULT_MAX_TEMP
@@ -172,14 +176,14 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
current_temperature = self.device.states[
current_temperature = self.device.states.get(
OverkizState.IO_MIDDLE_WATER_TEMPERATURE
]
)
if current_temperature:
return current_temperature.value_as_float
current_temperature = self.device.states[
current_temperature = self.device.states.get(
OverkizState.MODBUSLINK_MIDDLE_WATER_TEMPERATURE
]
)
if current_temperature:
return current_temperature.value_as_float
return None
@@ -188,19 +192,21 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
target_temperature = self.device.states[
target_temperature = self.device.states.get(
OverkizState.CORE_WATER_TARGET_TEMPERATURE
]
)
if target_temperature:
return target_temperature.value_as_float
target_temperature = self.device.states[
target_temperature = self.device.states.get(
OverkizState.CORE_TARGET_DWH_TEMPERATURE
]
)
if target_temperature:
return target_temperature.value_as_float
target_temperature = self.device.states[OverkizState.CORE_TARGET_TEMPERATURE]
target_temperature = self.device.states.get(
OverkizState.CORE_TARGET_TEMPERATURE
)
if target_temperature:
return target_temperature.value_as_float
@@ -209,9 +215,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def target_temperature_high(self) -> float | None:
"""Return the highbound target temperature we try to reach."""
target_temperature_high = self.device.states[
target_temperature_high = self.device.states.get(
OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE
]
)
if target_temperature_high:
return target_temperature_high.value_as_float
return None
@@ -219,9 +225,9 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity):
@property
def target_temperature_low(self) -> float | None:
"""Return the lowbound target temperature we try to reach."""
target_temperature_low = self.device.states[
target_temperature_low = self.device.states.get(
OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE
]
)
if target_temperature_low:
return target_temperature_low.value_as_float
return None
@@ -45,7 +45,7 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
current_temperature = self.device.states[OverkizState.CORE_DHW_TEMPERATURE]
current_temperature = self.device.states.get(OverkizState.CORE_DHW_TEMPERATURE)
if current_temperature and current_temperature.value_as_int:
return float(current_temperature.value_as_int)
@@ -55,9 +55,9 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
target_temperature = self.device.states[
target_temperature = self.device.states.get(
OverkizState.MODBUS_CONTROL_DHW_SETTING_TEMPERATURE
]
)
if target_temperature and target_temperature.value_as_int:
return float(target_temperature.value_as_int)
@@ -74,11 +74,11 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity):
@property
def current_operation(self) -> str | None:
"""Return current operation ie. eco, electric, performance, ..."""
modbus_control = self.device.states[OverkizState.MODBUS_CONTROL_DHW]
modbus_control = self.device.states.get(OverkizState.MODBUS_CONTROL_DHW)
if modbus_control and modbus_control.value_as_str == OverkizCommandParam.STOP:
return STATE_OFF
current_mode = self.device.states[OverkizState.MODBUS_DHW_MODE]
current_mode = self.device.states.get(OverkizState.MODBUS_DHW_MODE)
if current_mode and current_mode.value_as_str in OVERKIZ_TO_OPERATION_MODE:
return OVERKIZ_TO_OPERATION_MODE[current_mode.value_as_str]
+36 -26
View File
@@ -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."""

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