mirror of
https://github.com/home-assistant/core.git
synced 2025-11-18 23:40:09 +00:00
Compare commits
111 Commits
add-includ
...
epenet-202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bb08e4abe | ||
|
|
4874610ad6 | ||
|
|
9180282fc6 | ||
|
|
118f30f32e | ||
|
|
bd10da126f | ||
|
|
b73a7928ca | ||
|
|
3e20c2ea93 | ||
|
|
60130d3d68 | ||
|
|
c45ede2e5d | ||
|
|
e167061f53 | ||
|
|
5560fb6c9e | ||
|
|
9808b6c961 | ||
|
|
e8cfde579e | ||
|
|
f695fb4d51 | ||
|
|
a0e0549d90 | ||
|
|
ba034c6c8c | ||
|
|
008bb85c59 | ||
|
|
cf1c1294d3 | ||
|
|
11d5d314cc | ||
|
|
6f0de3071a | ||
|
|
87d2597292 | ||
|
|
437bc04fe8 | ||
|
|
67a0d6a187 | ||
|
|
abb52bca81 | ||
|
|
d2d6889278 | ||
|
|
bdca592219 | ||
|
|
5c0c7b9ec3 | ||
|
|
9717599fb9 | ||
|
|
4d7de2f814 | ||
|
|
779590ce1c | ||
|
|
f3a185ff9c | ||
|
|
5a5a106984 | ||
|
|
796b421d99 | ||
|
|
0c03e8dbe9 | ||
|
|
47cf4e3ffe | ||
|
|
0ea0fc151d | ||
|
|
b7e5afec9f | ||
|
|
7a2bb67e82 | ||
|
|
e0612bec07 | ||
|
|
a06f4b6776 | ||
|
|
275670a526 | ||
|
|
d0d62526dd | ||
|
|
aefdf412b0 | ||
|
|
56ab6b2512 | ||
|
|
d1dea85cf5 | ||
|
|
84b0d39763 | ||
|
|
3aff225bc3 | ||
|
|
04458e01be | ||
|
|
ae51cfb8c0 | ||
|
|
c116a9c037 | ||
|
|
fb58758684 | ||
|
|
25fbcbc68c | ||
|
|
a670286b45 | ||
|
|
52ba55b17f | ||
|
|
ff0fc98c36 | ||
|
|
9f78a2263d | ||
|
|
9b4696a80b | ||
|
|
70fe8cae39 | ||
|
|
95eb45ab08 | ||
|
|
84f8e57141 | ||
|
|
f484b6df0d | ||
|
|
34c1d45ee0 | ||
|
|
09a105d9ad | ||
|
|
6bd1787d0a | ||
|
|
37040f5064 | ||
|
|
531397ec07 | ||
|
|
d6cc0f81de | ||
|
|
f8ef8a466a | ||
|
|
713015e26a | ||
|
|
f9c1e81c5e | ||
|
|
0549d113e6 | ||
|
|
0d842978ec | ||
|
|
55476ef6ea | ||
|
|
0e130d8fdd | ||
|
|
20bcb84956 | ||
|
|
bbb1d57081 | ||
|
|
121406569b | ||
|
|
4866c775ce | ||
|
|
7c5ab12270 | ||
|
|
099edfac20 | ||
|
|
aa31df0fd5 | ||
|
|
13fbeb6cdb | ||
|
|
8d557447df | ||
|
|
e6e3f2455f | ||
|
|
c9c518ee84 | ||
|
|
214731e964 | ||
|
|
c4b09c9a0a | ||
|
|
f5b5b2fb70 | ||
|
|
bb3cdd382b | ||
|
|
8d09b5c273 | ||
|
|
d92fa7fa72 | ||
|
|
0c45b7f615 | ||
|
|
bfa1116115 | ||
|
|
4984237987 | ||
|
|
3839573151 | ||
|
|
e02dc53df3 | ||
|
|
bedae1e12c | ||
|
|
b4eb73be98 | ||
|
|
0ac3f776fa | ||
|
|
8e8a4fff11 | ||
|
|
579ffcc64d | ||
|
|
81943fb31d | ||
|
|
70dd0bf12e | ||
|
|
c2d462c1e7 | ||
|
|
49e050cc60 | ||
|
|
f6d829a2f3 | ||
|
|
e44e3b6f25 | ||
|
|
af603661c0 | ||
|
|
35c6113777 | ||
|
|
3c2f729ddc | ||
|
|
0d63cb765f |
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -231,6 +231,7 @@ homeassistant.components.google_cloud.*
|
||||
homeassistant.components.google_drive.*
|
||||
homeassistant.components.google_photos.*
|
||||
homeassistant.components.google_sheets.*
|
||||
homeassistant.components.google_weather.*
|
||||
homeassistant.components.govee_ble.*
|
||||
homeassistant.components.gpsd.*
|
||||
homeassistant.components.greeneye_monitor.*
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -607,6 +607,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/google_tasks/ @allenporter
|
||||
/homeassistant/components/google_travel_time/ @eifinger
|
||||
/tests/components/google_travel_time/ @eifinger
|
||||
/homeassistant/components/google_weather/ @tronikos
|
||||
/tests/components/google_weather/ @tronikos
|
||||
/homeassistant/components/govee_ble/ @bdraco
|
||||
/tests/components/govee_ble/ @bdraco
|
||||
/homeassistant/components/govee_light_local/ @Galorhallen
|
||||
@@ -1374,6 +1376,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/sanix/ @tomaszsluszniak
|
||||
/homeassistant/components/satel_integra/ @Tommatheussen
|
||||
/tests/components/satel_integra/ @Tommatheussen
|
||||
/homeassistant/components/saunum/ @mettolen
|
||||
/tests/components/saunum/ @mettolen
|
||||
/homeassistant/components/scene/ @home-assistant/core
|
||||
/tests/components/scene/ @home-assistant/core
|
||||
/homeassistant/components/schedule/ @home-assistant/core
|
||||
|
||||
10
build.yaml
10
build.yaml
@@ -1,10 +1,10 @@
|
||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.1
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.1
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.11.0
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.11.0
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.11.0
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.11.0
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.11.0
|
||||
cosign:
|
||||
base_identity: https://github.com/home-assistant/docker/.*
|
||||
identity: https://github.com/home-assistant/core/.*
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"google_tasks",
|
||||
"google_translate",
|
||||
"google_travel_time",
|
||||
"google_weather",
|
||||
"google_wifi",
|
||||
"google",
|
||||
"nest",
|
||||
|
||||
@@ -45,7 +45,7 @@ SERVICE_REFRESH_SCHEMA = vol.Schema(
|
||||
{vol.Optional(CONF_FORCE, default=False): cv.boolean}
|
||||
)
|
||||
|
||||
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
|
||||
PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
|
||||
type AdGuardConfigEntry = ConfigEntry[AdGuardData]
|
||||
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["adguardhome"],
|
||||
"requirements": ["adguardhome==0.7.0"]
|
||||
"requirements": ["adguardhome==0.8.1"]
|
||||
}
|
||||
|
||||
71
homeassistant/components/adguard/update.py
Normal file
71
homeassistant/components/adguard/update.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""AdGuard Home Update platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from adguardhome import AdGuardHomeError
|
||||
|
||||
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AdGuardConfigEntry, AdGuardData
|
||||
from .const import DOMAIN
|
||||
from .entity import AdGuardHomeEntity
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=300)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AdGuardConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AdGuard Home update entity based on a config entry."""
|
||||
data = entry.runtime_data
|
||||
|
||||
if (await data.client.update.update_available()).disabled:
|
||||
return
|
||||
|
||||
async_add_entities([AdGuardHomeUpdate(data, entry)], True)
|
||||
|
||||
|
||||
class AdGuardHomeUpdate(AdGuardHomeEntity, UpdateEntity):
|
||||
"""Defines an AdGuard Home update."""
|
||||
|
||||
_attr_supported_features = UpdateEntityFeature.INSTALL
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: AdGuardData,
|
||||
entry: AdGuardConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize AdGuard Home update."""
|
||||
super().__init__(data, entry)
|
||||
|
||||
self._attr_unique_id = "_".join(
|
||||
[DOMAIN, self.adguard.host, str(self.adguard.port), "update"]
|
||||
)
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
value = await self.adguard.update.update_available()
|
||||
self._attr_installed_version = self.data.version
|
||||
self._attr_latest_version = value.new_version
|
||||
self._attr_release_summary = value.announcement
|
||||
self._attr_release_url = value.announcement_url
|
||||
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Install latest update."""
|
||||
try:
|
||||
await self.adguard.update.begin_update()
|
||||
except AdGuardHomeError as err:
|
||||
raise HomeAssistantError(f"Failed to install update: {err}") from err
|
||||
self.hass.config_entries.async_schedule_reload(self._entry.entry_id)
|
||||
@@ -16,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
|
||||
@@ -43,6 +44,9 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
name=entry.title,
|
||||
config_entry=entry,
|
||||
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||
request_refresh_debouncer=Debouncer(
|
||||
hass, _LOGGER, cooldown=30, immediate=False
|
||||
),
|
||||
)
|
||||
self.api = AmazonEchoApi(
|
||||
session,
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from functools import partial
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
@@ -283,7 +284,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
vol.Optional(
|
||||
CONF_CHAT_MODEL,
|
||||
default=RECOMMENDED_CHAT_MODEL,
|
||||
): str,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=await self._get_model_list(), custom_value=True
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_MAX_TOKENS,
|
||||
default=RECOMMENDED_MAX_TOKENS,
|
||||
@@ -394,6 +399,39 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
async def _get_model_list(self) -> list[SelectOptionDict]:
|
||||
"""Get list of available models."""
|
||||
try:
|
||||
client = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
anthropic.AsyncAnthropic,
|
||||
api_key=self._get_entry().data[CONF_API_KEY],
|
||||
)
|
||||
)
|
||||
models = (await client.models.list()).data
|
||||
except anthropic.AnthropicError:
|
||||
models = []
|
||||
_LOGGER.debug("Available models: %s", models)
|
||||
model_options: list[SelectOptionDict] = []
|
||||
short_form = re.compile(r"[^\d]-\d$")
|
||||
for model_info in models:
|
||||
# Resolve alias from versioned model name:
|
||||
model_alias = (
|
||||
model_info.id[:-9]
|
||||
if model_info.id
|
||||
not in ("claude-3-haiku-20240307", "claude-3-opus-20240229")
|
||||
else model_info.id
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
value=model_alias,
|
||||
)
|
||||
)
|
||||
return model_options
|
||||
|
||||
async def _get_location_data(self) -> dict[str, str]:
|
||||
"""Get approximate location data of the user."""
|
||||
location_data: dict[str, str] = {}
|
||||
|
||||
@@ -392,7 +392,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
type="tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input="",
|
||||
input={},
|
||||
)
|
||||
current_tool_args = ""
|
||||
if response.content_block.name == output_tool:
|
||||
@@ -459,7 +459,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
type="server_tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input="",
|
||||
input={},
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(response.content_block, WebSearchToolResultBlock):
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["anthropic==0.69.0"]
|
||||
"requirements": ["anthropic==0.73.0"]
|
||||
}
|
||||
|
||||
@@ -111,8 +111,6 @@ def handle_errors_and_zip[_AsusWrtBridgeT: AsusWrtBridge](
|
||||
|
||||
if isinstance(data, dict):
|
||||
return dict(zip(keys, list(data.values()), strict=False))
|
||||
if not isinstance(data, (list, tuple)):
|
||||
raise UpdateFailed("Received invalid data type")
|
||||
return dict(zip(keys, data, strict=False))
|
||||
|
||||
return _wrapper
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==2.45.0",
|
||||
"dbus-fast==3.0.0",
|
||||
"habluetooth==5.7.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -74,8 +74,11 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
|
||||
super().__init__(data.fast_coordinator, data)
|
||||
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
|
||||
|
||||
self._attr_min_temp = data.static.min_temp.value
|
||||
self._attr_max_temp = data.static.max_temp.value
|
||||
# Set temperature range if available, otherwise use Home Assistant defaults
|
||||
if data.static.min_temp is not None and data.static.min_temp.value is not None:
|
||||
self._attr_min_temp = data.static.min_temp.value
|
||||
if data.static.max_temp is not None and data.static.max_temp.value is not None:
|
||||
self._attr_max_temp = data.static.max_temp.value
|
||||
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
|
||||
|
||||
@property
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"requirements": ["python-bsblan==3.1.0"],
|
||||
"requirements": ["python-bsblan==3.1.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -55,6 +55,7 @@ from .const import (
|
||||
CONF_ALIASES,
|
||||
CONF_API_SERVER,
|
||||
CONF_COGNITO_CLIENT_ID,
|
||||
CONF_DISCOVERY_SERVICE_ACTIONS,
|
||||
CONF_ENTITY_CONFIG,
|
||||
CONF_FILTER,
|
||||
CONF_GOOGLE_ACTIONS,
|
||||
@@ -139,6 +140,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_MODE): vol.In([MODE_DEV]),
|
||||
vol.Required(CONF_API_SERVER): str,
|
||||
vol.Optional(CONF_DISCOVERY_SERVICE_ACTIONS): {str: cv.url},
|
||||
}
|
||||
),
|
||||
_BASE_CONFIG_SCHEMA.extend(
|
||||
|
||||
@@ -79,6 +79,7 @@ CONF_ACCOUNT_LINK_SERVER = "account_link_server"
|
||||
CONF_ACCOUNTS_SERVER = "accounts_server"
|
||||
CONF_ACME_SERVER = "acme_server"
|
||||
CONF_API_SERVER = "api_server"
|
||||
CONF_DISCOVERY_SERVICE_ACTIONS = "discovery_service_actions"
|
||||
CONF_RELAYER_SERVER = "relayer_server"
|
||||
CONF_REMOTESTATE_SERVER = "remotestate_server"
|
||||
CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["async_upnp_client"],
|
||||
"requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"],
|
||||
"requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dependencies": ["ssdp"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["async-upnp-client==0.45.0"],
|
||||
"requirements": ["async-upnp-client==0.46.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
||||
|
||||
84
homeassistant/components/emoncms/quality_scale.yaml
Normal file
84
homeassistant/components/emoncms/quality_scale.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
rules:
|
||||
# todo : add get_feed_list to the library
|
||||
# todo : see if we can drop some extra attributes
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
test_reconfigure_api_error should use a mock config entry fixture
|
||||
test_user_flow_failure should use a mock config entry fixture
|
||||
move test_user_flow_* to the top of the file
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No events are explicitly registered by the integration.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
test the entry state in test_failure
|
||||
|
||||
# Gold
|
||||
devices: todo
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: done
|
||||
docs-examples:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide any automation
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: change device_class=SensorDeviceClass.SIGNAL_STRENGTH to SOUND_PRESSURE
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -386,11 +386,260 @@ def _async_validate_auto_generated_cost_entity(
|
||||
issues.add_issue(hass, "recorder_untracked", cost_entity_id)
|
||||
|
||||
|
||||
def _validate_grid_source(
|
||||
hass: HomeAssistant,
|
||||
source: data.GridSourceType,
|
||||
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
wanted_statistics_metadata: set[str],
|
||||
source_result: ValidationIssues,
|
||||
validate_calls: list[functools.partial[None]],
|
||||
) -> None:
|
||||
"""Validate grid energy source."""
|
||||
flow_from: data.FlowFromGridSourceType
|
||||
for flow_from in source["flow_from"]:
|
||||
wanted_statistics_metadata.add(flow_from["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
flow_from["stat_energy_from"],
|
||||
ENERGY_USAGE_DEVICE_CLASSES,
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
if (stat_cost := flow_from.get("stat_cost")) is not None:
|
||||
wanted_statistics_metadata.add(stat_cost)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_cost,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (entity_energy_price := flow_from.get("entity_energy_price")) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
ENERGY_PRICE_UNITS,
|
||||
ENERGY_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
flow_from.get("entity_energy_price") is not None
|
||||
or flow_from.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
flow_from["stat_energy_from"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
flow_to: data.FlowToGridSourceType
|
||||
for flow_to in source["flow_to"]:
|
||||
wanted_statistics_metadata.add(flow_to["stat_energy_to"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
flow_to["stat_energy_to"],
|
||||
ENERGY_USAGE_DEVICE_CLASSES,
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
if (stat_compensation := flow_to.get("stat_compensation")) is not None:
|
||||
wanted_statistics_metadata.add(stat_compensation)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_compensation,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (entity_energy_price := flow_to.get("entity_energy_price")) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
ENERGY_PRICE_UNITS,
|
||||
ENERGY_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
flow_to.get("entity_energy_price") is not None
|
||||
or flow_to.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
flow_to["stat_energy_to"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
for power_stat in source.get("power", []):
|
||||
wanted_statistics_metadata.add(power_stat["stat_rate"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_power_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
power_stat["stat_rate"],
|
||||
POWER_USAGE_DEVICE_CLASSES,
|
||||
POWER_USAGE_UNITS,
|
||||
POWER_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _validate_gas_source(
|
||||
hass: HomeAssistant,
|
||||
source: data.GasSourceType,
|
||||
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
wanted_statistics_metadata: set[str],
|
||||
source_result: ValidationIssues,
|
||||
validate_calls: list[functools.partial[None]],
|
||||
) -> None:
|
||||
"""Validate gas energy source."""
|
||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
source["stat_energy_from"],
|
||||
GAS_USAGE_DEVICE_CLASSES,
|
||||
GAS_USAGE_UNITS,
|
||||
GAS_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
if (stat_cost := source.get("stat_cost")) is not None:
|
||||
wanted_statistics_metadata.add(stat_cost)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_cost,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
GAS_PRICE_UNITS,
|
||||
GAS_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
source.get("entity_energy_price") is not None
|
||||
or source.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
source["stat_energy_from"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _validate_water_source(
|
||||
hass: HomeAssistant,
|
||||
source: data.WaterSourceType,
|
||||
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
wanted_statistics_metadata: set[str],
|
||||
source_result: ValidationIssues,
|
||||
validate_calls: list[functools.partial[None]],
|
||||
) -> None:
|
||||
"""Validate water energy source."""
|
||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
source["stat_energy_from"],
|
||||
WATER_USAGE_DEVICE_CLASSES,
|
||||
WATER_USAGE_UNITS,
|
||||
WATER_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
if (stat_cost := source.get("stat_cost")) is not None:
|
||||
wanted_statistics_metadata.add(stat_cost)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_cost,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
WATER_PRICE_UNITS,
|
||||
WATER_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
source.get("entity_energy_price") is not None
|
||||
or source.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
source["stat_energy_from"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
"""Validate the energy configuration."""
|
||||
manager: data.EnergyManager = await data.async_get_manager(hass)
|
||||
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]] = {}
|
||||
validate_calls = []
|
||||
validate_calls: list[functools.partial[None]] = []
|
||||
wanted_statistics_metadata: set[str] = set()
|
||||
|
||||
result = EnergyPreferencesValidation()
|
||||
@@ -404,230 +653,35 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
result.energy_sources.append(source_result)
|
||||
|
||||
if source["type"] == "grid":
|
||||
flow: data.FlowFromGridSourceType | data.FlowToGridSourceType
|
||||
for flow in source["flow_from"]:
|
||||
wanted_statistics_metadata.add(flow["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
flow["stat_energy_from"],
|
||||
ENERGY_USAGE_DEVICE_CLASSES,
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
if (stat_cost := flow.get("stat_cost")) is not None:
|
||||
wanted_statistics_metadata.add(stat_cost)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_cost,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (
|
||||
entity_energy_price := flow.get("entity_energy_price")
|
||||
) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
ENERGY_PRICE_UNITS,
|
||||
ENERGY_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
flow.get("entity_energy_price") is not None
|
||||
or flow.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
flow["stat_energy_from"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
for flow in source["flow_to"]:
|
||||
wanted_statistics_metadata.add(flow["stat_energy_to"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
flow["stat_energy_to"],
|
||||
ENERGY_USAGE_DEVICE_CLASSES,
|
||||
ENERGY_USAGE_UNITS,
|
||||
ENERGY_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
if (stat_compensation := flow.get("stat_compensation")) is not None:
|
||||
wanted_statistics_metadata.add(stat_compensation)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_compensation,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (
|
||||
entity_energy_price := flow.get("entity_energy_price")
|
||||
) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
ENERGY_PRICE_UNITS,
|
||||
ENERGY_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
flow.get("entity_energy_price") is not None
|
||||
or flow.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
flow["stat_energy_to"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
for power_stat in source.get("power", []):
|
||||
wanted_statistics_metadata.add(power_stat["stat_rate"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_power_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
power_stat["stat_rate"],
|
||||
POWER_USAGE_DEVICE_CLASSES,
|
||||
POWER_USAGE_UNITS,
|
||||
POWER_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
_validate_grid_source(
|
||||
hass,
|
||||
source,
|
||||
statistics_metadata,
|
||||
wanted_statistics_metadata,
|
||||
source_result,
|
||||
validate_calls,
|
||||
)
|
||||
|
||||
elif source["type"] == "gas":
|
||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
source["stat_energy_from"],
|
||||
GAS_USAGE_DEVICE_CLASSES,
|
||||
GAS_USAGE_UNITS,
|
||||
GAS_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
_validate_gas_source(
|
||||
hass,
|
||||
source,
|
||||
statistics_metadata,
|
||||
wanted_statistics_metadata,
|
||||
source_result,
|
||||
validate_calls,
|
||||
)
|
||||
|
||||
if (stat_cost := source.get("stat_cost")) is not None:
|
||||
wanted_statistics_metadata.add(stat_cost)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_cost,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
GAS_PRICE_UNITS,
|
||||
GAS_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
source.get("entity_energy_price") is not None
|
||||
or source.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
source["stat_energy_from"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
elif source["type"] == "water":
|
||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_usage_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
source["stat_energy_from"],
|
||||
WATER_USAGE_DEVICE_CLASSES,
|
||||
WATER_USAGE_UNITS,
|
||||
WATER_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
_validate_water_source(
|
||||
hass,
|
||||
source,
|
||||
statistics_metadata,
|
||||
wanted_statistics_metadata,
|
||||
source_result,
|
||||
validate_calls,
|
||||
)
|
||||
|
||||
if (stat_cost := source.get("stat_cost")) is not None:
|
||||
wanted_statistics_metadata.add(stat_cost)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_cost_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_cost,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_price_entity,
|
||||
hass,
|
||||
entity_energy_price,
|
||||
source_result,
|
||||
WATER_PRICE_UNITS,
|
||||
WATER_PRICE_UNIT_ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
source.get("entity_energy_price") is not None
|
||||
or source.get("number_energy_price") is not None
|
||||
):
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_auto_generated_cost_entity,
|
||||
hass,
|
||||
source["stat_energy_from"],
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
elif source["type"] == "solar":
|
||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
|
||||
@@ -147,6 +147,8 @@ async def async_get_config_entry_diagnostics(
|
||||
"ctmeter_production_phases": envoy_data.ctmeter_production_phases,
|
||||
"ctmeter_consumption_phases": envoy_data.ctmeter_consumption_phases,
|
||||
"ctmeter_storage_phases": envoy_data.ctmeter_storage_phases,
|
||||
"ctmeters": envoy_data.ctmeters,
|
||||
"ctmeters_phases": envoy_data.ctmeters_phases,
|
||||
"dry_contact_status": envoy_data.dry_contact_status,
|
||||
"dry_contact_settings": envoy_data.dry_contact_settings,
|
||||
"inverters": envoy_data.inverters,
|
||||
@@ -179,6 +181,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"ct_consumption_meter": envoy.consumption_meter_type,
|
||||
"ct_production_meter": envoy.production_meter_type,
|
||||
"ct_storage_meter": envoy.storage_meter_type,
|
||||
"ct_meters": list(envoy_data.ctmeters.keys()),
|
||||
}
|
||||
|
||||
fixture_data: dict[str, Any] = {}
|
||||
|
||||
@@ -399,330 +399,189 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
|
||||
cttype: str | None = None
|
||||
|
||||
|
||||
CT_NET_CONSUMPTION_SENSORS = (
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="lifetime_net_consumption",
|
||||
translation_key="lifetime_net_consumption",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_delivered"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="lifetime_net_production",
|
||||
translation_key="lifetime_net_production",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_received"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_consumption",
|
||||
translation_key="net_consumption",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("active_power"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="frequency",
|
||||
translation_key="net_ct_frequency",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("frequency"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="voltage",
|
||||
translation_key="net_ct_voltage",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("voltage"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_ct_current",
|
||||
translation_key="net_ct_current",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
suggested_display_precision=3,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("current"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_ct_powerfactor",
|
||||
translation_key="net_ct_powerfactor",
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("power_factor"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_consumption_ct_metering_status",
|
||||
translation_key="net_ct_metering_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
options=list(CtMeterStatus),
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("metering_status"),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="net_consumption_ct_status_flags",
|
||||
translation_key="net_ct_status_flags",
|
||||
state_class=None,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||
on_phase=None,
|
||||
cttype=CtType.NET_CONSUMPTION,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
CT_NET_CONSUMPTION_PHASE_SENSORS = {
|
||||
(on_phase := PHASENAMES[phase]): [
|
||||
replace(
|
||||
sensor,
|
||||
key=f"{sensor.key}_l{phase + 1}",
|
||||
translation_key=f"{sensor.translation_key}_phase",
|
||||
entity_registry_enabled_default=False,
|
||||
on_phase=on_phase,
|
||||
translation_placeholders={"phase_name": f"l{phase + 1}"},
|
||||
# All ct types unified in common setup
|
||||
CT_SENSORS = (
|
||||
[
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=key,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_delivered"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
(CtType.NET_CONSUMPTION, "lifetime_net_consumption"),
|
||||
# Production CT energy_delivered is not used
|
||||
(CtType.STORAGE, "lifetime_battery_discharged"),
|
||||
)
|
||||
for sensor in list(CT_NET_CONSUMPTION_SENSORS)
|
||||
]
|
||||
for phase in range(3)
|
||||
}
|
||||
|
||||
CT_PRODUCTION_SENSORS = (
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_frequency",
|
||||
translation_key="production_ct_frequency",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("frequency"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_voltage",
|
||||
translation_key="production_ct_voltage",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("voltage"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_current",
|
||||
translation_key="production_ct_current",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
suggested_display_precision=3,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("current"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_powerfactor",
|
||||
translation_key="production_ct_powerfactor",
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("power_factor"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_metering_status",
|
||||
translation_key="production_ct_metering_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(CtMeterStatus),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("metering_status"),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="production_ct_status_flags",
|
||||
translation_key="production_ct_status_flags",
|
||||
state_class=None,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||
on_phase=None,
|
||||
cttype=CtType.PRODUCTION,
|
||||
),
|
||||
)
|
||||
|
||||
CT_PRODUCTION_PHASE_SENSORS = {
|
||||
(on_phase := PHASENAMES[phase]): [
|
||||
replace(
|
||||
sensor,
|
||||
key=f"{sensor.key}_l{phase + 1}",
|
||||
translation_key=f"{sensor.translation_key}_phase",
|
||||
entity_registry_enabled_default=False,
|
||||
on_phase=on_phase,
|
||||
translation_placeholders={"phase_name": f"l{phase + 1}"},
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=key,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_received"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
(CtType.NET_CONSUMPTION, "lifetime_net_production"),
|
||||
# Production CT energy_received is not used
|
||||
(CtType.STORAGE, "lifetime_battery_charged"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=key,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("active_power"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
(CtType.NET_CONSUMPTION, "net_consumption"),
|
||||
# Production CT active_power is not used
|
||||
(CtType.STORAGE, "battery_discharge"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=(translation_key if translation_key != "" else key),
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("frequency"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
(CtType.NET_CONSUMPTION, "frequency", "net_ct_frequency"),
|
||||
(CtType.PRODUCTION, "production_ct_frequency", ""),
|
||||
(CtType.STORAGE, "storage_ct_frequency", ""),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=(translation_key if translation_key != "" else key),
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("voltage"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
(CtType.NET_CONSUMPTION, "voltage", "net_ct_voltage"),
|
||||
(CtType.PRODUCTION, "production_ct_voltage", ""),
|
||||
(CtType.STORAGE, "storage_voltage", "storage_ct_voltage"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=key,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
suggested_display_precision=3,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("current"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
(CtType.NET_CONSUMPTION, "net_ct_current"),
|
||||
(CtType.PRODUCTION, "production_ct_current"),
|
||||
(CtType.STORAGE, "storage_ct_current"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=key,
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("power_factor"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
(CtType.NET_CONSUMPTION, "net_ct_powerfactor"),
|
||||
(CtType.PRODUCTION, "production_ct_powerfactor"),
|
||||
(CtType.STORAGE, "storage_ct_powerfactor"),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=(translation_key if translation_key != "" else key),
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
options=list(CtMeterStatus),
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("metering_status"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
(
|
||||
CtType.NET_CONSUMPTION,
|
||||
"net_consumption_ct_metering_status",
|
||||
"net_ct_metering_status",
|
||||
),
|
||||
(CtType.PRODUCTION, "production_ct_metering_status", ""),
|
||||
(CtType.STORAGE, "storage_ct_metering_status", ""),
|
||||
)
|
||||
]
|
||||
+ [
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=(translation_key if translation_key != "" else key),
|
||||
state_class=None,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
(
|
||||
CtType.NET_CONSUMPTION,
|
||||
"net_consumption_ct_status_flags",
|
||||
"net_ct_status_flags",
|
||||
),
|
||||
(CtType.PRODUCTION, "production_ct_status_flags", ""),
|
||||
(CtType.STORAGE, "storage_ct_status_flags", ""),
|
||||
)
|
||||
for sensor in list(CT_PRODUCTION_SENSORS)
|
||||
]
|
||||
for phase in range(3)
|
||||
}
|
||||
|
||||
CT_STORAGE_SENSORS = (
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="lifetime_battery_discharged",
|
||||
translation_key="lifetime_battery_discharged",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_delivered"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="lifetime_battery_charged",
|
||||
translation_key="lifetime_battery_charged",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_received"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="battery_discharge",
|
||||
translation_key="battery_discharge",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("active_power"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_frequency",
|
||||
translation_key="storage_ct_frequency",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("frequency"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_voltage",
|
||||
translation_key="storage_ct_voltage",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("voltage"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_current",
|
||||
translation_key="storage_ct_current",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
suggested_display_precision=3,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("current"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_powerfactor",
|
||||
translation_key="storage_ct_powerfactor",
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("power_factor"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_metering_status",
|
||||
translation_key="storage_ct_metering_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(CtMeterStatus),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("metering_status"),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
EnvoyCTSensorEntityDescription(
|
||||
key="storage_ct_status_flags",
|
||||
translation_key="storage_ct_status_flags",
|
||||
state_class=None,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||
on_phase=None,
|
||||
cttype=CtType.STORAGE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
CT_STORAGE_PHASE_SENSORS = {
|
||||
CT_PHASE_SENSORS = {
|
||||
(on_phase := PHASENAMES[phase]): [
|
||||
replace(
|
||||
sensor,
|
||||
@@ -732,7 +591,7 @@ CT_STORAGE_PHASE_SENSORS = {
|
||||
on_phase=on_phase,
|
||||
translation_placeholders={"phase_name": f"l{phase + 1}"},
|
||||
)
|
||||
for sensor in list(CT_STORAGE_SENSORS)
|
||||
for sensor in list(CT_SENSORS)
|
||||
]
|
||||
for phase in range(3)
|
||||
}
|
||||
@@ -1060,24 +919,14 @@ async def async_setup_entry(
|
||||
if envoy_data.ctmeters:
|
||||
entities.extend(
|
||||
EnvoyCTEntity(coordinator, description)
|
||||
for sensors in (
|
||||
CT_NET_CONSUMPTION_SENSORS,
|
||||
CT_PRODUCTION_SENSORS,
|
||||
CT_STORAGE_SENSORS,
|
||||
)
|
||||
for description in sensors
|
||||
for description in CT_SENSORS
|
||||
if description.cttype in envoy_data.ctmeters
|
||||
)
|
||||
# Add Current Transformer phase entities
|
||||
if ctmeters_phases := envoy_data.ctmeters_phases:
|
||||
entities.extend(
|
||||
EnvoyCTPhaseEntity(coordinator, description)
|
||||
for sensors in (
|
||||
CT_NET_CONSUMPTION_PHASE_SENSORS,
|
||||
CT_PRODUCTION_PHASE_SENSORS,
|
||||
CT_STORAGE_PHASE_SENSORS,
|
||||
)
|
||||
for phase, descriptions in sensors.items()
|
||||
for phase, descriptions in CT_PHASE_SENSORS.items()
|
||||
for description in descriptions
|
||||
if (cttype := description.cttype) in ctmeters_phases
|
||||
and phase in ctmeters_phases[cttype]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from urllib.parse import quote
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -152,7 +153,9 @@ class HassFoscamCamera(FoscamEntity, Camera):
|
||||
async def stream_source(self) -> str | None:
|
||||
"""Return the stream source."""
|
||||
if self._rtsp_port:
|
||||
return f"rtsp://{self._username}:{self._password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}"
|
||||
_username = quote(self._username)
|
||||
_password = quote(self._password)
|
||||
return f"rtsp://{_username}:{_password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -777,7 +777,9 @@ class ManifestJSONView(HomeAssistantView):
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "frontend/get_icons",
|
||||
vol.Required("category"): vol.In({"entity", "entity_component", "services"}),
|
||||
vol.Required("category"): vol.In(
|
||||
{"entity", "entity_component", "services", "triggers", "conditions"}
|
||||
),
|
||||
vol.Optional("integration"): vol.All(cv.ensure_list, [str]),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""The Goodwe inverter component."""
|
||||
|
||||
from goodwe import InverterError, connect
|
||||
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
|
||||
from goodwe import Inverter, InverterError, connect
|
||||
from goodwe.const import GOODWE_UDP_PORT
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
|
||||
from .config_flow import GoodweFlowHandler
|
||||
from .const import CONF_MODEL_FAMILY, DOMAIN, PLATFORMS
|
||||
from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoordinator
|
||||
|
||||
@@ -15,28 +16,22 @@ from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoord
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bool:
|
||||
"""Set up the Goodwe components from a config entry."""
|
||||
host = entry.data[CONF_HOST]
|
||||
port = entry.data.get(CONF_PORT, GOODWE_UDP_PORT)
|
||||
model_family = entry.data[CONF_MODEL_FAMILY]
|
||||
|
||||
# Connect to Goodwe inverter
|
||||
try:
|
||||
inverter = await connect(
|
||||
host=host,
|
||||
port=GOODWE_UDP_PORT,
|
||||
port=port,
|
||||
family=model_family,
|
||||
retries=10,
|
||||
)
|
||||
except InverterError as err_udp:
|
||||
# First try with UDP failed, trying with the TCP port
|
||||
except InverterError as err:
|
||||
try:
|
||||
inverter = await connect(
|
||||
host=host,
|
||||
port=GOODWE_TCP_PORT,
|
||||
family=model_family,
|
||||
retries=10,
|
||||
)
|
||||
inverter = await async_check_port(hass, entry, host)
|
||||
except InverterError:
|
||||
# Both ports are unavailable
|
||||
raise ConfigEntryNotReady from err_udp
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
device_info = DeviceInfo(
|
||||
configuration_url="https://www.semsportal.com",
|
||||
@@ -66,6 +61,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo
|
||||
return True
|
||||
|
||||
|
||||
async def async_check_port(
|
||||
hass: HomeAssistant, entry: GoodweConfigEntry, host: str
|
||||
) -> Inverter:
|
||||
"""Check the communication port of the inverter, it may have changed after a firmware update."""
|
||||
inverter, port = await GoodweFlowHandler.async_detect_inverter_port(host=host)
|
||||
family = type(inverter).__name__
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
CONF_PORT: port,
|
||||
CONF_MODEL_FAMILY: family,
|
||||
},
|
||||
)
|
||||
return inverter
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: GoodweConfigEntry
|
||||
) -> bool:
|
||||
@@ -76,3 +88,31 @@ async def async_unload_entry(
|
||||
async def update_listener(hass: HomeAssistant, config_entry: GoodweConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, config_entry: GoodweConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old config entries."""
|
||||
|
||||
if config_entry.version > 2:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if config_entry.version == 1:
|
||||
# Update from version 1 to version 2 adding the PROTOCOL to the config entry
|
||||
host = config_entry.data[CONF_HOST]
|
||||
try:
|
||||
inverter, port = await GoodweFlowHandler.async_detect_inverter_port(
|
||||
host=host
|
||||
)
|
||||
except InverterError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
new_data = {
|
||||
CONF_HOST: host,
|
||||
CONF_PORT: port,
|
||||
CONF_MODEL_FAMILY: type(inverter).__name__,
|
||||
}
|
||||
hass.config_entries.async_update_entry(config_entry, data=new_data, version=2)
|
||||
|
||||
return True
|
||||
|
||||
@@ -5,12 +5,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from goodwe import InverterError, connect
|
||||
from goodwe import Inverter, InverterError, connect
|
||||
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
|
||||
from .const import CONF_MODEL_FAMILY, DEFAULT_NAME, DOMAIN
|
||||
|
||||
@@ -26,9 +26,15 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Goodwe config flow."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def _handle_successful_connection(self, inverter, host):
|
||||
async def async_handle_successful_connection(
|
||||
self,
|
||||
inverter: Inverter,
|
||||
host: str,
|
||||
port: int,
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a successful connection storing it's values on the entry data."""
|
||||
await self.async_set_unique_id(inverter.serial_number)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
@@ -36,6 +42,7 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
title=DEFAULT_NAME,
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
CONF_PORT: port,
|
||||
CONF_MODEL_FAMILY: type(inverter).__name__,
|
||||
},
|
||||
)
|
||||
@@ -48,19 +55,26 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
host = user_input[CONF_HOST]
|
||||
try:
|
||||
inverter = await connect(host=host, port=GOODWE_UDP_PORT, retries=10)
|
||||
inverter, port = await self.async_detect_inverter_port(host=host)
|
||||
except InverterError:
|
||||
try:
|
||||
inverter = await connect(
|
||||
host=host, port=GOODWE_TCP_PORT, retries=10
|
||||
)
|
||||
except InverterError:
|
||||
errors[CONF_HOST] = "connection_error"
|
||||
else:
|
||||
return await self._handle_successful_connection(inverter, host)
|
||||
errors[CONF_HOST] = "connection_error"
|
||||
else:
|
||||
return await self._handle_successful_connection(inverter, host)
|
||||
|
||||
return await self.async_handle_successful_connection(
|
||||
inverter, host, port
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def async_detect_inverter_port(
|
||||
host: str,
|
||||
) -> tuple[Inverter, int]:
|
||||
"""Detects the port of the Inverter."""
|
||||
port = GOODWE_UDP_PORT
|
||||
try:
|
||||
inverter = await connect(host=host, port=port, retries=10)
|
||||
except InverterError:
|
||||
port = GOODWE_TCP_PORT
|
||||
inverter = await connect(host=host, port=port, retries=10)
|
||||
return inverter, port
|
||||
|
||||
@@ -53,6 +53,9 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
|
||||
# Due dates are returned always in UTC so we only need to
|
||||
# parse the date portion which will be interpreted as a a local date.
|
||||
due = datetime.fromisoformat(due_str).date()
|
||||
completed: datetime | None = None
|
||||
if (completed_str := item.get("completed")) is not None:
|
||||
completed = datetime.fromisoformat(completed_str)
|
||||
return TodoItem(
|
||||
summary=item["title"],
|
||||
uid=item["id"],
|
||||
@@ -61,6 +64,7 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
|
||||
TodoItemStatus.NEEDS_ACTION,
|
||||
),
|
||||
due=due,
|
||||
completed=completed,
|
||||
description=item.get("notes"),
|
||||
)
|
||||
|
||||
|
||||
84
homeassistant/components/google_weather/__init__.py
Normal file
84
homeassistant/components/google_weather/__init__.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""The Google Weather integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from google_weather_api import GoogleWeatherApi
|
||||
|
||||
from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_REFERRER
|
||||
from .coordinator import (
|
||||
GoogleWeatherConfigEntry,
|
||||
GoogleWeatherCurrentConditionsCoordinator,
|
||||
GoogleWeatherDailyForecastCoordinator,
|
||||
GoogleWeatherHourlyForecastCoordinator,
|
||||
GoogleWeatherRuntimeData,
|
||||
GoogleWeatherSubEntryRuntimeData,
|
||||
)
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.WEATHER]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Google Weather from a config entry."""
|
||||
|
||||
api = GoogleWeatherApi(
|
||||
session=async_get_clientsession(hass),
|
||||
api_key=entry.data[CONF_API_KEY],
|
||||
referrer=entry.data.get(CONF_REFERRER),
|
||||
language_code=hass.config.language,
|
||||
)
|
||||
subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData] = {}
|
||||
for subentry in entry.subentries.values():
|
||||
subentry_runtime_data = GoogleWeatherSubEntryRuntimeData(
|
||||
coordinator_observation=GoogleWeatherCurrentConditionsCoordinator(
|
||||
hass, entry, subentry, api
|
||||
),
|
||||
coordinator_daily_forecast=GoogleWeatherDailyForecastCoordinator(
|
||||
hass, entry, subentry, api
|
||||
),
|
||||
coordinator_hourly_forecast=GoogleWeatherHourlyForecastCoordinator(
|
||||
hass, entry, subentry, api
|
||||
),
|
||||
)
|
||||
subentries_runtime_data[subentry.subentry_id] = subentry_runtime_data
|
||||
tasks = [
|
||||
coro
|
||||
for subentry_runtime_data in subentries_runtime_data.values()
|
||||
for coro in (
|
||||
subentry_runtime_data.coordinator_observation.async_config_entry_first_refresh(),
|
||||
subentry_runtime_data.coordinator_daily_forecast.async_config_entry_first_refresh(),
|
||||
subentry_runtime_data.coordinator_hourly_forecast.async_config_entry_first_refresh(),
|
||||
)
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
entry.runtime_data = GoogleWeatherRuntimeData(
|
||||
api=api,
|
||||
subentries_runtime_data=subentries_runtime_data,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
||||
|
||||
|
||||
async def async_update_options(
|
||||
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
|
||||
) -> None:
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
198
homeassistant/components/google_weather/config_flow.py
Normal file
198
homeassistant/components/google_weather/config_flow.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Config flow for the Google Weather integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from google_weather_api import GoogleWeatherApi, GoogleWeatherApiError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LATITUDE,
|
||||
CONF_LOCATION,
|
||||
CONF_LONGITUDE,
|
||||
CONF_NAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import LocationSelector, LocationSelectorConfig
|
||||
|
||||
from .const import CONF_REFERRER, DOMAIN, SECTION_API_KEY_OPTIONS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Optional(SECTION_API_KEY_OPTIONS): section(
|
||||
vol.Schema({vol.Optional(CONF_REFERRER): str}), {"collapsed": True}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _validate_input(
|
||||
user_input: dict[str, Any],
|
||||
api: GoogleWeatherApi,
|
||||
errors: dict[str, str],
|
||||
description_placeholders: dict[str, str],
|
||||
) -> bool:
|
||||
try:
|
||||
await api.async_get_current_conditions(
|
||||
latitude=user_input[CONF_LOCATION][CONF_LATITUDE],
|
||||
longitude=user_input[CONF_LOCATION][CONF_LONGITUDE],
|
||||
)
|
||||
except GoogleWeatherApiError as err:
|
||||
errors["base"] = "cannot_connect"
|
||||
description_placeholders["error_message"] = str(err)
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_location_schema(hass: HomeAssistant) -> vol.Schema:
|
||||
"""Return the schema for a location with default values from the hass config."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME, default=hass.config.location_name): str,
|
||||
vol.Required(
|
||||
CONF_LOCATION,
|
||||
default={
|
||||
CONF_LATITUDE: hass.config.latitude,
|
||||
CONF_LONGITUDE: hass.config.longitude,
|
||||
},
|
||||
): LocationSelector(LocationSelectorConfig(radius=False)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_location_already_configured(
|
||||
hass: HomeAssistant, new_data: dict[str, float], epsilon: float = 1e-4
|
||||
) -> bool:
|
||||
"""Check if the location is already configured."""
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
for subentry in entry.subentries.values():
|
||||
# A more accurate way is to use the haversine formula, but for simplicity
|
||||
# we use a simple distance check. The epsilon value is small anyway.
|
||||
# This is mostly to capture cases where the user has slightly moved the location pin.
|
||||
if (
|
||||
abs(subentry.data[CONF_LATITUDE] - new_data[CONF_LATITUDE]) <= epsilon
|
||||
and abs(subentry.data[CONF_LONGITUDE] - new_data[CONF_LONGITUDE])
|
||||
<= epsilon
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Google Weather."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {
|
||||
"api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key",
|
||||
"restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys",
|
||||
}
|
||||
if user_input is not None:
|
||||
api_key = user_input[CONF_API_KEY]
|
||||
referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER)
|
||||
self._async_abort_entries_match({CONF_API_KEY: api_key})
|
||||
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
|
||||
return self.async_abort(reason="already_configured")
|
||||
api = GoogleWeatherApi(
|
||||
session=async_get_clientsession(self.hass),
|
||||
api_key=api_key,
|
||||
referrer=referrer,
|
||||
language_code=self.hass.config.language,
|
||||
)
|
||||
if await _validate_input(user_input, api, errors, description_placeholders):
|
||||
return self.async_create_entry(
|
||||
title="Google Weather",
|
||||
data={
|
||||
CONF_API_KEY: api_key,
|
||||
CONF_REFERRER: referrer,
|
||||
},
|
||||
subentries=[
|
||||
{
|
||||
"subentry_type": "location",
|
||||
"data": user_input[CONF_LOCATION],
|
||||
"title": user_input[CONF_NAME],
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
else:
|
||||
user_input = {}
|
||||
schema = STEP_USER_DATA_SCHEMA.schema.copy()
|
||||
schema.update(_get_location_schema(self.hass).schema)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(schema), user_input
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {"location": LocationSubentryFlowHandler}
|
||||
|
||||
|
||||
class LocationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle a subentry flow for location."""
|
||||
|
||||
async def async_step_location(
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the location step."""
|
||||
if self._get_entry().state != ConfigEntryState.LOADED:
|
||||
return self.async_abort(reason="entry_not_loaded")
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
|
||||
return self.async_abort(reason="already_configured")
|
||||
api: GoogleWeatherApi = self._get_entry().runtime_data.api
|
||||
if await _validate_input(user_input, api, errors, description_placeholders):
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME],
|
||||
data=user_input[CONF_LOCATION],
|
||||
)
|
||||
else:
|
||||
user_input = {}
|
||||
return self.async_show_form(
|
||||
step_id="location",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
_get_location_schema(self.hass), user_input
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
async_step_user = async_step_location
|
||||
8
homeassistant/components/google_weather/const.py
Normal file
8
homeassistant/components/google_weather/const.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Constants for the Google Weather integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN = "google_weather"
|
||||
|
||||
SECTION_API_KEY_OPTIONS: Final = "api_key_options"
|
||||
CONF_REFERRER: Final = "referrer"
|
||||
169
homeassistant/components/google_weather/coordinator.py
Normal file
169
homeassistant/components/google_weather/coordinator.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""The Google Weather coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TypeVar
|
||||
|
||||
from google_weather_api import (
|
||||
CurrentConditionsResponse,
|
||||
DailyForecastResponse,
|
||||
GoogleWeatherApi,
|
||||
GoogleWeatherApiError,
|
||||
HourlyForecastResponse,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
TimestampDataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar(
|
||||
"T",
|
||||
bound=(
|
||||
CurrentConditionsResponse
|
||||
| DailyForecastResponse
|
||||
| HourlyForecastResponse
|
||||
| None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoogleWeatherSubEntryRuntimeData:
|
||||
"""Runtime data for a Google Weather sub-entry."""
|
||||
|
||||
coordinator_observation: GoogleWeatherCurrentConditionsCoordinator
|
||||
coordinator_daily_forecast: GoogleWeatherDailyForecastCoordinator
|
||||
coordinator_hourly_forecast: GoogleWeatherHourlyForecastCoordinator
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoogleWeatherRuntimeData:
|
||||
"""Runtime data for the Google Weather integration."""
|
||||
|
||||
api: GoogleWeatherApi
|
||||
subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData]
|
||||
|
||||
|
||||
type GoogleWeatherConfigEntry = ConfigEntry[GoogleWeatherRuntimeData]
|
||||
|
||||
|
||||
class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]):
|
||||
"""Base class for Google Weather coordinators."""
|
||||
|
||||
config_entry: GoogleWeatherConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: GoogleWeatherConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
data_type_name: str,
|
||||
update_interval: timedelta,
|
||||
api_method: Callable[..., Awaitable[T]],
|
||||
) -> None:
|
||||
"""Initialize the data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"Google Weather {data_type_name} coordinator for {subentry.title}",
|
||||
update_interval=update_interval,
|
||||
)
|
||||
self.subentry = subentry
|
||||
self._data_type_name = data_type_name
|
||||
self._api_method = api_method
|
||||
|
||||
async def _async_update_data(self) -> T:
|
||||
"""Fetch data from API and handle errors."""
|
||||
try:
|
||||
return await self._api_method(
|
||||
self.subentry.data[CONF_LATITUDE],
|
||||
self.subentry.data[CONF_LONGITUDE],
|
||||
)
|
||||
except GoogleWeatherApiError as err:
|
||||
_LOGGER.error(
|
||||
"Error fetching %s for %s: %s",
|
||||
self._data_type_name,
|
||||
self.subentry.title,
|
||||
err,
|
||||
)
|
||||
raise UpdateFailed(f"Error fetching {self._data_type_name}") from err
|
||||
|
||||
|
||||
class GoogleWeatherCurrentConditionsCoordinator(
|
||||
GoogleWeatherBaseCoordinator[CurrentConditionsResponse]
|
||||
):
|
||||
"""Handle fetching current weather conditions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: GoogleWeatherConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
api: GoogleWeatherApi,
|
||||
) -> None:
|
||||
"""Initialize the data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
config_entry,
|
||||
subentry,
|
||||
"current weather conditions",
|
||||
timedelta(minutes=15),
|
||||
api.async_get_current_conditions,
|
||||
)
|
||||
|
||||
|
||||
class GoogleWeatherDailyForecastCoordinator(
|
||||
GoogleWeatherBaseCoordinator[DailyForecastResponse]
|
||||
):
|
||||
"""Handle fetching daily weather forecast."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: GoogleWeatherConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
api: GoogleWeatherApi,
|
||||
) -> None:
|
||||
"""Initialize the data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
config_entry,
|
||||
subentry,
|
||||
"daily weather forecast",
|
||||
timedelta(hours=1),
|
||||
api.async_get_daily_forecast,
|
||||
)
|
||||
|
||||
|
||||
class GoogleWeatherHourlyForecastCoordinator(
|
||||
GoogleWeatherBaseCoordinator[HourlyForecastResponse]
|
||||
):
|
||||
"""Handle fetching hourly weather forecast."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: GoogleWeatherConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
api: GoogleWeatherApi,
|
||||
) -> None:
|
||||
"""Initialize the data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
config_entry,
|
||||
subentry,
|
||||
"hourly weather forecast",
|
||||
timedelta(hours=1),
|
||||
api.async_get_hourly_forecast,
|
||||
)
|
||||
28
homeassistant/components/google_weather/entity.py
Normal file
28
homeassistant/components/google_weather/entity.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Base entity for Google Weather."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GoogleWeatherConfigEntry
|
||||
|
||||
|
||||
class GoogleWeatherBaseEntity(Entity):
|
||||
"""Base entity for all Google Weather entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, config_entry: GoogleWeatherConfigEntry, subentry: ConfigSubentry
|
||||
) -> None:
|
||||
"""Initialize base entity."""
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Google",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
12
homeassistant/components/google_weather/manifest.json
Normal file
12
homeassistant/components/google_weather/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "google_weather",
|
||||
"name": "Google Weather",
|
||||
"codeowners": ["@tronikos"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_weather",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["google_weather_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-google-weather-api==0.0.4"]
|
||||
}
|
||||
82
homeassistant/components/google_weather/quality_scale.yaml
Normal file
82
homeassistant/components/google_weather/quality_scale.yaml
Normal file
@@ -0,0 +1,82 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: No actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: No actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: No events subscribed.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: No actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: No configuration options.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: No discovery.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: No physical devices.
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: N/A
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repairs.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: N/A
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
65
homeassistant/components/google_weather/strings.json
Normal file
65
homeassistant/components/google_weather/strings.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Unable to connect to the Google Weather API:\n\n{error_message}",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"location": "[%key:common::config_flow::data::location%]",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "A unique alphanumeric string that associates your Google billing account with Google Weather API",
|
||||
"location": "Location coordinates",
|
||||
"name": "Location name"
|
||||
},
|
||||
"description": "Get your API key from [here]({api_key_url}).",
|
||||
"sections": {
|
||||
"api_key_options": {
|
||||
"data": {
|
||||
"referrer": "HTTP referrer"
|
||||
},
|
||||
"data_description": {
|
||||
"referrer": "Specify this only if the API key has a [website application restriction]({restricting_api_keys_url})"
|
||||
},
|
||||
"name": "Optional API key options"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"location": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
|
||||
"entry_not_loaded": "Cannot add things while the configuration is disabled."
|
||||
},
|
||||
"entry_type": "Location",
|
||||
"error": {
|
||||
"cannot_connect": "[%key:component::google_weather::config::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add location"
|
||||
},
|
||||
"step": {
|
||||
"location": {
|
||||
"data": {
|
||||
"location": "[%key:common::config_flow::data::location%]",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"data_description": {
|
||||
"location": "[%key:component::google_weather::config::step::user::data_description::location%]",
|
||||
"name": "[%key:component::google_weather::config::step::user::data_description::name%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
366
homeassistant/components/google_weather/weather.py
Normal file
366
homeassistant/components/google_weather/weather.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""Weather entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from google_weather_api import (
|
||||
DailyForecastResponse,
|
||||
HourlyForecastResponse,
|
||||
WeatherCondition,
|
||||
)
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_CONDITION_CLEAR_NIGHT,
|
||||
ATTR_CONDITION_CLOUDY,
|
||||
ATTR_CONDITION_HAIL,
|
||||
ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
ATTR_CONDITION_PARTLYCLOUDY,
|
||||
ATTR_CONDITION_POURING,
|
||||
ATTR_CONDITION_RAINY,
|
||||
ATTR_CONDITION_SNOWY,
|
||||
ATTR_CONDITION_SNOWY_RAINY,
|
||||
ATTR_CONDITION_SUNNY,
|
||||
ATTR_CONDITION_WINDY,
|
||||
ATTR_FORECAST_CLOUD_COVERAGE,
|
||||
ATTR_FORECAST_CONDITION,
|
||||
ATTR_FORECAST_HUMIDITY,
|
||||
ATTR_FORECAST_IS_DAYTIME,
|
||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP,
|
||||
ATTR_FORECAST_NATIVE_DEW_POINT,
|
||||
ATTR_FORECAST_NATIVE_PRECIPITATION,
|
||||
ATTR_FORECAST_NATIVE_PRESSURE,
|
||||
ATTR_FORECAST_NATIVE_TEMP,
|
||||
ATTR_FORECAST_NATIVE_TEMP_LOW,
|
||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
|
||||
ATTR_FORECAST_NATIVE_WIND_SPEED,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
|
||||
ATTR_FORECAST_TIME,
|
||||
ATTR_FORECAST_UV_INDEX,
|
||||
ATTR_FORECAST_WIND_BEARING,
|
||||
CoordinatorWeatherEntity,
|
||||
Forecast,
|
||||
WeatherEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import (
|
||||
UnitOfLength,
|
||||
UnitOfPrecipitationDepth,
|
||||
UnitOfPressure,
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import (
|
||||
GoogleWeatherConfigEntry,
|
||||
GoogleWeatherCurrentConditionsCoordinator,
|
||||
GoogleWeatherDailyForecastCoordinator,
|
||||
GoogleWeatherHourlyForecastCoordinator,
|
||||
)
|
||||
from .entity import GoogleWeatherBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
# Maps https://developers.google.com/maps/documentation/weather/weather-condition-icons
|
||||
# to https://developers.home-assistant.io/docs/core/entity/weather/#recommended-values-for-state-and-condition
|
||||
_CONDITION_MAP: dict[WeatherCondition.Type, str | None] = {
|
||||
WeatherCondition.Type.TYPE_UNSPECIFIED: None,
|
||||
WeatherCondition.Type.CLEAR: ATTR_CONDITION_SUNNY,
|
||||
WeatherCondition.Type.MOSTLY_CLEAR: ATTR_CONDITION_PARTLYCLOUDY,
|
||||
WeatherCondition.Type.PARTLY_CLOUDY: ATTR_CONDITION_PARTLYCLOUDY,
|
||||
WeatherCondition.Type.MOSTLY_CLOUDY: ATTR_CONDITION_CLOUDY,
|
||||
WeatherCondition.Type.CLOUDY: ATTR_CONDITION_CLOUDY,
|
||||
WeatherCondition.Type.WINDY: ATTR_CONDITION_WINDY,
|
||||
WeatherCondition.Type.WIND_AND_RAIN: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.LIGHT_RAIN_SHOWERS: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.CHANCE_OF_SHOWERS: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.SCATTERED_SHOWERS: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.RAIN_SHOWERS: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.HEAVY_RAIN_SHOWERS: ATTR_CONDITION_POURING,
|
||||
WeatherCondition.Type.LIGHT_TO_MODERATE_RAIN: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.MODERATE_TO_HEAVY_RAIN: ATTR_CONDITION_POURING,
|
||||
WeatherCondition.Type.RAIN: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.LIGHT_RAIN: ATTR_CONDITION_RAINY,
|
||||
WeatherCondition.Type.HEAVY_RAIN: ATTR_CONDITION_POURING,
|
||||
WeatherCondition.Type.RAIN_PERIODICALLY_HEAVY: ATTR_CONDITION_POURING,
|
||||
WeatherCondition.Type.LIGHT_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.CHANCE_OF_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.SCATTERED_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.HEAVY_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.LIGHT_TO_MODERATE_SNOW: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.MODERATE_TO_HEAVY_SNOW: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.SNOW: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.LIGHT_SNOW: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.HEAVY_SNOW: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.SNOWSTORM: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.SNOW_PERIODICALLY_HEAVY: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.HEAVY_SNOW_STORM: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.BLOWING_SNOW: ATTR_CONDITION_SNOWY,
|
||||
WeatherCondition.Type.RAIN_AND_SNOW: ATTR_CONDITION_SNOWY_RAINY,
|
||||
WeatherCondition.Type.HAIL: ATTR_CONDITION_HAIL,
|
||||
WeatherCondition.Type.HAIL_SHOWERS: ATTR_CONDITION_HAIL,
|
||||
WeatherCondition.Type.THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
WeatherCondition.Type.THUNDERSHOWER: ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
WeatherCondition.Type.LIGHT_THUNDERSTORM_RAIN: ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
WeatherCondition.Type.SCATTERED_THUNDERSTORMS: ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
WeatherCondition.Type.HEAVY_THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
}
|
||||
|
||||
|
||||
def _get_condition(
|
||||
api_condition: WeatherCondition.Type, is_daytime: bool
|
||||
) -> str | None:
|
||||
"""Map Google Weather condition to Home Assistant condition."""
|
||||
cond = _CONDITION_MAP[api_condition]
|
||||
if cond == ATTR_CONDITION_SUNNY and not is_daytime:
|
||||
return ATTR_CONDITION_CLEAR_NIGHT
|
||||
return cond
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GoogleWeatherConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add a weather entity from a config_entry."""
|
||||
for subentry in entry.subentries.values():
|
||||
async_add_entities(
|
||||
[GoogleWeatherEntity(entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class GoogleWeatherEntity(
|
||||
CoordinatorWeatherEntity[
|
||||
GoogleWeatherCurrentConditionsCoordinator,
|
||||
GoogleWeatherDailyForecastCoordinator,
|
||||
GoogleWeatherHourlyForecastCoordinator,
|
||||
GoogleWeatherDailyForecastCoordinator,
|
||||
],
|
||||
GoogleWeatherBaseEntity,
|
||||
):
|
||||
"""Representation of a Google Weather entity."""
|
||||
|
||||
_attr_attribution = "Data from Google Weather"
|
||||
|
||||
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_native_pressure_unit = UnitOfPressure.MBAR
|
||||
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
|
||||
_attr_native_visibility_unit = UnitOfLength.KILOMETERS
|
||||
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
|
||||
|
||||
_attr_name = None
|
||||
|
||||
_attr_supported_features = (
|
||||
WeatherEntityFeature.FORECAST_DAILY
|
||||
| WeatherEntityFeature.FORECAST_HOURLY
|
||||
| WeatherEntityFeature.FORECAST_TWICE_DAILY
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: GoogleWeatherConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
) -> None:
|
||||
"""Initialize the weather entity."""
|
||||
subentry_runtime_data = entry.runtime_data.subentries_runtime_data[
|
||||
subentry.subentry_id
|
||||
]
|
||||
super().__init__(
|
||||
observation_coordinator=subentry_runtime_data.coordinator_observation,
|
||||
daily_coordinator=subentry_runtime_data.coordinator_daily_forecast,
|
||||
hourly_coordinator=subentry_runtime_data.coordinator_hourly_forecast,
|
||||
twice_daily_coordinator=subentry_runtime_data.coordinator_daily_forecast,
|
||||
)
|
||||
GoogleWeatherBaseEntity.__init__(self, entry, subentry)
|
||||
|
||||
@property
|
||||
def condition(self) -> str | None:
|
||||
"""Return the current condition."""
|
||||
return _get_condition(
|
||||
self.coordinator.data.weather_condition.type,
|
||||
self.coordinator.data.is_daytime,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_temperature(self) -> float:
|
||||
"""Return the temperature."""
|
||||
return self.coordinator.data.temperature.degrees
|
||||
|
||||
@property
|
||||
def native_apparent_temperature(self) -> float:
|
||||
"""Return the apparent temperature."""
|
||||
return self.coordinator.data.feels_like_temperature.degrees
|
||||
|
||||
@property
|
||||
def native_dew_point(self) -> float:
|
||||
"""Return the dew point."""
|
||||
return self.coordinator.data.dew_point.degrees
|
||||
|
||||
@property
|
||||
def humidity(self) -> int:
|
||||
"""Return the humidity."""
|
||||
return self.coordinator.data.relative_humidity
|
||||
|
||||
@property
|
||||
def uv_index(self) -> float:
|
||||
"""Return the UV index."""
|
||||
return float(self.coordinator.data.uv_index)
|
||||
|
||||
@property
|
||||
def native_pressure(self) -> float:
|
||||
"""Return the pressure."""
|
||||
return self.coordinator.data.air_pressure.mean_sea_level_millibars
|
||||
|
||||
@property
|
||||
def native_wind_gust_speed(self) -> float:
|
||||
"""Return the wind gust speed."""
|
||||
return self.coordinator.data.wind.gust.value
|
||||
|
||||
@property
|
||||
def native_wind_speed(self) -> float:
|
||||
"""Return the wind speed."""
|
||||
return self.coordinator.data.wind.speed.value
|
||||
|
||||
@property
|
||||
def wind_bearing(self) -> int:
|
||||
"""Return the wind bearing."""
|
||||
return self.coordinator.data.wind.direction.degrees
|
||||
|
||||
@property
|
||||
def native_visibility(self) -> float:
|
||||
"""Return the visibility."""
|
||||
return self.coordinator.data.visibility.distance
|
||||
|
||||
@property
|
||||
def cloud_coverage(self) -> float:
|
||||
"""Return the Cloud coverage in %."""
|
||||
return float(self.coordinator.data.cloud_cover)
|
||||
|
||||
@callback
|
||||
def _async_forecast_daily(self) -> list[Forecast] | None:
|
||||
"""Return the daily forecast in native units."""
|
||||
coordinator = self.forecast_coordinators["daily"]
|
||||
assert coordinator
|
||||
daily_data = coordinator.data
|
||||
assert isinstance(daily_data, DailyForecastResponse)
|
||||
return [
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: _get_condition(
|
||||
item.daytime_forecast.weather_condition.type, is_daytime=True
|
||||
),
|
||||
ATTR_FORECAST_TIME: item.interval.start_time,
|
||||
ATTR_FORECAST_HUMIDITY: item.daytime_forecast.relative_humidity,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: max(
|
||||
item.daytime_forecast.precipitation.probability.percent,
|
||||
item.nighttime_forecast.precipitation.probability.percent,
|
||||
),
|
||||
ATTR_FORECAST_CLOUD_COVERAGE: item.daytime_forecast.cloud_cover,
|
||||
ATTR_FORECAST_NATIVE_PRECIPITATION: (
|
||||
item.daytime_forecast.precipitation.qpf.quantity
|
||||
+ item.nighttime_forecast.precipitation.qpf.quantity
|
||||
),
|
||||
ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees,
|
||||
ATTR_FORECAST_NATIVE_TEMP_LOW: item.min_temperature.degrees,
|
||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP: (
|
||||
item.feels_like_max_temperature.degrees
|
||||
),
|
||||
ATTR_FORECAST_WIND_BEARING: item.daytime_forecast.wind.direction.degrees,
|
||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: max(
|
||||
item.daytime_forecast.wind.gust.value,
|
||||
item.nighttime_forecast.wind.gust.value,
|
||||
),
|
||||
ATTR_FORECAST_NATIVE_WIND_SPEED: max(
|
||||
item.daytime_forecast.wind.speed.value,
|
||||
item.nighttime_forecast.wind.speed.value,
|
||||
),
|
||||
ATTR_FORECAST_UV_INDEX: item.daytime_forecast.uv_index,
|
||||
}
|
||||
for item in daily_data.forecast_days
|
||||
]
|
||||
|
||||
@callback
|
||||
def _async_forecast_hourly(self) -> list[Forecast] | None:
|
||||
"""Return the hourly forecast in native units."""
|
||||
coordinator = self.forecast_coordinators["hourly"]
|
||||
assert coordinator
|
||||
hourly_data = coordinator.data
|
||||
assert isinstance(hourly_data, HourlyForecastResponse)
|
||||
return [
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: _get_condition(
|
||||
item.weather_condition.type, item.is_daytime
|
||||
),
|
||||
ATTR_FORECAST_TIME: item.interval.start_time,
|
||||
ATTR_FORECAST_HUMIDITY: item.relative_humidity,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: item.precipitation.probability.percent,
|
||||
ATTR_FORECAST_CLOUD_COVERAGE: item.cloud_cover,
|
||||
ATTR_FORECAST_NATIVE_PRECIPITATION: item.precipitation.qpf.quantity,
|
||||
ATTR_FORECAST_NATIVE_PRESSURE: item.air_pressure.mean_sea_level_millibars,
|
||||
ATTR_FORECAST_NATIVE_TEMP: item.temperature.degrees,
|
||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_temperature.degrees,
|
||||
ATTR_FORECAST_WIND_BEARING: item.wind.direction.degrees,
|
||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item.wind.gust.value,
|
||||
ATTR_FORECAST_NATIVE_WIND_SPEED: item.wind.speed.value,
|
||||
ATTR_FORECAST_NATIVE_DEW_POINT: item.dew_point.degrees,
|
||||
ATTR_FORECAST_UV_INDEX: item.uv_index,
|
||||
ATTR_FORECAST_IS_DAYTIME: item.is_daytime,
|
||||
}
|
||||
for item in hourly_data.forecast_hours
|
||||
]
|
||||
|
||||
@callback
|
||||
def _async_forecast_twice_daily(self) -> list[Forecast] | None:
|
||||
"""Return the twice daily forecast in native units."""
|
||||
coordinator = self.forecast_coordinators["twice_daily"]
|
||||
assert coordinator
|
||||
daily_data = coordinator.data
|
||||
assert isinstance(daily_data, DailyForecastResponse)
|
||||
forecasts: list[Forecast] = []
|
||||
for item in daily_data.forecast_days:
|
||||
# Process daytime forecast
|
||||
day_forecast = item.daytime_forecast
|
||||
forecasts.append(
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: _get_condition(
|
||||
day_forecast.weather_condition.type, is_daytime=True
|
||||
),
|
||||
ATTR_FORECAST_TIME: day_forecast.interval.start_time,
|
||||
ATTR_FORECAST_HUMIDITY: day_forecast.relative_humidity,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: day_forecast.precipitation.probability.percent,
|
||||
ATTR_FORECAST_CLOUD_COVERAGE: day_forecast.cloud_cover,
|
||||
ATTR_FORECAST_NATIVE_PRECIPITATION: day_forecast.precipitation.qpf.quantity,
|
||||
ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees,
|
||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_max_temperature.degrees,
|
||||
ATTR_FORECAST_WIND_BEARING: day_forecast.wind.direction.degrees,
|
||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: day_forecast.wind.gust.value,
|
||||
ATTR_FORECAST_NATIVE_WIND_SPEED: day_forecast.wind.speed.value,
|
||||
ATTR_FORECAST_UV_INDEX: day_forecast.uv_index,
|
||||
ATTR_FORECAST_IS_DAYTIME: True,
|
||||
}
|
||||
)
|
||||
|
||||
# Process nighttime forecast
|
||||
night_forecast = item.nighttime_forecast
|
||||
forecasts.append(
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: _get_condition(
|
||||
night_forecast.weather_condition.type, is_daytime=False
|
||||
),
|
||||
ATTR_FORECAST_TIME: night_forecast.interval.start_time,
|
||||
ATTR_FORECAST_HUMIDITY: night_forecast.relative_humidity,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: night_forecast.precipitation.probability.percent,
|
||||
ATTR_FORECAST_CLOUD_COVERAGE: night_forecast.cloud_cover,
|
||||
ATTR_FORECAST_NATIVE_PRECIPITATION: night_forecast.precipitation.qpf.quantity,
|
||||
ATTR_FORECAST_NATIVE_TEMP: item.min_temperature.degrees,
|
||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_min_temperature.degrees,
|
||||
ATTR_FORECAST_WIND_BEARING: night_forecast.wind.direction.degrees,
|
||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: night_forecast.wind.gust.value,
|
||||
ATTR_FORECAST_NATIVE_WIND_SPEED: night_forecast.wind.speed.value,
|
||||
ATTR_FORECAST_UV_INDEX: night_forecast.uv_index,
|
||||
ATTR_FORECAST_IS_DAYTIME: False,
|
||||
}
|
||||
)
|
||||
|
||||
return forecasts
|
||||
@@ -7,7 +7,7 @@ from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from aiohomeconnect.client import Client as HomeConnectClient
|
||||
from aiohomeconnect.model import (
|
||||
@@ -247,14 +247,15 @@ class HomeConnectCoordinator(
|
||||
value=event.value,
|
||||
)
|
||||
else:
|
||||
event_value = event.value
|
||||
if event_key in (
|
||||
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
):
|
||||
) and isinstance(event_value, str):
|
||||
await self.update_options(
|
||||
event_message_ha_id,
|
||||
event_key,
|
||||
ProgramKey(cast(str, event.value)),
|
||||
ProgramKey(event_value),
|
||||
)
|
||||
events[event_key] = event
|
||||
self._call_event_listener(event_message)
|
||||
|
||||
@@ -14,7 +14,6 @@ from aiohomeconnect.model.error import (
|
||||
TooManyRequestsError,
|
||||
)
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -62,10 +61,8 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.update_native_value()
|
||||
available = self._attr_available = self.appliance.info.connected
|
||||
self.async_write_ha_state()
|
||||
state = STATE_UNAVAILABLE if not available else self.state
|
||||
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, state)
|
||||
_LOGGER.debug("Updated %s", self)
|
||||
|
||||
@property
|
||||
def bsh_key(self) -> str:
|
||||
@@ -80,7 +77,7 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
|
||||
as event updates should take precedence over the coordinator
|
||||
refresh.
|
||||
"""
|
||||
return self._attr_available
|
||||
return self.appliance.info.connected and self._attr_available
|
||||
|
||||
|
||||
class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_connect_zbt2",
|
||||
"integration_type": "hardware",
|
||||
"loggers": [
|
||||
"bellows",
|
||||
"universal_silabs_flasher",
|
||||
"zigpy.serial",
|
||||
"serial_asyncio_fast"
|
||||
],
|
||||
"quality_scale": "bronze",
|
||||
"usb": [
|
||||
{
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect",
|
||||
"integration_type": "hardware",
|
||||
"loggers": [
|
||||
"bellows",
|
||||
"universal_silabs_flasher",
|
||||
"zigpy.serial",
|
||||
"serial_asyncio_fast"
|
||||
],
|
||||
"usb": [
|
||||
{
|
||||
"description": "*skyconnect v1.0*",
|
||||
|
||||
@@ -7,5 +7,11 @@
|
||||
"dependencies": ["hardware", "homeassistant_hardware"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow",
|
||||
"integration_type": "hardware",
|
||||
"loggers": [
|
||||
"bellows",
|
||||
"universal_silabs_flasher",
|
||||
"zigpy.serial",
|
||||
"serial_asyncio_fast"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -121,12 +121,15 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):
|
||||
"""Initialize AutomowerEntity."""
|
||||
super().__init__(coordinator)
|
||||
self.mower_id = mower_id
|
||||
parts = self.mower_attributes.system.model.split(maxsplit=2)
|
||||
model_witout_manufacturer = self.mower_attributes.system.model.removeprefix(
|
||||
"Husqvarna "
|
||||
).removeprefix("HUSQVARNA ")
|
||||
parts = model_witout_manufacturer.split(maxsplit=1)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, mower_id)},
|
||||
manufacturer=parts[0],
|
||||
model=parts[1],
|
||||
model_id=parts[2],
|
||||
manufacturer="Husqvarna",
|
||||
model=parts[0].capitalize().removesuffix("®"),
|
||||
model_id=parts[1],
|
||||
name=self.mower_attributes.system.name,
|
||||
serial_number=self.mower_attributes.system.serial_number,
|
||||
suggested_area="Garden",
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==2.1.2"]
|
||||
"requirements": ["pylamarzocco==2.1.3"]
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ class LocalTodoListEntity(TodoListEntity):
|
||||
),
|
||||
due=due,
|
||||
description=item.description,
|
||||
completed=item.completed,
|
||||
)
|
||||
)
|
||||
self._attr_todo_items = todo_items
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
"name": "Altitude above sea level"
|
||||
},
|
||||
"auto_relock_timer": {
|
||||
"name": "Autorelock time"
|
||||
"name": "Auto-relock time"
|
||||
},
|
||||
"cook_time": {
|
||||
"name": "Cooking time"
|
||||
|
||||
@@ -139,7 +139,7 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE
|
||||
forecasts_by_date[date].append(timestamp)
|
||||
|
||||
daily_forecasts = []
|
||||
for date in sorted(forecasts_by_date.keys())[:5]:
|
||||
for date in sorted(forecasts_by_date.keys()):
|
||||
day_forecasts = forecasts_by_date[date]
|
||||
if not day_forecasts:
|
||||
continue
|
||||
@@ -186,5 +186,5 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE
|
||||
return None
|
||||
return [
|
||||
self._convert_forecast_data(forecast_data)
|
||||
for forecast_data in self.coordinator.data.forecast_timestamps[:24]
|
||||
for forecast_data in self.coordinator.data.forecast_timestamps
|
||||
]
|
||||
|
||||
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientResponseError
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -153,11 +153,12 @@ class MieleButton(MieleEntity, ButtonEntity):
|
||||
self._device_id,
|
||||
{PROCESS_ACTION: self.entity_description.press_data},
|
||||
)
|
||||
except aiohttp.ClientResponseError as ex:
|
||||
except ClientResponseError as err:
|
||||
_LOGGER.debug("Error setting button state for %s: %s", self.entity_id, err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_state_error",
|
||||
translation_placeholders={
|
||||
"entity": self.entity_id,
|
||||
},
|
||||
) from ex
|
||||
) from err
|
||||
|
||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Final, cast
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientResponseError
|
||||
from pymiele import MieleDevice, MieleTemperature
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
@@ -250,7 +250,8 @@ class MieleClimate(MieleEntity, ClimateEntity):
|
||||
cast(float, kwargs.get(ATTR_TEMPERATURE)),
|
||||
self.entity_description.zone,
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
except ClientResponseError as err:
|
||||
_LOGGER.debug("Error setting climate state for %s: %s", self.entity_id, err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_state_error",
|
||||
|
||||
@@ -73,7 +73,7 @@ class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]):
|
||||
_LOGGER.debug(
|
||||
"Error fetching actions for device %s: Status: %s, Message: %s",
|
||||
device_id,
|
||||
err.status,
|
||||
str(err.status),
|
||||
err.message,
|
||||
)
|
||||
actions_json = {}
|
||||
|
||||
@@ -142,14 +142,15 @@ class MieleFan(MieleEntity, FanEntity):
|
||||
await self.api.send_action(
|
||||
self._device_id, {VENTILATION_STEP: ventilation_step}
|
||||
)
|
||||
except ClientResponseError as ex:
|
||||
except ClientResponseError as err:
|
||||
_LOGGER.debug("Error setting fan state for %s: %s", self.entity_id, err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_state_error",
|
||||
translation_placeholders={
|
||||
"entity": self.entity_id,
|
||||
},
|
||||
) from ex
|
||||
) from err
|
||||
self.device.state_ventilation_step = ventilation_step
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -171,6 +172,7 @@ class MieleFan(MieleEntity, FanEntity):
|
||||
translation_key="set_state_error",
|
||||
translation_placeholders={
|
||||
"entity": self.entity_id,
|
||||
"err_status": str(ex.status),
|
||||
},
|
||||
) from ex
|
||||
|
||||
@@ -188,6 +190,7 @@ class MieleFan(MieleEntity, FanEntity):
|
||||
translation_key="set_state_error",
|
||||
translation_placeholders={
|
||||
"entity": self.entity_id,
|
||||
"err_status": str(ex.status),
|
||||
},
|
||||
) from ex
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Final
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientResponseError
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ColorMode,
|
||||
@@ -131,7 +131,8 @@ class MieleLight(MieleEntity, LightEntity):
|
||||
await self.api.send_action(
|
||||
self._device_id, {self.entity_description.light_type: mode}
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
except ClientResponseError as err:
|
||||
_LOGGER.debug("Error setting light state for %s: %s", self.entity_id, err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_state_error",
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientResponseError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE
|
||||
@@ -107,7 +107,7 @@ async def set_program(call: ServiceCall) -> None:
|
||||
data = {"programId": call.data[ATTR_PROGRAM_ID]}
|
||||
try:
|
||||
await api.set_program(serial_number, data)
|
||||
except aiohttp.ClientResponseError as ex:
|
||||
except ClientResponseError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_program_error",
|
||||
@@ -137,7 +137,7 @@ async def set_program_oven(call: ServiceCall) -> None:
|
||||
data["temperature"] = call.data[ATTR_TEMPERATURE]
|
||||
try:
|
||||
await api.set_program(serial_number, data)
|
||||
except aiohttp.ClientResponseError as ex:
|
||||
except ClientResponseError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_program_oven_error",
|
||||
@@ -157,7 +157,7 @@ async def get_programs(call: ServiceCall) -> ServiceResponse:
|
||||
|
||||
try:
|
||||
programs = await api.get_programs(serial_number)
|
||||
except aiohttp.ClientResponseError as ex:
|
||||
except ClientResponseError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="get_programs_error",
|
||||
|
||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Final, cast
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientResponseError
|
||||
from pymiele import MieleDevice
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
@@ -165,7 +165,8 @@ class MieleSwitch(MieleEntity, SwitchEntity):
|
||||
"""Set switch to mode."""
|
||||
try:
|
||||
await self.api.send_action(self._device_id, mode)
|
||||
except aiohttp.ClientError as err:
|
||||
except ClientResponseError as err:
|
||||
_LOGGER.debug("Error setting switch state for %s: %s", self.entity_id, err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_state_error",
|
||||
@@ -197,7 +198,8 @@ class MielePowerSwitch(MieleSwitch):
|
||||
"""Set switch to mode."""
|
||||
try:
|
||||
await self.api.send_action(self._device_id, mode)
|
||||
except aiohttp.ClientError as err:
|
||||
except ClientResponseError as err:
|
||||
_LOGGER.debug("Error setting switch state for %s: %s", self.entity_id, err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_state_error",
|
||||
|
||||
@@ -189,14 +189,15 @@ class MieleVacuum(MieleEntity, StateVacuumEntity):
|
||||
"""Send action to the device."""
|
||||
try:
|
||||
await self.api.send_action(device_id, action)
|
||||
except ClientResponseError as ex:
|
||||
except ClientResponseError as err:
|
||||
_LOGGER.debug("Error setting vacuum state for %s: %s", self.entity_id, err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_state_error",
|
||||
translation_placeholders={
|
||||
"entity": self.entity_id,
|
||||
},
|
||||
) from ex
|
||||
) from err
|
||||
|
||||
async def async_clean_spot(self, **kwargs: Any) -> None:
|
||||
"""Clean spot."""
|
||||
|
||||
@@ -88,6 +88,8 @@ class ModbusRegisterSensor(ModbusStructEntity, RestoreSensor, SensorEntity):
|
||||
self._attr_device_class = entry.get(CONF_DEVICE_CLASS)
|
||||
if self._precision > 0 or self._scale != int(self._scale):
|
||||
self._value_is_int = False
|
||||
if self._precision > 0 and self._data_type not in ["string", "custom"]:
|
||||
self._attr_suggested_display_precision = self._precision
|
||||
|
||||
async def async_setup_slaves(
|
||||
self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any]
|
||||
|
||||
@@ -4237,7 +4237,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
return self.async_show_form(
|
||||
step_id="entity",
|
||||
data_schema=data_schema,
|
||||
description_placeholders={
|
||||
description_placeholders=TRANSLATION_DESCRIPTION_PLACEHOLDERS
|
||||
| {
|
||||
"mqtt_device": device_name,
|
||||
"entity_name_label": entity_name_label,
|
||||
"platform_label": platform_label,
|
||||
|
||||
@@ -1312,6 +1312,7 @@
|
||||
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
||||
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/mvglive",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["MVG"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["mvg==1.4.0"]
|
||||
}
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nest",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["google_nest_sdm"],
|
||||
"requirements": ["google-nest-sdm==7.1.4"]
|
||||
"requirements": ["google-nest-sdm==9.0.1"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["openai==2.2.0", "python-open-router==0.3.2"]
|
||||
"requirements": ["openai==2.8.0", "python-open-router==0.3.3"]
|
||||
}
|
||||
|
||||
@@ -338,6 +338,13 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
options.pop(CONF_CODE_INTERPRETER)
|
||||
|
||||
if model.startswith(("o", "gpt-5")) and not model.startswith("gpt-5-pro"):
|
||||
if model.startswith("gpt-5.1"):
|
||||
reasoning_options = ["none", "low", "medium", "high"]
|
||||
elif model.startswith("gpt-5"):
|
||||
reasoning_options = ["minimal", "low", "medium", "high"]
|
||||
else:
|
||||
reasoning_options = ["low", "medium", "high"]
|
||||
|
||||
step_schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
@@ -345,9 +352,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
default=RECOMMENDED_REASONING_EFFORT,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=["low", "medium", "high"]
|
||||
if model.startswith("o")
|
||||
else ["minimal", "low", "medium", "high"],
|
||||
options=reasoning_options,
|
||||
translation_key=CONF_REASONING_EFFORT,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
|
||||
@@ -510,6 +510,9 @@ class OpenAIBaseLLMEntity(Entity):
|
||||
"verbosity": options.get(CONF_VERBOSITY, RECOMMENDED_VERBOSITY)
|
||||
}
|
||||
|
||||
if model_args["model"].startswith("gpt-5.1"):
|
||||
model_args["prompt_cache_retention"] = "24h"
|
||||
|
||||
tools: list[ToolParam] = []
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/openai_conversation",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["openai==2.2.0"]
|
||||
"requirements": ["openai==2.8.0"]
|
||||
}
|
||||
|
||||
@@ -146,7 +146,8 @@
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"medium": "[%key:common::state::medium%]",
|
||||
"minimal": "Minimal"
|
||||
"minimal": "Minimal",
|
||||
"none": "None"
|
||||
}
|
||||
},
|
||||
"search_context_size": {
|
||||
|
||||
@@ -37,6 +37,7 @@ SELECT_TYPES = (
|
||||
PlugwiseSelectEntityDescription(
|
||||
key=SELECT_SCHEDULE,
|
||||
translation_key=SELECT_SCHEDULE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
options_key="available_schedules",
|
||||
),
|
||||
PlugwiseSelectEntityDescription(
|
||||
|
||||
@@ -48,7 +48,6 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
PlugwiseSensorEntityDescription(
|
||||
key="setpoint_high",
|
||||
@@ -56,7 +55,6 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
PlugwiseSensorEntityDescription(
|
||||
key="setpoint_low",
|
||||
@@ -64,13 +62,11 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
PlugwiseSensorEntityDescription(
|
||||
key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
PlugwiseSensorEntityDescription(
|
||||
@@ -94,6 +90,7 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
|
||||
translation_key="outdoor_temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
PlugwiseSensorEntityDescription(
|
||||
@@ -352,8 +349,8 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
|
||||
key="illuminance",
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
PlugwiseSensorEntityDescription(
|
||||
key="modulation_level",
|
||||
@@ -365,8 +362,8 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
|
||||
PlugwiseSensorEntityDescription(
|
||||
key="valve_position",
|
||||
translation_key="valve_position",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
PlugwiseSensorEntityDescription(
|
||||
|
||||
@@ -2,5 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import UnitOfTemperature, UnitOfVolumeFlowRate
|
||||
|
||||
DOMAIN = "pooldose"
|
||||
MANUFACTURER = "SEKO"
|
||||
|
||||
# Mapping of device units to Home Assistant units
|
||||
UNIT_MAPPING: dict[str, str] = {
|
||||
# Temperature units
|
||||
"°C": UnitOfTemperature.CELSIUS,
|
||||
"°F": UnitOfTemperature.FAHRENHEIT,
|
||||
# Volume flow rate units
|
||||
"m3/h": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
|
||||
"L/s": UnitOfVolumeFlowRate.LITERS_PER_SECOND,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"cl": {
|
||||
"default": "mdi:pool"
|
||||
},
|
||||
"cl_type_dosing": {
|
||||
"default": "mdi:flask"
|
||||
},
|
||||
"flow_rate": {
|
||||
"default": "mdi:pipe-valve"
|
||||
},
|
||||
"ofa_orp_time": {
|
||||
"default": "mdi:clock"
|
||||
},
|
||||
@@ -22,6 +31,9 @@
|
||||
"orp_type_dosing": {
|
||||
"default": "mdi:flask"
|
||||
},
|
||||
"peristaltic_cl_dosing": {
|
||||
"default": "mdi:pump"
|
||||
},
|
||||
"peristaltic_orp_dosing": {
|
||||
"default": "mdi:pump"
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -10,36 +11,61 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfElectricPotential, UnitOfTime
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
EntityCategory,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PooldoseConfigEntry
|
||||
from .const import UNIT_MAPPING
|
||||
from .entity import PooldoseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PooldoseSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes PoolDose sensor entity."""
|
||||
|
||||
use_dynamic_unit: bool = False
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
|
||||
PooldoseSensorEntityDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
# Unit dynamically determined via API
|
||||
use_dynamic_unit=True,
|
||||
),
|
||||
SensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH),
|
||||
PooldoseSensorEntityDescription(
|
||||
key="orp",
|
||||
translation_key="orp",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="cl",
|
||||
translation_key="cl",
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
PooldoseSensorEntityDescription(
|
||||
key="flow_rate",
|
||||
translation_key="flow_rate",
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
use_dynamic_unit=True,
|
||||
),
|
||||
PooldoseSensorEntityDescription(
|
||||
key="ph_type_dosing",
|
||||
translation_key="ph_type_dosing",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["alcalyne", "acid"],
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="peristaltic_ph_dosing",
|
||||
translation_key="peristaltic_ph_dosing",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -47,7 +73,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["proportional", "on_off", "timed"],
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="ofa_ph_time",
|
||||
translation_key="ofa_ph_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -55,7 +81,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="orp_type_dosing",
|
||||
translation_key="orp_type_dosing",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -63,7 +89,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["low", "high"],
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="peristaltic_orp_dosing",
|
||||
translation_key="peristaltic_orp_dosing",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -71,7 +97,23 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["off", "proportional", "on_off", "timed"],
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="cl_type_dosing",
|
||||
translation_key="cl_type_dosing",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["low", "high"],
|
||||
),
|
||||
PooldoseSensorEntityDescription(
|
||||
key="peristaltic_cl_dosing",
|
||||
translation_key="peristaltic_cl_dosing",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["off", "proportional", "on_off", "timed"],
|
||||
),
|
||||
PooldoseSensorEntityDescription(
|
||||
key="ofa_orp_time",
|
||||
translation_key="ofa_orp_time",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
@@ -79,7 +121,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="ph_calibration_type",
|
||||
translation_key="ph_calibration_type",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -87,7 +129,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["off", "reference", "1_point", "2_points"],
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="ph_calibration_offset",
|
||||
translation_key="ph_calibration_offset",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -96,7 +138,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="ph_calibration_slope",
|
||||
translation_key="ph_calibration_slope",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -105,7 +147,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="orp_calibration_type",
|
||||
translation_key="orp_calibration_type",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -113,7 +155,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["off", "reference", "1_point"],
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="orp_calibration_offset",
|
||||
translation_key="orp_calibration_offset",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -122,7 +164,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
PooldoseSensorEntityDescription(
|
||||
key="orp_calibration_slope",
|
||||
translation_key="orp_calibration_slope",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -163,6 +205,8 @@ async def async_setup_entry(
|
||||
class PooldoseSensor(PooldoseEntity, SensorEntity):
|
||||
"""Sensor entity for the Seko PoolDose Python API."""
|
||||
|
||||
entity_description: PooldoseSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | int | str | None:
|
||||
"""Return the current value of the sensor."""
|
||||
@@ -175,9 +219,12 @@ class PooldoseSensor(PooldoseEntity, SensorEntity):
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit of measurement."""
|
||||
if (
|
||||
self.entity_description.key == "temperature"
|
||||
self.entity_description.use_dynamic_unit
|
||||
and (data := self.get_data()) is not None
|
||||
and (device_unit := data.get("unit"))
|
||||
):
|
||||
return data["unit"] # °C or °F
|
||||
# Map device unit to Home Assistant unit, return None if unknown
|
||||
return UNIT_MAPPING.get(device_unit)
|
||||
|
||||
# Fall back to static unit from entity description
|
||||
return super().native_unit_of_measurement
|
||||
|
||||
@@ -34,6 +34,19 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"cl": {
|
||||
"name": "Chlorine"
|
||||
},
|
||||
"cl_type_dosing": {
|
||||
"name": "Chlorine dosing type",
|
||||
"state": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]"
|
||||
}
|
||||
},
|
||||
"flow_rate": {
|
||||
"name": "Flow rate"
|
||||
},
|
||||
"ofa_orp_time": {
|
||||
"name": "ORP overfeed alert time"
|
||||
},
|
||||
@@ -64,6 +77,15 @@
|
||||
"low": "[%key:common::state::low%]"
|
||||
}
|
||||
},
|
||||
"peristaltic_cl_dosing": {
|
||||
"name": "Chlorine peristaltic dosing",
|
||||
"state": {
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on_off": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::on_off%]",
|
||||
"proportional": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::proportional%]",
|
||||
"timed": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::timed%]"
|
||||
}
|
||||
},
|
||||
"peristaltic_orp_dosing": {
|
||||
"name": "ORP peristaltic dosing",
|
||||
"state": {
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
||||
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||
|
||||
@@ -58,7 +58,8 @@ PLATFORMS = [
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
]
|
||||
DEVICE_UPDATE_INTERVAL = timedelta(seconds=60)
|
||||
DEVICE_UPDATE_INTERVAL_MIN = timedelta(seconds=60)
|
||||
DEVICE_UPDATE_INTERVAL_PER_CAM = timedelta(seconds=10)
|
||||
FIRMWARE_UPDATE_INTERVAL = timedelta(hours=24)
|
||||
NUM_CRED_ERRORS = 3
|
||||
|
||||
@@ -137,9 +138,12 @@ async def async_setup_entry(
|
||||
}
|
||||
hass.config_entries.async_update_entry(config_entry, data=data)
|
||||
|
||||
min_timeout = host.api.timeout * (RETRY_ATTEMPTS + 2)
|
||||
update_timeout = max(min_timeout, min_timeout * host.api.num_cameras / 10)
|
||||
|
||||
async def async_device_config_update() -> None:
|
||||
"""Update the host state cache and renew the ONVIF-subscription."""
|
||||
async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
|
||||
async with asyncio.timeout(update_timeout):
|
||||
try:
|
||||
await host.update_states()
|
||||
except CredentialsInvalidError as err:
|
||||
@@ -156,7 +160,7 @@ async def async_setup_entry(
|
||||
|
||||
host.credential_errors = 0
|
||||
|
||||
async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
|
||||
async with asyncio.timeout(min_timeout):
|
||||
await host.renew()
|
||||
|
||||
if host.api.new_devices and config_entry.state == ConfigEntryState.LOADED:
|
||||
@@ -171,7 +175,7 @@ async def async_setup_entry(
|
||||
|
||||
async def async_check_firmware_update() -> None:
|
||||
"""Check for firmware updates."""
|
||||
async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
|
||||
async with asyncio.timeout(min_timeout):
|
||||
try:
|
||||
await host.api.check_new_firmware(host.firmware_ch_list)
|
||||
except ReolinkError as err:
|
||||
@@ -197,7 +201,10 @@ async def async_setup_entry(
|
||||
config_entry=config_entry,
|
||||
name=f"reolink.{host.api.nvr_name}",
|
||||
update_method=async_device_config_update,
|
||||
update_interval=DEVICE_UPDATE_INTERVAL,
|
||||
update_interval=max(
|
||||
DEVICE_UPDATE_INTERVAL_MIN,
|
||||
DEVICE_UPDATE_INTERVAL_PER_CAM * host.api.num_cameras,
|
||||
),
|
||||
)
|
||||
firmware_coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
|
||||
@@ -222,17 +222,13 @@ class ReolinkHost:
|
||||
enable_onvif = None
|
||||
enable_rtmp = None
|
||||
|
||||
if not self._api.rtsp_enabled and not self._api.baichuan_only:
|
||||
if not self._api.rtsp_enabled and self._api.supported(None, "RTSP"):
|
||||
_LOGGER.debug(
|
||||
"RTSP is disabled on %s, trying to enable it", self._api.nvr_name
|
||||
)
|
||||
enable_rtsp = True
|
||||
|
||||
if (
|
||||
not self._api.onvif_enabled
|
||||
and onvif_supported
|
||||
and not self._api.baichuan_only
|
||||
):
|
||||
if not self._api.onvif_enabled and onvif_supported:
|
||||
_LOGGER.debug(
|
||||
"ONVIF is disabled on %s, trying to enable it", self._api.nvr_name
|
||||
)
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.16.4"]
|
||||
"requirements": ["reolink-aio==0.16.5"]
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.helpers.update_coordinator import (
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from . import DEVICE_UPDATE_INTERVAL
|
||||
from . import DEVICE_UPDATE_INTERVAL_MIN, DEVICE_UPDATE_INTERVAL_PER_CAM
|
||||
from .const import DOMAIN
|
||||
from .entity import (
|
||||
ReolinkChannelCoordinatorEntity,
|
||||
@@ -221,7 +221,10 @@ class ReolinkUpdateBaseEntity(
|
||||
|
||||
async def _resume_update_coordinator(self, *args: Any) -> None:
|
||||
"""Resume updating the states using the data update coordinator (after reboots)."""
|
||||
self._reolink_data.device_coordinator.update_interval = DEVICE_UPDATE_INTERVAL
|
||||
self._reolink_data.device_coordinator.update_interval = max(
|
||||
DEVICE_UPDATE_INTERVAL_MIN,
|
||||
DEVICE_UPDATE_INTERVAL_PER_CAM * self._host.api.num_cameras,
|
||||
)
|
||||
try:
|
||||
await self._reolink_data.device_coordinator.async_refresh()
|
||||
finally:
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any
|
||||
import aiohttp
|
||||
from aiohttp import hdrs
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_AUTHENTICATION,
|
||||
@@ -51,6 +52,7 @@ SUPPORT_REST_METHODS = ["get", "patch", "post", "put", "delete"]
|
||||
|
||||
CONF_CONTENT_TYPE = "content_type"
|
||||
CONF_INSECURE_CIPHER = "insecure_cipher"
|
||||
CONF_SKIP_URL_ENCODING = "skip_url_encoding"
|
||||
|
||||
COMMAND_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -69,6 +71,7 @@ COMMAND_SCHEMA = vol.Schema(
|
||||
vol.Optional(CONF_CONTENT_TYPE): cv.string,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
|
||||
vol.Optional(CONF_INSECURE_CIPHER, default=False): cv.boolean,
|
||||
vol.Optional(CONF_SKIP_URL_ENCODING, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -113,6 +116,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
method = command_config[CONF_METHOD]
|
||||
|
||||
template_url = command_config[CONF_URL]
|
||||
skip_url_encoding = command_config[CONF_SKIP_URL_ENCODING]
|
||||
|
||||
auth = None
|
||||
digest_middleware = None
|
||||
@@ -179,7 +183,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
request_kwargs["middlewares"] = (digest_middleware,)
|
||||
|
||||
async with getattr(websession, method)(
|
||||
request_url,
|
||||
URL(request_url, encoded=skip_url_encoding),
|
||||
**request_kwargs,
|
||||
) as response:
|
||||
if response.status < HTTPStatus.BAD_REQUEST:
|
||||
|
||||
@@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Ruuvitag BLE device from a config entry."""
|
||||
"""Set up Ruuvi BLE device from a config entry."""
|
||||
address = entry.unique_id
|
||||
assert address is not None
|
||||
data = RuuvitagBluetoothDeviceData()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "ruuvitag_ble",
|
||||
"name": "RuuviTag BLE",
|
||||
"name": "Ruuvi BLE",
|
||||
"bluetooth": [
|
||||
{
|
||||
"connectable": false,
|
||||
|
||||
@@ -191,7 +191,7 @@ async def async_setup_entry(
|
||||
entry: config_entries.ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Ruuvitag BLE sensors."""
|
||||
"""Set up the Ruuvi BLE sensors."""
|
||||
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
]
|
||||
@@ -210,7 +210,7 @@ class RuuvitagBluetoothSensorEntity(
|
||||
],
|
||||
SensorEntity,
|
||||
):
|
||||
"""Representation of a Ruuvitag BLE sensor."""
|
||||
"""Representation of a Ruuvi BLE sensor."""
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | float | None:
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"samsungctl[websocket]==0.7.1",
|
||||
"samsungtvws[async,encrypted]==2.7.2",
|
||||
"wakeonlan==3.1.0",
|
||||
"async-upnp-client==0.45.0"
|
||||
"async-upnp-client==0.46.0"
|
||||
],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -13,20 +13,19 @@ from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelState,
|
||||
CodeFormat,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
CONF_ARM_HOME_MODE,
|
||||
CONF_PARTITION_NUMBER,
|
||||
DOMAIN,
|
||||
SIGNAL_PANEL_MESSAGE,
|
||||
SUBENTRY_TYPE_PARTITION,
|
||||
SatelConfigEntry,
|
||||
)
|
||||
from .entity import SatelIntegraEntity
|
||||
|
||||
ALARM_STATE_MAP = {
|
||||
AlarmState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
|
||||
@@ -59,54 +58,49 @@ async def async_setup_entry(
|
||||
|
||||
for subentry in partition_subentries:
|
||||
partition_num: int = subentry.data[CONF_PARTITION_NUMBER]
|
||||
zone_name: str = subentry.data[CONF_NAME]
|
||||
arm_home_mode: int = subentry.data[CONF_ARM_HOME_MODE]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
SatelIntegraAlarmPanel(
|
||||
controller,
|
||||
zone_name,
|
||||
arm_home_mode,
|
||||
partition_num,
|
||||
config_entry.entry_id,
|
||||
subentry,
|
||||
partition_num,
|
||||
arm_home_mode,
|
||||
)
|
||||
],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
||||
class SatelIntegraAlarmPanel(SatelIntegraEntity, AlarmControlPanelEntity):
|
||||
"""Representation of an AlarmDecoder-based alarm panel."""
|
||||
|
||||
_attr_code_format = CodeFormat.NUMBER
|
||||
_attr_should_poll = False
|
||||
_attr_supported_features = (
|
||||
AlarmControlPanelEntityFeature.ARM_HOME
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
)
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: AsyncSatel,
|
||||
device_name: str,
|
||||
arm_home_mode: int,
|
||||
partition_id: int,
|
||||
config_entry_id: str,
|
||||
subentry: ConfigSubentry,
|
||||
device_number: int,
|
||||
arm_home_mode: int,
|
||||
) -> None:
|
||||
"""Initialize the alarm panel."""
|
||||
self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}"
|
||||
self._arm_home_mode = arm_home_mode
|
||||
self._partition_id = partition_id
|
||||
self._satel = controller
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
|
||||
super().__init__(
|
||||
controller,
|
||||
config_entry_id,
|
||||
subentry,
|
||||
device_number,
|
||||
)
|
||||
|
||||
self._arm_home_mode = arm_home_mode
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Update alarm status and register callbacks for future updates."""
|
||||
self._attr_alarm_state = self._read_alarm_state()
|
||||
@@ -136,7 +130,7 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
||||
for satel_state, ha_state in ALARM_STATE_MAP.items():
|
||||
if (
|
||||
satel_state in self._satel.partition_states
|
||||
and self._partition_id in self._satel.partition_states[satel_state]
|
||||
and self._device_number in self._satel.partition_states[satel_state]
|
||||
):
|
||||
return ha_state
|
||||
|
||||
@@ -152,21 +146,21 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
||||
self._attr_alarm_state == AlarmControlPanelState.TRIGGERED
|
||||
)
|
||||
|
||||
await self._satel.disarm(code, [self._partition_id])
|
||||
await self._satel.disarm(code, [self._device_number])
|
||||
|
||||
if clear_alarm_necessary:
|
||||
# Wait 1s before clearing the alarm
|
||||
await asyncio.sleep(1)
|
||||
await self._satel.clear_alarm(code, [self._partition_id])
|
||||
await self._satel.clear_alarm(code, [self._device_number])
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
|
||||
if code:
|
||||
await self._satel.arm(code, [self._partition_id])
|
||||
await self._satel.arm(code, [self._device_number])
|
||||
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send arm home command."""
|
||||
|
||||
if code:
|
||||
await self._satel.arm(code, [self._partition_id], self._arm_home_mode)
|
||||
await self._satel.arm(code, [self._device_number], self._arm_home_mode)
|
||||
|
||||
@@ -8,25 +8,22 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
CONF_OUTPUT_NUMBER,
|
||||
CONF_OUTPUTS,
|
||||
CONF_ZONE_NUMBER,
|
||||
CONF_ZONE_TYPE,
|
||||
CONF_ZONES,
|
||||
DOMAIN,
|
||||
SIGNAL_OUTPUTS_UPDATED,
|
||||
SIGNAL_ZONES_UPDATED,
|
||||
SUBENTRY_TYPE_OUTPUT,
|
||||
SUBENTRY_TYPE_ZONE,
|
||||
SatelConfigEntry,
|
||||
)
|
||||
from .entity import SatelIntegraEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -46,18 +43,16 @@ async def async_setup_entry(
|
||||
for subentry in zone_subentries:
|
||||
zone_num: int = subentry.data[CONF_ZONE_NUMBER]
|
||||
zone_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
|
||||
zone_name: str = subentry.data[CONF_NAME]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
SatelIntegraBinarySensor(
|
||||
controller,
|
||||
zone_num,
|
||||
zone_name,
|
||||
zone_type,
|
||||
CONF_ZONES,
|
||||
SIGNAL_ZONES_UPDATED,
|
||||
config_entry.entry_id,
|
||||
subentry,
|
||||
zone_num,
|
||||
zone_type,
|
||||
SIGNAL_ZONES_UPDATED,
|
||||
)
|
||||
],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
@@ -71,51 +66,44 @@ async def async_setup_entry(
|
||||
for subentry in output_subentries:
|
||||
output_num: int = subentry.data[CONF_OUTPUT_NUMBER]
|
||||
ouput_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
|
||||
output_name: str = subentry.data[CONF_NAME]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
SatelIntegraBinarySensor(
|
||||
controller,
|
||||
output_num,
|
||||
output_name,
|
||||
ouput_type,
|
||||
CONF_OUTPUTS,
|
||||
SIGNAL_OUTPUTS_UPDATED,
|
||||
config_entry.entry_id,
|
||||
subentry,
|
||||
output_num,
|
||||
ouput_type,
|
||||
SIGNAL_OUTPUTS_UPDATED,
|
||||
)
|
||||
],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class SatelIntegraBinarySensor(BinarySensorEntity):
|
||||
class SatelIntegraBinarySensor(SatelIntegraEntity, BinarySensorEntity):
|
||||
"""Representation of an Satel Integra binary sensor."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: AsyncSatel,
|
||||
device_number: int,
|
||||
device_name: str,
|
||||
device_class: BinarySensorDeviceClass,
|
||||
sensor_type: str,
|
||||
react_to_signal: str,
|
||||
config_entry_id: str,
|
||||
subentry: ConfigSubentry,
|
||||
device_number: int,
|
||||
device_class: BinarySensorDeviceClass,
|
||||
react_to_signal: str,
|
||||
) -> None:
|
||||
"""Initialize the binary_sensor."""
|
||||
self._device_number = device_number
|
||||
self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{device_number}"
|
||||
self._react_to_signal = react_to_signal
|
||||
self._satel = controller
|
||||
super().__init__(
|
||||
controller,
|
||||
config_entry_id,
|
||||
subentry,
|
||||
device_number,
|
||||
)
|
||||
|
||||
self._attr_device_class = device_class
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
|
||||
)
|
||||
self._react_to_signal = react_to_signal
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
|
||||
58
homeassistant/components/satel_integra/entity.py
Normal file
58
homeassistant/components/satel_integra/entity.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Satel Integra base entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from satel_integra.satel_integra import AsyncSatel
|
||||
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_OUTPUT,
|
||||
SUBENTRY_TYPE_PARTITION,
|
||||
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
|
||||
SUBENTRY_TYPE_ZONE,
|
||||
)
|
||||
|
||||
SubentryTypeToEntityType: dict[str, str] = {
|
||||
SUBENTRY_TYPE_PARTITION: "alarm_panel",
|
||||
SUBENTRY_TYPE_SWITCHABLE_OUTPUT: "switch",
|
||||
SUBENTRY_TYPE_ZONE: "zones",
|
||||
SUBENTRY_TYPE_OUTPUT: "outputs",
|
||||
}
|
||||
|
||||
|
||||
class SatelIntegraEntity(Entity):
|
||||
"""Defines a base Satel Integra entity."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: AsyncSatel,
|
||||
config_entry_id: str,
|
||||
subentry: ConfigSubentry,
|
||||
device_number: int,
|
||||
) -> None:
|
||||
"""Initialize the Satel Integra entity."""
|
||||
|
||||
self._satel = controller
|
||||
self._device_number = device_number
|
||||
|
||||
entity_type = SubentryTypeToEntityType[subentry.subentry_type]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert entity_type is not None
|
||||
|
||||
self._attr_unique_id = f"{config_entry_id}_{entity_type}_{device_number}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=subentry.data[CONF_NAME], identifiers={(DOMAIN, self._attr_unique_id)}
|
||||
)
|
||||
@@ -7,19 +7,19 @@ from typing import Any
|
||||
from satel_integra.satel_integra import AsyncSatel
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.const import CONF_CODE, CONF_NAME
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_CODE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
CONF_SWITCHABLE_OUTPUT_NUMBER,
|
||||
DOMAIN,
|
||||
SIGNAL_OUTPUTS_UPDATED,
|
||||
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
|
||||
SatelConfigEntry,
|
||||
)
|
||||
from .entity import SatelIntegraEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -38,47 +38,42 @@ async def async_setup_entry(
|
||||
|
||||
for subentry in switchable_output_subentries:
|
||||
switchable_output_num: int = subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER]
|
||||
switchable_output_name: str = subentry.data[CONF_NAME]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
SatelIntegraSwitch(
|
||||
controller,
|
||||
switchable_output_num,
|
||||
switchable_output_name,
|
||||
config_entry.options.get(CONF_CODE),
|
||||
config_entry.entry_id,
|
||||
subentry,
|
||||
switchable_output_num,
|
||||
config_entry.options.get(CONF_CODE),
|
||||
),
|
||||
],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class SatelIntegraSwitch(SwitchEntity):
|
||||
"""Representation of an Satel switch."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
class SatelIntegraSwitch(SatelIntegraEntity, SwitchEntity):
|
||||
"""Representation of an Satel Integra switch."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: AsyncSatel,
|
||||
device_number: int,
|
||||
device_name: str,
|
||||
code: str | None,
|
||||
config_entry_id: str,
|
||||
subentry: ConfigSubentry,
|
||||
device_number: int,
|
||||
code: str | None,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
self._device_number = device_number
|
||||
self._attr_unique_id = f"{config_entry_id}_switch_{device_number}"
|
||||
self._code = code
|
||||
self._satel = controller
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
|
||||
super().__init__(
|
||||
controller,
|
||||
config_entry_id,
|
||||
subentry,
|
||||
device_number,
|
||||
)
|
||||
|
||||
self._code = code
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
self._attr_is_on = self._device_number in self._satel.violated_outputs
|
||||
|
||||
50
homeassistant/components/saunum/__init__.py
Normal file
50
homeassistant/components/saunum/__init__.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""The Saunum Leil Sauna Control Unit integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pysaunum import SaunumClient, SaunumConnectionError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import LeilSaunaCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool:
|
||||
"""Set up Saunum Leil Sauna from a config entry."""
|
||||
host = entry.data[CONF_HOST]
|
||||
|
||||
client = SaunumClient(host=host)
|
||||
|
||||
# Test connection
|
||||
try:
|
||||
await client.connect()
|
||||
except SaunumConnectionError as exc:
|
||||
raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc
|
||||
|
||||
coordinator = LeilSaunaCoordinator(hass, client, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
coordinator = entry.runtime_data
|
||||
coordinator.client.close()
|
||||
|
||||
return unload_ok
|
||||
107
homeassistant/components/saunum/climate.py
Normal file
107
homeassistant/components/saunum/climate.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Climate platform for Saunum Leil Sauna Control Unit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pysaunum import MAX_TEMPERATURE, MIN_TEMPERATURE, SaunumException
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import LeilSaunaConfigEntry
|
||||
from .const import DELAYED_REFRESH_SECONDS
|
||||
from .entity import LeilSaunaEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LeilSaunaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Saunum Leil Sauna climate entity."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities([LeilSaunaClimate(coordinator)])
|
||||
|
||||
|
||||
class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
|
||||
"""Representation of a Saunum Leil Sauna climate entity."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
|
||||
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_min_temp = MIN_TEMPERATURE
|
||||
_attr_max_temp = MAX_TEMPERATURE
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature in Celsius."""
|
||||
return self.coordinator.data.current_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature in Celsius."""
|
||||
return self.coordinator.data.target_temperature
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return current HVAC mode."""
|
||||
session_active = self.coordinator.data.session_active
|
||||
return HVACMode.HEAT if session_active else HVACMode.OFF
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return current HVAC action."""
|
||||
if not self.coordinator.data.session_active:
|
||||
return HVACAction.OFF
|
||||
|
||||
heater_elements_active = self.coordinator.data.heater_elements_active
|
||||
return (
|
||||
HVACAction.HEATING
|
||||
if heater_elements_active and heater_elements_active > 0
|
||||
else HVACAction.IDLE
|
||||
)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new HVAC mode."""
|
||||
try:
|
||||
if hvac_mode == HVACMode.HEAT:
|
||||
await self.coordinator.client.async_start_session()
|
||||
else:
|
||||
await self.coordinator.client.async_stop_session()
|
||||
except SaunumException as err:
|
||||
raise HomeAssistantError(f"Failed to set HVAC mode to {hvac_mode}") from err
|
||||
|
||||
# The device takes 1-2 seconds to turn heater elements on/off and
|
||||
# update heater_elements_active. Wait and refresh again to ensure
|
||||
# the HVAC action state reflects the actual heater status.
|
||||
await asyncio.sleep(DELAYED_REFRESH_SECONDS.total_seconds())
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
try:
|
||||
await self.coordinator.client.async_set_target_temperature(
|
||||
int(kwargs[ATTR_TEMPERATURE])
|
||||
)
|
||||
except SaunumException as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to set temperature to {kwargs[ATTR_TEMPERATURE]}"
|
||||
) from err
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
76
homeassistant/components/saunum/config_flow.py
Normal file
76
homeassistant/components/saunum/config_flow.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Config flow for Saunum Leil Sauna Control Unit integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pysaunum import SaunumClient, SaunumException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(data: dict[str, Any]) -> None:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
host = data[CONF_HOST]
|
||||
|
||||
client = SaunumClient(host=host)
|
||||
|
||||
try:
|
||||
await client.connect()
|
||||
# Try to read data to verify communication
|
||||
await client.async_get_data()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Saunum Leil Sauna Control Unit."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
# Check for duplicate configuration
|
||||
self._async_abort_entries_match(user_input)
|
||||
|
||||
try:
|
||||
await validate_input(user_input)
|
||||
except SaunumException:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title="Saunum Leil Sauna",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
16
homeassistant/components/saunum/const.py
Normal file
16
homeassistant/components/saunum/const.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Constants for the Saunum Leil Sauna Control Unit integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN: Final = "saunum"
|
||||
|
||||
# Platforms
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.CLIMATE,
|
||||
]
|
||||
|
||||
DEFAULT_SCAN_INTERVAL: Final = timedelta(seconds=60)
|
||||
DELAYED_REFRESH_SECONDS: Final = timedelta(seconds=3)
|
||||
47
homeassistant/components/saunum/coordinator.py
Normal file
47
homeassistant/components/saunum/coordinator.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Coordinator for Saunum Leil Sauna Control Unit integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pysaunum import SaunumClient, SaunumData, SaunumException
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import LeilSaunaConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LeilSaunaCoordinator(DataUpdateCoordinator[SaunumData]):
|
||||
"""Coordinator for fetching Saunum Leil Sauna data."""
|
||||
|
||||
config_entry: LeilSaunaConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
client: SaunumClient,
|
||||
config_entry: LeilSaunaConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=DEFAULT_SCAN_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self) -> SaunumData:
|
||||
"""Fetch data from the sauna controller."""
|
||||
try:
|
||||
return await self.client.async_get_data()
|
||||
except SaunumException as err:
|
||||
raise UpdateFailed(f"Communication error: {err}") from err
|
||||
49
homeassistant/components/saunum/diagnostics.py
Normal file
49
homeassistant/components/saunum/diagnostics.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Diagnostics support for Saunum Leil Sauna Control Unit integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import LeilSaunaConfigEntry
|
||||
|
||||
REDACT_CONFIG = {CONF_HOST}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: LeilSaunaConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Build diagnostics data
|
||||
diagnostics_data: dict[str, Any] = {
|
||||
"config": async_redact_data(entry.data, REDACT_CONFIG),
|
||||
"client_info": {"connected": coordinator.client.is_connected},
|
||||
"coordinator_info": {
|
||||
"last_update_success": coordinator.last_update_success,
|
||||
"update_interval": str(coordinator.update_interval),
|
||||
"last_exception": str(coordinator.last_exception)
|
||||
if coordinator.last_exception
|
||||
else None,
|
||||
},
|
||||
}
|
||||
|
||||
# Add coordinator data if available
|
||||
if coordinator.data:
|
||||
data_dict = asdict(coordinator.data)
|
||||
diagnostics_data["coordinator_data"] = data_dict
|
||||
|
||||
# Add alarm summary
|
||||
alarm_fields = [
|
||||
key
|
||||
for key, value in data_dict.items()
|
||||
if key.startswith("alarm_") and value is True
|
||||
]
|
||||
diagnostics_data["active_alarms"] = alarm_fields
|
||||
|
||||
return diagnostics_data
|
||||
27
homeassistant/components/saunum/entity.py
Normal file
27
homeassistant/components/saunum/entity.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Base entity for Saunum Leil Sauna Control Unit integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LeilSaunaCoordinator
|
||||
|
||||
|
||||
class LeilSaunaEntity(CoordinatorEntity[LeilSaunaCoordinator]):
|
||||
"""Base entity for Saunum Leil Sauna."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: LeilSaunaCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
entry_id = coordinator.config_entry.entry_id
|
||||
self._attr_unique_id = entry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry_id)},
|
||||
name="Saunum Leil",
|
||||
manufacturer="Saunum",
|
||||
model="Leil Touch Panel",
|
||||
)
|
||||
12
homeassistant/components/saunum/manifest.json
Normal file
12
homeassistant/components/saunum/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "saunum",
|
||||
"name": "Saunum Leil",
|
||||
"codeowners": ["@mettolen"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/saunum",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pysaunum"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pysaunum==0.1.0"]
|
||||
}
|
||||
74
homeassistant/components/saunum/quality_scale.yaml
Normal file
74
homeassistant/components/saunum/quality_scale.yaml
Normal file
@@ -0,0 +1,74 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver tier
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: Modbus TCP does not require authentication.
|
||||
test-coverage: done
|
||||
|
||||
# Gold tier
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Device uses generic Espressif hardware with no unique identifying information (MAC OUI or hostname) that would distinguish it from other Espressif-based devices on the network.
|
||||
discovery-update-info: todo
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Integration controls a single device; no dynamic device discovery needed.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Integration controls a single device; no dynamic device discovery needed.
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
22
homeassistant/components/saunum/strings.json
Normal file
22
homeassistant/components/saunum/strings.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "IP address of your Saunum Leil sauna control unit"
|
||||
},
|
||||
"description": "To find the IP address, navigate to Settings → Modbus Settings on your Leil touch panel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,7 @@
|
||||
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
||||
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
|
||||
from aiosenz import SENZAPI, Thermostat
|
||||
from httpx import RequestError
|
||||
from httpx import HTTPStatusError, RequestError
|
||||
import jwt
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, httpx_client
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
@@ -58,8 +60,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool
|
||||
|
||||
try:
|
||||
account = await senz_api.get_account()
|
||||
except HTTPStatusError as err:
|
||||
if err.response.status_code == HTTPStatus.UNAUTHORIZED:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_auth_failed",
|
||||
) from err
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_ready",
|
||||
) from err
|
||||
except RequestError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_ready",
|
||||
) from err
|
||||
|
||||
coordinator: SENZDataUpdateCoordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
@@ -82,3 +97,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, config_entry: SENZConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old entry."""
|
||||
|
||||
# Use sub(ject) from access_token as unique_id
|
||||
if config_entry.version == 1 and config_entry.minor_version == 1:
|
||||
token = jwt.decode(
|
||||
config_entry.data["token"]["access_token"],
|
||||
options={"verify_signature": False},
|
||||
)
|
||||
uid = token["sub"]
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, unique_id=uid, minor_version=2
|
||||
)
|
||||
_LOGGER.info(
|
||||
"Migration to version %s.%s successful",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from aiosenz import MODE_AUTO, Thermostat
|
||||
from httpx import RequestError
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
@@ -14,6 +15,7 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -81,7 +83,7 @@ class SENZClimate(CoordinatorEntity[SENZDataUpdateCoordinator], ClimateEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the thermostat is available."""
|
||||
return self._thermostat.online
|
||||
return super().available and self._thermostat.online
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
@@ -97,14 +99,32 @@ class SENZClimate(CoordinatorEntity[SENZDataUpdateCoordinator], ClimateEntity):
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
if hvac_mode == HVACMode.AUTO:
|
||||
await self._thermostat.auto()
|
||||
else:
|
||||
await self._thermostat.manual()
|
||||
try:
|
||||
if hvac_mode == HVACMode.AUTO:
|
||||
await self._thermostat.auto()
|
||||
else:
|
||||
await self._thermostat.manual()
|
||||
except RequestError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_attribute_error",
|
||||
translation_placeholders={
|
||||
"attribute": "hvac mode",
|
||||
},
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
temp: float = kwargs[ATTR_TEMPERATURE]
|
||||
await self._thermostat.manual(temp)
|
||||
try:
|
||||
await self._thermostat.manual(temp)
|
||||
except RequestError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_attribute_error",
|
||||
translation_placeholders={
|
||||
"attribute": "target temperature",
|
||||
},
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
"""Config flow for nVent RAYCHEM SENZ."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -12,6 +21,8 @@ class OAuth2FlowHandler(
|
||||
):
|
||||
"""Config flow to handle SENZ OAuth2 authentication."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
@property
|
||||
@@ -23,3 +34,49 @@ class OAuth2FlowHandler(
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
return {"scope": "restapi offline_access"}
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: Mapping[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""User initiated reconfiguration."""
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||
"""Create or update the config entry."""
|
||||
|
||||
token = jwt.decode(
|
||||
data["token"]["access_token"], options={"verify_signature": False}
|
||||
)
|
||||
uid = token["sub"]
|
||||
await self.async_set_unique_id(uid)
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch(reason="account_mismatch")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data=data
|
||||
)
|
||||
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch(reason="account_mismatch")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(), data=data
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
return await super().async_oauth_create_entry(data)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user