mirror of
https://github.com/home-assistant/core.git
synced 2025-08-02 10:08:23 +00:00
Merge branch 'dev' of github.com:home-assistant/core into target_trigger
This commit is contained in:
commit
9e8372ce95
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.29.2
|
||||
uses: github/codeql-action/init@v3.29.4
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.29.2
|
||||
uses: github/codeql-action/analyze@v3.29.4
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
@ -501,6 +501,7 @@ homeassistant.components.tag.*
|
||||
homeassistant.components.tailscale.*
|
||||
homeassistant.components.tailwind.*
|
||||
homeassistant.components.tami4.*
|
||||
homeassistant.components.tankerkoenig.*
|
||||
homeassistant.components.tautulli.*
|
||||
homeassistant.components.tcp.*
|
||||
homeassistant.components.technove.*
|
||||
@ -546,6 +547,7 @@ homeassistant.components.valve.*
|
||||
homeassistant.components.velbus.*
|
||||
homeassistant.components.vlc_telnet.*
|
||||
homeassistant.components.vodafone_station.*
|
||||
homeassistant.components.volvo.*
|
||||
homeassistant.components.wake_on_lan.*
|
||||
homeassistant.components.wake_word.*
|
||||
homeassistant.components.wallbox.*
|
||||
|
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@ -1706,6 +1706,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/voip/ @balloob @synesthesiam @jaminh
|
||||
/homeassistant/components/volumio/ @OnFreund
|
||||
/tests/components/volumio/ @OnFreund
|
||||
/homeassistant/components/volvo/ @thomasddn
|
||||
/tests/components/volvo/ @thomasddn
|
||||
/homeassistant/components/volvooncall/ @molobrakos
|
||||
/tests/components/volvooncall/ @molobrakos
|
||||
/homeassistant/components/vulcan/ @Antoni-Czaplicki
|
||||
|
@ -695,10 +695,10 @@ async def async_mount_local_lib_path(config_dir: str) -> str:
|
||||
|
||||
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
|
||||
"""Get domains of components to set up."""
|
||||
# Filter out the repeating and common config section [homeassistant]
|
||||
domains = {
|
||||
domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN
|
||||
}
|
||||
# The common config section [homeassistant] could be filtered here,
|
||||
# but that is not necessary, since it corresponds to the core integration,
|
||||
# that is always unconditionally loaded.
|
||||
domains = {cv.domain_key(key) for key in config}
|
||||
|
||||
# Add config entry and default domains
|
||||
if not hass.config.recovery_mode:
|
||||
@ -726,34 +726,28 @@ async def _async_resolve_domains_and_preload(
|
||||
together with all their dependencies.
|
||||
"""
|
||||
domains_to_setup = _get_domains(hass, config)
|
||||
platform_integrations = conf_util.extract_platform_integrations(
|
||||
config, BASE_PLATFORMS
|
||||
)
|
||||
# Ensure base platforms that have platform integrations are added to `domains`,
|
||||
# so they can be setup first instead of discovering them later when a config
|
||||
# entry setup task notices that it's needed and there is already a long line
|
||||
# to use the import executor.
|
||||
|
||||
# Also process all base platforms since we do not require the manifest
|
||||
# to list them as dependencies.
|
||||
# We want to later avoid lock contention when multiple integrations try to load
|
||||
# their manifests at once.
|
||||
#
|
||||
# Additionally process integrations that are defined under base platforms
|
||||
# to speed things up.
|
||||
# For example if we have
|
||||
# sensor:
|
||||
# - platform: template
|
||||
#
|
||||
# `template` has to be loaded to validate the config for sensor
|
||||
# so we want to start loading `sensor` as soon as we know
|
||||
# it will be needed. The more platforms under `sensor:`, the longer
|
||||
# `template` has to be loaded to validate the config for sensor.
|
||||
# The more platforms under `sensor:`, the longer
|
||||
# it will take to finish setup for `sensor` because each of these
|
||||
# platforms has to be imported before we can validate the config.
|
||||
#
|
||||
# Thankfully we are migrating away from the platform pattern
|
||||
# so this will be less of a problem in the future.
|
||||
domains_to_setup.update(platform_integrations)
|
||||
|
||||
# Additionally process base platforms since we do not require the manifest
|
||||
# to list them as dependencies.
|
||||
# We want to later avoid lock contention when multiple integrations try to load
|
||||
# their manifests at once.
|
||||
# Also process integrations that are defined under base platforms
|
||||
# to speed things up.
|
||||
platform_integrations = conf_util.extract_platform_integrations(
|
||||
config, BASE_PLATFORMS
|
||||
)
|
||||
additional_domains_to_process = {
|
||||
*BASE_PLATFORMS,
|
||||
*chain.from_iterable(platform_integrations.values()),
|
||||
|
@ -45,6 +45,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
errors = {}
|
||||
await self.async_set_unique_id(user_input[CONF_ID])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
try:
|
||||
await airthings.get_token(
|
||||
@ -60,9 +62,6 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(user_input[CONF_ID])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(title="Airthings", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
|
@ -150,7 +150,7 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
entities = [
|
||||
AirthingsHeaterEnergySensor(
|
||||
AirthingsDeviceSensor(
|
||||
coordinator,
|
||||
airthings_device,
|
||||
SENSORS[sensor_types],
|
||||
@ -162,7 +162,7 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AirthingsHeaterEnergySensor(
|
||||
class AirthingsDeviceSensor(
|
||||
CoordinatorEntity[AirthingsDataUpdateCoordinator], SensorEntity
|
||||
):
|
||||
"""Representation of a Airthings Sensor device."""
|
||||
|
@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.6.15"]
|
||||
"requirements": ["aioairzone-cloud==0.7.1"]
|
||||
}
|
||||
|
@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioamazondevices==3.5.0"]
|
||||
"requirements": ["aioamazondevices==3.5.1"]
|
||||
}
|
||||
|
@ -51,14 +51,14 @@ rules:
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
|
@ -14,6 +14,7 @@ from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .analytics import Analytics
|
||||
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
|
||||
from .http import AnalyticsDevicesView
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
@ -55,6 +56,8 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
|
||||
hass.data[DATA_COMPONENT] = analytics
|
||||
return True
|
||||
|
||||
|
@ -27,7 +27,7 @@ from homeassistant.config_entries import SOURCE_IGNORE
|
||||
from homeassistant.const import ATTR_DOMAIN, BASE_PLATFORMS, __version__ as HA_VERSION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.storage import Store
|
||||
@ -77,6 +77,11 @@ from .const import (
|
||||
)
|
||||
|
||||
|
||||
def gen_uuid() -> str:
|
||||
"""Generate a new UUID."""
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalyticsData:
|
||||
"""Analytics data."""
|
||||
@ -184,7 +189,7 @@ class Analytics:
|
||||
return
|
||||
|
||||
if self._data.uuid is None:
|
||||
self._data.uuid = uuid.uuid4().hex
|
||||
self._data.uuid = gen_uuid()
|
||||
await self._store.async_save(dataclass_asdict(self._data))
|
||||
|
||||
if self.supervisor:
|
||||
@ -381,3 +386,83 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
|
||||
).values():
|
||||
domains.update(platforms)
|
||||
return domains
|
||||
|
||||
|
||||
async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
"""Return the devices payload."""
|
||||
integrations_without_model_id: set[str] = set()
|
||||
devices: list[dict[str, Any]] = []
|
||||
dev_reg = dr.async_get(hass)
|
||||
# Devices that need via device info set
|
||||
new_indexes: dict[str, int] = {}
|
||||
via_devices: dict[str, str] = {}
|
||||
|
||||
seen_integrations = set()
|
||||
|
||||
for device in dev_reg.devices.values():
|
||||
# Ignore services
|
||||
if device.entry_type:
|
||||
continue
|
||||
|
||||
if not device.primary_config_entry:
|
||||
continue
|
||||
|
||||
config_entry = hass.config_entries.async_get_entry(device.primary_config_entry)
|
||||
|
||||
if config_entry is None:
|
||||
continue
|
||||
|
||||
seen_integrations.add(config_entry.domain)
|
||||
|
||||
if not device.model_id:
|
||||
integrations_without_model_id.add(config_entry.domain)
|
||||
continue
|
||||
|
||||
if not device.manufacturer:
|
||||
continue
|
||||
|
||||
new_indexes[device.id] = len(devices)
|
||||
devices.append(
|
||||
{
|
||||
"integration": config_entry.domain,
|
||||
"manufacturer": device.manufacturer,
|
||||
"model_id": device.model_id,
|
||||
"model": device.model,
|
||||
"sw_version": device.sw_version,
|
||||
"hw_version": device.hw_version,
|
||||
"has_suggested_area": device.suggested_area is not None,
|
||||
"has_configuration_url": device.configuration_url is not None,
|
||||
"via_device": None,
|
||||
}
|
||||
)
|
||||
if device.via_device_id:
|
||||
via_devices[device.id] = device.via_device_id
|
||||
|
||||
for from_device, via_device in via_devices.items():
|
||||
if via_device not in new_indexes:
|
||||
continue
|
||||
devices[new_indexes[from_device]]["via_device"] = new_indexes[via_device]
|
||||
|
||||
integrations = {
|
||||
domain: integration
|
||||
for domain, integration in (
|
||||
await async_get_integrations(hass, seen_integrations)
|
||||
).items()
|
||||
if isinstance(integration, Integration)
|
||||
}
|
||||
|
||||
for device_info in devices:
|
||||
if integration := integrations.get(device_info["integration"]):
|
||||
device_info["is_custom_integration"] = not integration.is_built_in
|
||||
|
||||
return {
|
||||
"version": "home-assistant:1",
|
||||
"no_model_id": sorted(
|
||||
[
|
||||
domain
|
||||
for domain in integrations_without_model_id
|
||||
if domain in integrations and integrations[domain].is_built_in
|
||||
]
|
||||
),
|
||||
"devices": devices,
|
||||
}
|
||||
|
27
homeassistant/components/analytics/http.py
Normal file
27
homeassistant/components/analytics/http.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""HTTP endpoints for analytics integration."""
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .analytics import async_devices_payload
|
||||
|
||||
|
||||
class AnalyticsDevicesView(HomeAssistantView):
|
||||
"""View to handle analytics devices payload download requests."""
|
||||
|
||||
url = "/api/analytics/devices"
|
||||
name = "api:analytics:devices"
|
||||
|
||||
@require_admin
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Return analytics devices payload as JSON."""
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
payload = await async_devices_payload(hass)
|
||||
return self.json(
|
||||
payload,
|
||||
headers={
|
||||
"Content-Disposition": "attachment; filename=analytics_devices.json"
|
||||
},
|
||||
)
|
@ -3,7 +3,7 @@
|
||||
"name": "Analytics",
|
||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
||||
"codeowners": ["@home-assistant/core", "@ludeeus"],
|
||||
"dependencies": ["api", "websocket_api"],
|
||||
"dependencies": ["api", "websocket_api", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
|
@ -10,7 +10,7 @@
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"cannot_receive_deviceinfo": "Failed to retrieve MAC Address. Make sure the device is turned on"
|
||||
"cannot_receive_deviceinfo": "Failed to retrieve MAC address. Make sure the device is turned on"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
|
@ -311,11 +311,13 @@ def _create_token_stats(
|
||||
class AnthropicBaseLLMEntity(Entity):
|
||||
"""Anthropic base LLM entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
self._attr_name = subentry.title
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
|
@ -28,5 +28,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==8.10.0", "yalexs-ble==3.0.0"]
|
||||
"requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.0"]
|
||||
}
|
||||
|
@ -93,7 +93,7 @@
|
||||
}
|
||||
},
|
||||
"preset1": {
|
||||
"name": "Favourite 1",
|
||||
"name": "Favorite 1",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
@ -107,7 +107,7 @@
|
||||
}
|
||||
},
|
||||
"preset2": {
|
||||
"name": "Favourite 2",
|
||||
"name": "Favorite 2",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
@ -121,7 +121,7 @@
|
||||
}
|
||||
},
|
||||
"preset3": {
|
||||
"name": "Favourite 3",
|
||||
"name": "Favorite 3",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
@ -135,7 +135,7 @@
|
||||
}
|
||||
},
|
||||
"preset4": {
|
||||
"name": "Favourite 4",
|
||||
"name": "Favorite 4",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
|
@ -15,23 +15,31 @@ from bluecurrent_api.exceptions import (
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform
|
||||
from homeassistant.const import CONF_API_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE
|
||||
from .const import (
|
||||
CHARGEPOINT_SETTINGS,
|
||||
CHARGEPOINT_STATUS,
|
||||
DOMAIN,
|
||||
EVSE_ID,
|
||||
LOGGER,
|
||||
PLUG_AND_CHARGE,
|
||||
VALUE,
|
||||
)
|
||||
|
||||
type BlueCurrentConfigEntry = ConfigEntry[Connector]
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
|
||||
CHARGE_POINTS = "CHARGE_POINTS"
|
||||
DATA = "data"
|
||||
DELAY = 5
|
||||
|
||||
GRID = "GRID"
|
||||
OBJECT = "object"
|
||||
VALUE_TYPES = ["CH_STATUS"]
|
||||
VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@ -94,7 +102,7 @@ class Connector:
|
||||
elif object_name in VALUE_TYPES:
|
||||
value_data: dict = message[DATA]
|
||||
evse_id = value_data.pop(EVSE_ID)
|
||||
self.update_charge_point(evse_id, value_data)
|
||||
self.update_charge_point(evse_id, object_name, value_data)
|
||||
|
||||
# gets grid key / values
|
||||
elif GRID in object_name:
|
||||
@ -106,26 +114,37 @@ class Connector:
|
||||
"""Handle incoming chargepoint data."""
|
||||
await asyncio.gather(
|
||||
*(
|
||||
self.handle_charge_point(
|
||||
entry[EVSE_ID], entry[MODEL_TYPE], entry[ATTR_NAME]
|
||||
)
|
||||
self.handle_charge_point(entry[EVSE_ID], entry)
|
||||
for entry in charge_points_data
|
||||
),
|
||||
self.client.get_grid_status(charge_points_data[0][EVSE_ID]),
|
||||
)
|
||||
|
||||
async def handle_charge_point(self, evse_id: str, model: str, name: str) -> None:
|
||||
async def handle_charge_point(
|
||||
self, evse_id: str, charge_point: dict[str, Any]
|
||||
) -> None:
|
||||
"""Add the chargepoint and request their data."""
|
||||
self.add_charge_point(evse_id, model, name)
|
||||
self.add_charge_point(evse_id, charge_point)
|
||||
await self.client.get_status(evse_id)
|
||||
|
||||
def add_charge_point(self, evse_id: str, model: str, name: str) -> None:
|
||||
def add_charge_point(self, evse_id: str, charge_point: dict[str, Any]) -> None:
|
||||
"""Add a charge point to charge_points."""
|
||||
self.charge_points[evse_id] = {MODEL_TYPE: model, ATTR_NAME: name}
|
||||
self.charge_points[evse_id] = charge_point
|
||||
|
||||
def update_charge_point(self, evse_id: str, data: dict) -> None:
|
||||
def update_charge_point(self, evse_id: str, update_type: str, data: dict) -> None:
|
||||
"""Update the charge point data."""
|
||||
self.charge_points[evse_id].update(data)
|
||||
charge_point = self.charge_points[evse_id]
|
||||
if update_type == CHARGEPOINT_SETTINGS:
|
||||
# Update the plug and charge object. The library parses this object to a bool instead of an object.
|
||||
plug_and_charge = charge_point.get(PLUG_AND_CHARGE)
|
||||
if plug_and_charge is not None:
|
||||
plug_and_charge[VALUE] = data[PLUG_AND_CHARGE]
|
||||
|
||||
# Remove the plug and charge object from the data list before updating.
|
||||
del data[PLUG_AND_CHARGE]
|
||||
|
||||
charge_point.update(data)
|
||||
|
||||
self.dispatch_charge_point_update_signal(evse_id)
|
||||
|
||||
def dispatch_charge_point_update_signal(self, evse_id: str) -> None:
|
||||
|
@ -8,3 +8,14 @@ LOGGER = logging.getLogger(__package__)
|
||||
|
||||
EVSE_ID = "evse_id"
|
||||
MODEL_TYPE = "model_type"
|
||||
PLUG_AND_CHARGE = "plug_and_charge"
|
||||
VALUE = "value"
|
||||
PERMISSION = "permission"
|
||||
CHARGEPOINT_STATUS = "CH_STATUS"
|
||||
CHARGEPOINT_SETTINGS = "CH_SETTINGS"
|
||||
BLOCK = "block"
|
||||
UNAVAILABLE = "unavailable"
|
||||
AVAILABLE = "available"
|
||||
LINKED_CHARGE_CARDS = "linked_charge_cards_only"
|
||||
PUBLIC_CHARGING = "public_charging"
|
||||
ACTIVITY = "activity"
|
||||
|
@ -30,6 +30,17 @@
|
||||
"stop_charge_session": {
|
||||
"default": "mdi:stop"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"plug_and_charge": {
|
||||
"default": "mdi:ev-plug-type2"
|
||||
},
|
||||
"linked_charge_cards": {
|
||||
"default": "mdi:account-group"
|
||||
},
|
||||
"block": {
|
||||
"default": "mdi:lock"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -124,6 +124,17 @@
|
||||
"reset": {
|
||||
"name": "Reset"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"plug_and_charge": {
|
||||
"name": "Plug & Charge"
|
||||
},
|
||||
"linked_charge_cards_only": {
|
||||
"name": "Linked charging cards only"
|
||||
},
|
||||
"block": {
|
||||
"name": "Block charge point"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
169
homeassistant/components/blue_current/switch.py
Normal file
169
homeassistant/components/blue_current/switch.py
Normal file
@ -0,0 +1,169 @@
|
||||
"""Support for Blue Current switches."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PLUG_AND_CHARGE, BlueCurrentConfigEntry, Connector
|
||||
from .const import (
|
||||
AVAILABLE,
|
||||
BLOCK,
|
||||
LINKED_CHARGE_CARDS,
|
||||
PUBLIC_CHARGING,
|
||||
UNAVAILABLE,
|
||||
VALUE,
|
||||
)
|
||||
from .entity import ChargepointEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class BlueCurrentSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Describes a Blue Current switch entity."""
|
||||
|
||||
function: Callable[[Connector, str, bool], Any]
|
||||
|
||||
turn_on_off_fn: Callable[[str, Connector], tuple[bool, bool]]
|
||||
"""Update the switch based on the latest data received from the websocket. The first returned boolean is _attr_is_on, the second one has_value."""
|
||||
|
||||
|
||||
def update_on_value_and_activity(
|
||||
key: str, evse_id: str, connector: Connector, reverse_is_on: bool = False
|
||||
) -> tuple[bool, bool]:
|
||||
"""Return the updated state of the switch based on received chargepoint data and activity."""
|
||||
|
||||
data_object = connector.charge_points[evse_id].get(key)
|
||||
is_on = data_object[VALUE] if data_object is not None else None
|
||||
activity = connector.charge_points[evse_id].get("activity")
|
||||
|
||||
if is_on is not None and activity == AVAILABLE:
|
||||
return is_on if not reverse_is_on else not is_on, True
|
||||
return False, False
|
||||
|
||||
|
||||
def update_block_switch(evse_id: str, connector: Connector) -> tuple[bool, bool]:
|
||||
"""Return the updated data for a block switch."""
|
||||
activity = connector.charge_points[evse_id].get("activity")
|
||||
return activity == UNAVAILABLE, activity in [AVAILABLE, UNAVAILABLE]
|
||||
|
||||
|
||||
def update_charge_point(
|
||||
key: str, evse_id: str, connector: Connector, new_switch_value: bool
|
||||
) -> None:
|
||||
"""Change charge point data when the state of the switch changes."""
|
||||
data_objects = connector.charge_points[evse_id].get(key)
|
||||
if data_objects is not None:
|
||||
data_objects[VALUE] = new_switch_value
|
||||
|
||||
|
||||
async def set_plug_and_charge(connector: Connector, evse_id: str, value: bool) -> None:
|
||||
"""Toggle the plug and charge setting for a specific charging point."""
|
||||
await connector.client.set_plug_and_charge(evse_id, value)
|
||||
update_charge_point(PLUG_AND_CHARGE, evse_id, connector, value)
|
||||
|
||||
|
||||
async def set_linked_charge_cards(
|
||||
connector: Connector, evse_id: str, value: bool
|
||||
) -> None:
|
||||
"""Toggle the plug and charge setting for a specific charging point."""
|
||||
await connector.client.set_linked_charge_cards_only(evse_id, value)
|
||||
update_charge_point(PUBLIC_CHARGING, evse_id, connector, not value)
|
||||
|
||||
|
||||
SWITCHES = (
|
||||
BlueCurrentSwitchEntityDescription(
|
||||
key=PLUG_AND_CHARGE,
|
||||
translation_key=PLUG_AND_CHARGE,
|
||||
function=set_plug_and_charge,
|
||||
turn_on_off_fn=lambda evse_id, connector: (
|
||||
update_on_value_and_activity(PLUG_AND_CHARGE, evse_id, connector)
|
||||
),
|
||||
),
|
||||
BlueCurrentSwitchEntityDescription(
|
||||
key=LINKED_CHARGE_CARDS,
|
||||
translation_key=LINKED_CHARGE_CARDS,
|
||||
function=set_linked_charge_cards,
|
||||
turn_on_off_fn=lambda evse_id, connector: (
|
||||
update_on_value_and_activity(
|
||||
PUBLIC_CHARGING, evse_id, connector, reverse_is_on=True
|
||||
)
|
||||
),
|
||||
),
|
||||
BlueCurrentSwitchEntityDescription(
|
||||
key=BLOCK,
|
||||
translation_key=BLOCK,
|
||||
function=lambda connector, evse_id, value: connector.client.block(
|
||||
evse_id, value
|
||||
),
|
||||
turn_on_off_fn=update_block_switch,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: BlueCurrentConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Blue Current switches."""
|
||||
connector = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
ChargePointSwitch(
|
||||
connector,
|
||||
evse_id,
|
||||
switch,
|
||||
)
|
||||
for evse_id in connector.charge_points
|
||||
for switch in SWITCHES
|
||||
)
|
||||
|
||||
|
||||
class ChargePointSwitch(ChargepointEntity, SwitchEntity):
|
||||
"""Base charge point switch."""
|
||||
|
||||
has_value = True
|
||||
entity_description: BlueCurrentSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connector: Connector,
|
||||
evse_id: str,
|
||||
switch: BlueCurrentSwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(connector, evse_id)
|
||||
|
||||
self.key = switch.key
|
||||
self.entity_description = switch
|
||||
self.evse_id = evse_id
|
||||
self._attr_available = True
|
||||
self._attr_unique_id = f"{switch.key}_{evse_id}"
|
||||
|
||||
async def call_function(self, value: bool) -> None:
|
||||
"""Call the function to set setting."""
|
||||
await self.entity_description.function(self.connector, self.evse_id, value)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
await self.call_function(True)
|
||||
self._attr_is_on = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
await self.call_function(False)
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def update_from_latest_data(self) -> None:
|
||||
"""Fetch new state data for the switch."""
|
||||
new_state = self.entity_description.turn_on_off_fn(self.evse_id, self.connector)
|
||||
self._attr_is_on = new_state[0]
|
||||
self.has_value = new_state[1]
|
@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.0.0",
|
||||
"bluetooth-auto-recovery==1.5.2",
|
||||
"bluetooth-data-tools==1.28.2",
|
||||
"dbus-fast==2.43.0",
|
||||
"dbus-fast==2.44.2",
|
||||
"habluetooth==4.0.1"
|
||||
]
|
||||
}
|
||||
|
@ -10,14 +10,8 @@ import random
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
from hass_nabucasa import Cloud, CloudError
|
||||
from hass_nabucasa.api import CloudApiError, CloudApiNonRetryableError
|
||||
from hass_nabucasa.cloud_api import (
|
||||
FilesHandlerListEntry,
|
||||
async_files_delete_file,
|
||||
async_files_list,
|
||||
)
|
||||
from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5
|
||||
from hass_nabucasa import Cloud, CloudApiError, CloudApiNonRetryableError, CloudError
|
||||
from hass_nabucasa.files import FilesError, StorageType, StoredFile, calculate_b64md5
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
AgentBackup,
|
||||
@ -186,8 +180,7 @@ class CloudBackupAgent(BackupAgent):
|
||||
"""
|
||||
backup = await self._async_get_backup(backup_id)
|
||||
try:
|
||||
await async_files_delete_file(
|
||||
self._cloud,
|
||||
await self._cloud.files.delete(
|
||||
storage_type=StorageType.BACKUP,
|
||||
filename=backup["Key"],
|
||||
)
|
||||
@ -199,12 +192,10 @@ class CloudBackupAgent(BackupAgent):
|
||||
backups = await self._async_list_backups()
|
||||
return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups]
|
||||
|
||||
async def _async_list_backups(self) -> list[FilesHandlerListEntry]:
|
||||
async def _async_list_backups(self) -> list[StoredFile]:
|
||||
"""List backups."""
|
||||
try:
|
||||
backups = await async_files_list(
|
||||
self._cloud, storage_type=StorageType.BACKUP
|
||||
)
|
||||
backups = await self._cloud.files.list(storage_type=StorageType.BACKUP)
|
||||
except (ClientError, CloudError) as err:
|
||||
raise BackupAgentError("Failed to list backups") from err
|
||||
|
||||
@ -220,7 +211,7 @@ class CloudBackupAgent(BackupAgent):
|
||||
backup = await self._async_get_backup(backup_id)
|
||||
return AgentBackup.from_dict(backup["Metadata"])
|
||||
|
||||
async def _async_get_backup(self, backup_id: str) -> FilesHandlerListEntry:
|
||||
async def _async_get_backup(self, backup_id: str) -> StoredFile:
|
||||
"""Return a backup."""
|
||||
backups = await self._async_list_backups()
|
||||
|
||||
|
@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==0.108.0"],
|
||||
"requirements": ["hass-nabucasa==0.110.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@ -17,6 +17,8 @@ from homeassistant.components.tts import (
|
||||
PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA,
|
||||
Provider,
|
||||
TextToSpeechEntity,
|
||||
TTSAudioRequest,
|
||||
TTSAudioResponse,
|
||||
TtsAudioType,
|
||||
Voice,
|
||||
)
|
||||
@ -332,7 +334,7 @@ class CloudTTSEntity(TextToSpeechEntity):
|
||||
def default_options(self) -> dict[str, str]:
|
||||
"""Return a dict include default options."""
|
||||
return {
|
||||
ATTR_AUDIO_OUTPUT: AudioOutput.MP3,
|
||||
ATTR_AUDIO_OUTPUT: AudioOutput.MP3.value,
|
||||
}
|
||||
|
||||
@property
|
||||
@ -433,6 +435,29 @@ class CloudTTSEntity(TextToSpeechEntity):
|
||||
|
||||
return (options[ATTR_AUDIO_OUTPUT], data)
|
||||
|
||||
async def async_stream_tts_audio(
|
||||
self, request: TTSAudioRequest
|
||||
) -> TTSAudioResponse:
|
||||
"""Generate speech from an incoming message."""
|
||||
data_gen = self.cloud.voice.process_tts_stream(
|
||||
text_stream=request.message_gen,
|
||||
**_prepare_voice_args(
|
||||
hass=self.hass,
|
||||
language=request.language,
|
||||
voice=request.options.get(
|
||||
ATTR_VOICE,
|
||||
(
|
||||
self._voice
|
||||
if request.language == self._language
|
||||
else DEFAULT_VOICES[request.language]
|
||||
),
|
||||
),
|
||||
gender=request.options.get(ATTR_GENDER),
|
||||
),
|
||||
)
|
||||
|
||||
return TTSAudioResponse(AudioOutput.WAV.value, data_gen)
|
||||
|
||||
|
||||
class CloudProvider(Provider):
|
||||
"""Home Assistant Cloud speech API provider."""
|
||||
@ -526,9 +551,11 @@ class CloudProvider(Provider):
|
||||
language=language,
|
||||
voice=options.get(
|
||||
ATTR_VOICE,
|
||||
(
|
||||
self._voice
|
||||
if language == self._language
|
||||
else DEFAULT_VOICES[language],
|
||||
else DEFAULT_VOICES[language]
|
||||
),
|
||||
),
|
||||
gender=options.get(ATTR_GENDER),
|
||||
),
|
||||
|
@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/compensation",
|
||||
"iot_class": "calculated",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["numpy==2.3.0"]
|
||||
"requirements": ["numpy==2.3.2"]
|
||||
}
|
||||
|
@ -2,9 +2,10 @@
|
||||
|
||||
import logging
|
||||
|
||||
from datadog import initialize, statsd
|
||||
from datadog import DogStatsd, initialize
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PORT,
|
||||
@ -17,14 +18,19 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, state as state_helper
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import config_flow as config_flow
|
||||
from .const import (
|
||||
CONF_RATE,
|
||||
DEFAULT_HOST,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_PREFIX,
|
||||
DEFAULT_RATE,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_RATE = "rate"
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_PORT = 8125
|
||||
DEFAULT_PREFIX = "hass"
|
||||
DEFAULT_RATE = 1
|
||||
DOMAIN = "datadog"
|
||||
type DatadogConfigEntry = ConfigEntry[DogStatsd]
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
@ -43,63 +49,85 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Datadog component."""
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Datadog integration from YAML, initiating config flow import."""
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
|
||||
conf = config[DOMAIN]
|
||||
host = conf[CONF_HOST]
|
||||
port = conf[CONF_PORT]
|
||||
sample_rate = conf[CONF_RATE]
|
||||
prefix = conf[CONF_PREFIX]
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=config[DOMAIN],
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> bool:
|
||||
"""Set up Datadog from a config entry."""
|
||||
|
||||
data = entry.data
|
||||
options = entry.options
|
||||
host = data[CONF_HOST]
|
||||
port = data[CONF_PORT]
|
||||
prefix = options[CONF_PREFIX]
|
||||
sample_rate = options[CONF_RATE]
|
||||
|
||||
statsd_client = DogStatsd(host=host, port=port, namespace=prefix)
|
||||
entry.runtime_data = statsd_client
|
||||
|
||||
initialize(statsd_host=host, statsd_port=port)
|
||||
|
||||
def logbook_entry_listener(event):
|
||||
"""Listen for logbook entries and send them as events."""
|
||||
name = event.data.get("name")
|
||||
message = event.data.get("message")
|
||||
|
||||
statsd.event(
|
||||
entry.runtime_data.event(
|
||||
title="Home Assistant",
|
||||
text=f"%%% \n **{name}** {message} \n %%%",
|
||||
message=f"%%% \n **{name}** {message} \n %%%",
|
||||
tags=[
|
||||
f"entity:{event.data.get('entity_id')}",
|
||||
f"domain:{event.data.get('domain')}",
|
||||
],
|
||||
)
|
||||
|
||||
_LOGGER.debug("Sent event %s", event.data.get("entity_id"))
|
||||
|
||||
def state_changed_listener(event):
|
||||
"""Listen for new messages on the bus and sends them to Datadog."""
|
||||
state = event.data.get("new_state")
|
||||
|
||||
if state is None or state.state == STATE_UNKNOWN:
|
||||
return
|
||||
|
||||
states = dict(state.attributes)
|
||||
metric = f"{prefix}.{state.domain}"
|
||||
tags = [f"entity:{state.entity_id}"]
|
||||
|
||||
for key, value in states.items():
|
||||
if isinstance(value, (float, int)):
|
||||
attribute = f"{metric}.{key.replace(' ', '_')}"
|
||||
for key, value in state.attributes.items():
|
||||
if isinstance(value, (float, int, bool)):
|
||||
value = int(value) if isinstance(value, bool) else value
|
||||
statsd.gauge(attribute, value, sample_rate=sample_rate, tags=tags)
|
||||
|
||||
_LOGGER.debug("Sent metric %s: %s (tags: %s)", attribute, value, tags)
|
||||
attribute = f"{metric}.{key.replace(' ', '_')}"
|
||||
entry.runtime_data.gauge(
|
||||
attribute, value, sample_rate=sample_rate, tags=tags
|
||||
)
|
||||
|
||||
try:
|
||||
value = state_helper.state_as_number(state)
|
||||
entry.runtime_data.gauge(metric, value, sample_rate=sample_rate, tags=tags)
|
||||
except ValueError:
|
||||
_LOGGER.debug("Error sending %s: %s (tags: %s)", metric, state.state, tags)
|
||||
return
|
||||
pass
|
||||
|
||||
statsd.gauge(metric, value, sample_rate=sample_rate, tags=tags)
|
||||
|
||||
_LOGGER.debug("Sent metric %s: %s (tags: %s)", metric, value, tags)
|
||||
|
||||
hass.bus.listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener)
|
||||
hass.bus.listen(EVENT_STATE_CHANGED, state_changed_listener)
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener)
|
||||
)
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed_listener)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> bool:
|
||||
"""Unload a Datadog config entry."""
|
||||
runtime = entry.runtime_data
|
||||
runtime.flush()
|
||||
runtime.close_socket()
|
||||
return True
|
||||
|
185
homeassistant/components/datadog/config_flow.py
Normal file
185
homeassistant/components/datadog/config_flow.py
Normal file
@ -0,0 +1,185 @@
|
||||
"""Config flow for Datadog."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from datadog import DogStatsd
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PREFIX
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
|
||||
from .const import (
|
||||
CONF_RATE,
|
||||
DEFAULT_HOST,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_PREFIX,
|
||||
DEFAULT_RATE,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
class DatadogConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Datadog."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle user config flow."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input:
|
||||
self._async_abort_entries_match(
|
||||
{CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
|
||||
)
|
||||
# Validate connection to Datadog Agent
|
||||
success = await validate_datadog_connection(
|
||||
self.hass,
|
||||
user_input,
|
||||
)
|
||||
if not success:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=f"Datadog {user_input['host']}",
|
||||
data={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
},
|
||||
options={
|
||||
CONF_PREFIX: user_input[CONF_PREFIX],
|
||||
CONF_RATE: user_input[CONF_RATE],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
vol.Required(CONF_PREFIX, default=DEFAULT_PREFIX): str,
|
||||
vol.Required(CONF_RATE, default=DEFAULT_RATE): int,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle import from configuration.yaml."""
|
||||
# Check for duplicates
|
||||
self._async_abort_entries_match(
|
||||
{CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
|
||||
)
|
||||
|
||||
result = await self.async_step_user(user_input)
|
||||
|
||||
if errors := result.get("errors"):
|
||||
await deprecate_yaml_issue(self.hass, False)
|
||||
return self.async_abort(reason=errors["base"])
|
||||
|
||||
await deprecate_yaml_issue(self.hass, True)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
"""Get the options flow handler."""
|
||||
return DatadogOptionsFlowHandler()
|
||||
|
||||
|
||||
class DatadogOptionsFlowHandler(OptionsFlow):
|
||||
"""Handle Datadog options."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the Datadog options."""
|
||||
errors: dict[str, str] = {}
|
||||
data = self.config_entry.data
|
||||
options = self.config_entry.options
|
||||
|
||||
if user_input is None:
|
||||
user_input = {}
|
||||
|
||||
success = await validate_datadog_connection(
|
||||
self.hass,
|
||||
{**data, **user_input},
|
||||
)
|
||||
if success:
|
||||
return self.async_create_entry(
|
||||
data={
|
||||
CONF_PREFIX: user_input[CONF_PREFIX],
|
||||
CONF_RATE: user_input[CONF_RATE],
|
||||
}
|
||||
)
|
||||
|
||||
errors["base"] = "cannot_connect"
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PREFIX, default=options[CONF_PREFIX]): str,
|
||||
vol.Required(CONF_RATE, default=options[CONF_RATE]): int,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
async def validate_datadog_connection(
|
||||
hass: HomeAssistant, user_input: dict[str, Any]
|
||||
) -> bool:
|
||||
"""Attempt to send a test metric to the Datadog agent."""
|
||||
try:
|
||||
client = DogStatsd(user_input[CONF_HOST], user_input[CONF_PORT])
|
||||
await hass.async_add_executor_job(client.increment, "connection_test")
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
async def deprecate_yaml_issue(
|
||||
hass: HomeAssistant,
|
||||
import_success: bool,
|
||||
) -> None:
|
||||
"""Create an issue to deprecate YAML config."""
|
||||
if import_success:
|
||||
async_create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
breaks_in_ha_version="2026.2.0",
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Datadog",
|
||||
},
|
||||
)
|
||||
else:
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_yaml_import_connection_error",
|
||||
breaks_in_ha_version="2026.2.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml_import_connection_error",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Datadog",
|
||||
"url": f"/config/integrations/dashboard/add?domain={DOMAIN}",
|
||||
},
|
||||
)
|
10
homeassistant/components/datadog/const.py
Normal file
10
homeassistant/components/datadog/const.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""Constants for the Datadog integration."""
|
||||
|
||||
DOMAIN = "datadog"
|
||||
|
||||
CONF_RATE = "rate"
|
||||
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_PORT = 8125
|
||||
DEFAULT_PREFIX = "hass"
|
||||
DEFAULT_RATE = 1
|
@ -2,6 +2,7 @@
|
||||
"domain": "datadog",
|
||||
"name": "Datadog",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/datadog",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["datadog"],
|
||||
|
56
homeassistant/components/datadog/strings.json
Normal file
56
homeassistant/components/datadog/strings.json
Normal file
@ -0,0 +1,56 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Enter your Datadog Agent's address and port.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"prefix": "Prefix",
|
||||
"rate": "Rate"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the Datadog Agent.",
|
||||
"port": "Port the Datadog Agent is listening on",
|
||||
"prefix": "Metric prefix to use",
|
||||
"rate": "The sample rate of UDP packets sent to Datadog."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Update the Datadog configuration.",
|
||||
"data": {
|
||||
"prefix": "[%key:component::datadog::config::step::user::data::prefix%]",
|
||||
"rate": "[%key:component::datadog::config::step::user::data::rate%]"
|
||||
},
|
||||
"data_description": {
|
||||
"prefix": "[%key:component::datadog::config::step::user::data_description::prefix%]",
|
||||
"rate": "[%key:component::datadog::config::step::user::data_description::rate%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_import_connection_error": {
|
||||
"title": "{domain} YAML configuration import failed",
|
||||
"description": "There was an error connecting to the Datadog Agent when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the {domain} configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually."
|
||||
}
|
||||
}
|
||||
}
|
@ -7,45 +7,39 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from . import configure_mydevolo
|
||||
from .const import DOMAIN, SUPPORTED_MODEL_TYPES
|
||||
from .exceptions import CredentialsInvalid, UuidChanged
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
|
||||
)
|
||||
|
||||
|
||||
class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a devolo HomeControl config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
_reauth_entry: ConfigEntry
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize devolo Home Control flow."""
|
||||
self.data_schema = {
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
if user_input is None:
|
||||
return self._show_form(step_id="user")
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
return await self._connect_mydevolo(user_input)
|
||||
except CredentialsInvalid:
|
||||
return self._show_form(step_id="user", errors={"base": "invalid_auth"})
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
@ -61,41 +55,46 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initiated by zeroconf."""
|
||||
if user_input is None:
|
||||
return self._show_form(step_id="zeroconf_confirm")
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
return await self._connect_mydevolo(user_input)
|
||||
except CredentialsInvalid:
|
||||
return self._show_form(
|
||||
step_id="zeroconf_confirm", errors={"base": "invalid_auth"}
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="zeroconf_confirm", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication."""
|
||||
self._reauth_entry = self._get_reauth_entry()
|
||||
self.data_schema = {
|
||||
vol.Required(CONF_USERNAME, default=entry_data[CONF_USERNAME]): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initiated by reauthentication."""
|
||||
if user_input is None:
|
||||
return self._show_form(step_id="reauth_confirm")
|
||||
errors: dict[str, str] = {}
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME, default=self.init_data[CONF_USERNAME]): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
return await self._connect_mydevolo(user_input)
|
||||
except CredentialsInvalid:
|
||||
return self._show_form(
|
||||
step_id="reauth_confirm", errors={"base": "invalid_auth"}
|
||||
)
|
||||
errors["base"] = "invalid_auth"
|
||||
except UuidChanged:
|
||||
return self._show_form(
|
||||
step_id="reauth_confirm", errors={"base": "reauth_failed"}
|
||||
errors["base"] = "reauth_failed"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm", data_schema=data_schema, errors=errors
|
||||
)
|
||||
|
||||
async def _connect_mydevolo(self, user_input: dict[str, Any]) -> ConfigFlowResult:
|
||||
@ -119,21 +118,11 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
if self._reauth_entry.unique_id != uuid:
|
||||
if self.unique_id != uuid:
|
||||
# The old user and the new user are not the same. This could mess-up everything as all unique IDs might change.
|
||||
raise UuidChanged
|
||||
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._reauth_entry, data=user_input, unique_id=uuid
|
||||
)
|
||||
|
||||
@callback
|
||||
def _show_form(
|
||||
self, step_id: str, errors: dict[str, str] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show the form to the user."""
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
data_schema=vol.Schema(self.data_schema),
|
||||
errors=errors if errors else {},
|
||||
reauth_entry, data=user_input, unique_id=uuid
|
||||
)
|
||||
|
@ -61,7 +61,7 @@
|
||||
"message": "Failed to connect to devolo Home Control central unit {gateway_id}."
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Authentication failed. Please re-authenticaticate with your mydevolo account."
|
||||
"message": "Authentication failed. Please re-authenticate with your mydevolo account."
|
||||
},
|
||||
"maintenance": {
|
||||
"message": "devolo Home Control is currently in maintenance mode."
|
||||
|
@ -8,6 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["devolo_plc_api"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["devolo-plc-api==1.5.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
|
@ -0,0 +1,84 @@
|
||||
rules:
|
||||
# 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: done
|
||||
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: |
|
||||
Entities of this 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
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have an options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
A change of the IP address is covered by discovery-update-info and a change of the password is covered by reauthentication-flow. No other configuration options are available.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration doesn't have any cases where raising an issue is needed.
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: |
|
||||
The tracked devices could be own devices with a manual delete option as the API cannot distinguish between stale devices and devices that are not home.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
@ -105,7 +105,7 @@
|
||||
"message": "Device {title} did not respond"
|
||||
},
|
||||
"password_protected": {
|
||||
"message": "Device {title} requires re-authenticatication to set or change the password"
|
||||
"message": "Device {title} requires re-authentication to set or change the password"
|
||||
},
|
||||
"password_wrong": {
|
||||
"message": "The used password is wrong"
|
||||
|
@ -2,11 +2,11 @@
|
||||
"services": {
|
||||
"order": {
|
||||
"name": "Order",
|
||||
"description": "Places a set of orders with Dominos Pizza.",
|
||||
"description": "Places a set of orders with Domino's Pizza.",
|
||||
"fields": {
|
||||
"order_entity_id": {
|
||||
"name": "Order entity",
|
||||
"description": "The ID (as specified in the configuration) of an order to place. If provided as an array, all of the identified orders will be placed."
|
||||
"description": "The ID (as specified in the configuration) of an order to place. If provided as an array, all the identified orders will be placed."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -222,7 +222,7 @@
|
||||
"data": {
|
||||
"time_between_update": "Minimum time between entity updates [s]"
|
||||
},
|
||||
"title": "DSMR Options"
|
||||
"title": "DSMR options"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -263,7 +263,7 @@
|
||||
"issues": {
|
||||
"cannot_subscribe_mqtt_topic": {
|
||||
"title": "Cannot subscribe to MQTT topic {topic_title}",
|
||||
"description": "The DSMR Reader integration cannot subscribe to the MQTT topic: `{topic}`. Please check the configuration of the MQTT broker and the topic.\nDSMR Reader needs to be running, before starting this integration."
|
||||
"description": "The DSMR Reader integration cannot subscribe to the MQTT topic: `{topic}`. Please check the configuration of the MQTT broker and the topic.\nDSMR Reader needs to be running before starting this integration."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,10 +4,12 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from deebot_client.capabilities import CapabilityEvent
|
||||
from deebot_client.events.base import Event
|
||||
from deebot_client.events import Event
|
||||
from deebot_client.events.water_info import MopAttachedEvent
|
||||
from sucks import VacBot
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
@ -16,7 +18,11 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EcovacsConfigEntry
|
||||
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity
|
||||
from .entity import (
|
||||
EcovacsCapabilityEntityDescription,
|
||||
EcovacsDescriptionEntity,
|
||||
EcovacsLegacyEntity,
|
||||
)
|
||||
from .util import get_supported_entities
|
||||
|
||||
|
||||
@ -47,12 +53,23 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add entities for passed config_entry in HA."""
|
||||
controller = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
get_supported_entities(
|
||||
config_entry.runtime_data, EcovacsBinarySensor, ENTITY_DESCRIPTIONS
|
||||
)
|
||||
)
|
||||
|
||||
legacy_entities = []
|
||||
for device in controller.legacy_devices:
|
||||
if not controller.legacy_entity_is_added(device, "battery_charging"):
|
||||
controller.add_legacy_entity(device, "battery_charging")
|
||||
legacy_entities.append(EcovacsLegacyBatteryChargingSensor(device))
|
||||
|
||||
if legacy_entities:
|
||||
async_add_entities(legacy_entities)
|
||||
|
||||
|
||||
class EcovacsBinarySensor[EventT: Event](
|
||||
EcovacsDescriptionEntity[CapabilityEvent[EventT]],
|
||||
@ -71,3 +88,33 @@ class EcovacsBinarySensor[EventT: Event](
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._subscribe(self._capability.event, on_event)
|
||||
|
||||
|
||||
class EcovacsLegacyBatteryChargingSensor(EcovacsLegacyEntity, BinarySensorEntity):
|
||||
"""Legacy battery charging sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: VacBot,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(device)
|
||||
self._attr_unique_id = f"{device.vacuum['did']}_battery_charging"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Set up the event listeners now that hass is ready."""
|
||||
self._event_listeners.append(
|
||||
self.device.statusEvents.subscribe(
|
||||
lambda _: self.schedule_update_ha_state()
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
if self.device.charge_status is None:
|
||||
return None
|
||||
return bool(self.device.is_charging)
|
||||
|
@ -37,6 +37,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.icon import icon_for_battery_level
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import EcovacsConfigEntry
|
||||
@ -225,7 +226,7 @@ async def async_setup_entry(
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
async def _add_legacy_entities() -> None:
|
||||
async def _add_legacy_lifespan_entities() -> None:
|
||||
entities = []
|
||||
for device in controller.legacy_devices:
|
||||
for description in LEGACY_LIFESPAN_SENSORS:
|
||||
@ -242,14 +243,21 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
def _fire_ecovacs_legacy_lifespan_event(_: Any) -> None:
|
||||
hass.create_task(_add_legacy_entities())
|
||||
hass.create_task(_add_legacy_lifespan_entities())
|
||||
|
||||
legacy_entities = []
|
||||
for device in controller.legacy_devices:
|
||||
config_entry.async_on_unload(
|
||||
device.lifespanEvents.subscribe(
|
||||
_fire_ecovacs_legacy_lifespan_event
|
||||
).unsubscribe
|
||||
)
|
||||
if not controller.legacy_entity_is_added(device, "battery_status"):
|
||||
controller.add_legacy_entity(device, "battery_status")
|
||||
legacy_entities.append(EcovacsLegacyBatterySensor(device))
|
||||
|
||||
if legacy_entities:
|
||||
async_add_entities(legacy_entities)
|
||||
|
||||
|
||||
class EcovacsSensor(
|
||||
@ -344,6 +352,44 @@ class EcovacsErrorSensor(
|
||||
self._subscribe(self._capability.event, on_event)
|
||||
|
||||
|
||||
class EcovacsLegacyBatterySensor(EcovacsLegacyEntity, SensorEntity):
|
||||
"""Legacy battery sensor."""
|
||||
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_device_class = SensorDeviceClass.BATTERY
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: VacBot,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(device)
|
||||
self._attr_unique_id = f"{device.vacuum['did']}_battery_status"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Set up the event listeners now that hass is ready."""
|
||||
self._event_listeners.append(
|
||||
self.device.batteryEvents.subscribe(
|
||||
lambda _: self.schedule_update_ha_state()
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value reported by the sensor."""
|
||||
if (status := self.device.battery_status) is not None:
|
||||
return status * 100 # type: ignore[no-any-return]
|
||||
return None
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
"""Return the icon to use in the frontend, if any."""
|
||||
return icon_for_battery_level(
|
||||
battery_level=self.native_value, charging=self.device.is_charging
|
||||
)
|
||||
|
||||
|
||||
class EcovacsLegacyLifespanSensor(EcovacsLegacyEntity, SensorEntity):
|
||||
"""Legacy Lifespan sensor."""
|
||||
|
||||
|
@ -22,7 +22,6 @@ from homeassistant.core import HomeAssistant, SupportsResponse
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.icon import icon_for_battery_level
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from . import EcovacsConfigEntry
|
||||
@ -71,8 +70,7 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
|
||||
|
||||
_attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH]
|
||||
_attr_supported_features = (
|
||||
VacuumEntityFeature.BATTERY
|
||||
| VacuumEntityFeature.RETURN_HOME
|
||||
VacuumEntityFeature.RETURN_HOME
|
||||
| VacuumEntityFeature.CLEAN_SPOT
|
||||
| VacuumEntityFeature.STOP
|
||||
| VacuumEntityFeature.START
|
||||
@ -89,11 +87,6 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
|
||||
lambda _: self.schedule_update_ha_state()
|
||||
)
|
||||
)
|
||||
self._event_listeners.append(
|
||||
self.device.batteryEvents.subscribe(
|
||||
lambda _: self.schedule_update_ha_state()
|
||||
)
|
||||
)
|
||||
self._event_listeners.append(
|
||||
self.device.lifespanEvents.subscribe(
|
||||
lambda _: self.schedule_update_ha_state()
|
||||
@ -137,21 +130,6 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def battery_level(self) -> int | None:
|
||||
"""Return the battery level of the vacuum cleaner."""
|
||||
if self.device.battery_status is not None:
|
||||
return self.device.battery_status * 100 # type: ignore[no-any-return]
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def battery_icon(self) -> str:
|
||||
"""Return the battery icon for the vacuum cleaner."""
|
||||
return icon_for_battery_level(
|
||||
battery_level=self.battery_level, charging=self.device.is_charging
|
||||
)
|
||||
|
||||
@property
|
||||
def fan_speed(self) -> str | None:
|
||||
"""Return the fan speed of the vacuum cleaner."""
|
||||
|
@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eheimdigital"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["eheimdigital==1.3.0"],
|
||||
"zeroconf": [
|
||||
{ "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }
|
||||
|
@ -46,22 +46,24 @@ rules:
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repairs.
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
|
@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==37.0.2",
|
||||
"aioesphomeapi==37.1.2",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.1.0"
|
||||
],
|
||||
|
@ -214,7 +214,7 @@
|
||||
"message": "Unable to establish a connection"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Error while uptaing the data: {error}"
|
||||
"message": "Error while updating the data: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ from homeassistant.components.climate import (
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
FAN_MEDIUM,
|
||||
FAN_OFF,
|
||||
SWING_BOTH,
|
||||
SWING_HORIZONTAL,
|
||||
SWING_OFF,
|
||||
@ -31,6 +32,7 @@ from .coordinator import FGLairConfigEntry, FGLairCoordinator
|
||||
from .entity import FGLairEntity
|
||||
|
||||
HA_TO_FUJI_FAN = {
|
||||
FAN_OFF: FanSpeed.QUIET,
|
||||
FAN_LOW: FanSpeed.LOW,
|
||||
FAN_MEDIUM: FanSpeed.MEDIUM,
|
||||
FAN_HIGH: FanSpeed.HIGH,
|
||||
|
@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/garages_amsterdam",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["odp-amsterdam==6.1.1"]
|
||||
"requirements": ["odp-amsterdam==6.1.2"]
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
import contextlib
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from errno import EHOSTUNREACH, EIO
|
||||
import io
|
||||
import logging
|
||||
@ -52,9 +52,8 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
from homeassistant.helpers import config_validation as cv, template as template_helper
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
from homeassistant.helpers.entity_platform import PlatformData
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.setup import async_prepare_setup_platform
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .camera import GenericCamera, generate_auth
|
||||
@ -569,18 +568,9 @@ async def ws_start_preview(
|
||||
)
|
||||
user_input = flow.preview_image_settings
|
||||
|
||||
# Create an EntityPlatform, needed for name translations
|
||||
platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN)
|
||||
entity_platform = EntityPlatform(
|
||||
hass=hass,
|
||||
logger=_LOGGER,
|
||||
domain=CAMERA_DOMAIN,
|
||||
platform_name=DOMAIN,
|
||||
platform=platform,
|
||||
scan_interval=timedelta(seconds=3600),
|
||||
entity_namespace=None,
|
||||
)
|
||||
await entity_platform.async_load_translations()
|
||||
# Create PlatformData, needed for name translations
|
||||
platform_data = PlatformData(hass=hass, domain=CAMERA_DOMAIN, platform_name=DOMAIN)
|
||||
await platform_data.async_load_translations()
|
||||
|
||||
ha_still_url = None
|
||||
ha_stream_url = None
|
||||
|
@ -12,7 +12,7 @@
|
||||
}
|
||||
},
|
||||
"confirm_discovery": {
|
||||
"description": "DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Refer to your router's user manual."
|
||||
"description": "DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new IP address. Refer to your router's user manual."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
@ -230,7 +230,7 @@ async def async_setup_entry(
|
||||
calendar_info = calendars[calendar_id]
|
||||
else:
|
||||
calendar_info = get_calendar_info(
|
||||
hass, calendar_item.dict(exclude_unset=True)
|
||||
hass, calendar_item.model_dump(exclude_unset=True)
|
||||
)
|
||||
new_calendars.append(calendar_info)
|
||||
|
||||
@ -467,7 +467,7 @@ class GoogleCalendarEntity(
|
||||
else:
|
||||
start = DateOrDatetime(date=dtstart)
|
||||
end = DateOrDatetime(date=dtend)
|
||||
event = Event.parse_obj(
|
||||
event = Event.model_validate(
|
||||
{
|
||||
EVENT_SUMMARY: kwargs[EVENT_SUMMARY],
|
||||
"start": start,
|
||||
@ -538,7 +538,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) ->
|
||||
|
||||
if EVENT_IN in call.data:
|
||||
if EVENT_IN_DAYS in call.data[EVENT_IN]:
|
||||
now = datetime.now()
|
||||
now = datetime.now().date()
|
||||
|
||||
start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS])
|
||||
end_in = start_in + timedelta(days=1)
|
||||
@ -547,7 +547,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) ->
|
||||
end = DateOrDatetime(date=end_in)
|
||||
|
||||
elif EVENT_IN_WEEKS in call.data[EVENT_IN]:
|
||||
now = datetime.now()
|
||||
now = datetime.now().date()
|
||||
|
||||
start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS])
|
||||
end_in = start_in + timedelta(days=1)
|
||||
|
@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.4"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.0.0"]
|
||||
}
|
||||
|
@ -186,3 +186,13 @@ STT_LANGUAGES = [
|
||||
"yue-Hant-HK",
|
||||
"zu-ZA",
|
||||
]
|
||||
|
||||
# This allows us to support HA's standard codes (e.g., zh-CN) while
|
||||
# sending the correct code to the Google API (e.g., cmn-Hans-CN).
|
||||
HA_TO_GOOGLE_STT_LANG_MAP = {
|
||||
"zh-CN": "cmn-Hans-CN", # Chinese (Mandarin, Simplified, China)
|
||||
"zh-HK": "yue-Hant-HK", # Chinese (Cantonese, Traditional, Hong Kong)
|
||||
"zh-TW": "cmn-Hant-TW", # Chinese (Mandarin, Traditional, Taiwan)
|
||||
"he-IL": "iw-IL", # Hebrew (Google uses 'iw' legacy code)
|
||||
"nb-NO": "no-NO", # Norwegian Bokmål
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import logging
|
||||
from google.api_core.exceptions import GoogleAPIError, Unauthenticated
|
||||
from google.api_core.retry import AsyncRetry
|
||||
from google.cloud import speech_v1
|
||||
from propcache.api import cached_property
|
||||
|
||||
from homeassistant.components.stt import (
|
||||
AudioBitRates,
|
||||
@ -30,6 +31,7 @@ from .const import (
|
||||
CONF_STT_MODEL,
|
||||
DEFAULT_STT_MODEL,
|
||||
DOMAIN,
|
||||
HA_TO_GOOGLE_STT_LANG_MAP,
|
||||
STT_LANGUAGES,
|
||||
)
|
||||
|
||||
@ -68,10 +70,14 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity):
|
||||
self._client = client
|
||||
self._model = entry.options.get(CONF_STT_MODEL, DEFAULT_STT_MODEL)
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def supported_languages(self) -> list[str]:
|
||||
"""Return a list of supported languages."""
|
||||
return STT_LANGUAGES
|
||||
# Combine the native Google languages and the standard HA languages.
|
||||
# A set is used to automatically handle duplicates.
|
||||
supported = set(STT_LANGUAGES)
|
||||
supported.update(HA_TO_GOOGLE_STT_LANG_MAP.keys())
|
||||
return sorted(supported)
|
||||
|
||||
@property
|
||||
def supported_formats(self) -> list[AudioFormats]:
|
||||
@ -102,6 +108,10 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity):
|
||||
self, metadata: SpeechMetadata, stream: AsyncIterable[bytes]
|
||||
) -> SpeechResult:
|
||||
"""Process an audio stream to STT service."""
|
||||
language_code = HA_TO_GOOGLE_STT_LANG_MAP.get(
|
||||
metadata.language, metadata.language
|
||||
)
|
||||
|
||||
streaming_config = speech_v1.StreamingRecognitionConfig(
|
||||
config=speech_v1.RecognitionConfig(
|
||||
encoding=(
|
||||
@ -110,7 +120,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity):
|
||||
else speech_v1.RecognitionConfig.AudioEncoding.LINEAR16
|
||||
),
|
||||
sample_rate_hertz=metadata.sample_rate,
|
||||
language_code=metadata.language,
|
||||
language_code=language_code,
|
||||
model=self._model,
|
||||
)
|
||||
)
|
||||
|
@ -53,103 +53,51 @@ class GoogleGenerativeAISttEntity(
|
||||
"""Return a list of supported languages."""
|
||||
return [
|
||||
"af-ZA",
|
||||
"sq-AL",
|
||||
"am-ET",
|
||||
"ar-DZ",
|
||||
"ar-AE",
|
||||
"ar-BH",
|
||||
"ar-DZ",
|
||||
"ar-EG",
|
||||
"ar-IQ",
|
||||
"ar-IL",
|
||||
"ar-IQ",
|
||||
"ar-JO",
|
||||
"ar-KW",
|
||||
"ar-LB",
|
||||
"ar-MA",
|
||||
"ar-OM",
|
||||
"ar-PS",
|
||||
"ar-QA",
|
||||
"ar-SA",
|
||||
"ar-PS",
|
||||
"ar-TN",
|
||||
"ar-AE",
|
||||
"ar-YE",
|
||||
"hy-AM",
|
||||
"az-AZ",
|
||||
"eu-ES",
|
||||
"bg-BG",
|
||||
"bn-BD",
|
||||
"bn-IN",
|
||||
"bs-BA",
|
||||
"bg-BG",
|
||||
"my-MM",
|
||||
"ca-ES",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"hr-HR",
|
||||
"cs-CZ",
|
||||
"da-DK",
|
||||
"nl-BE",
|
||||
"nl-NL",
|
||||
"de-AT",
|
||||
"de-CH",
|
||||
"de-DE",
|
||||
"el-GR",
|
||||
"en-AU",
|
||||
"en-CA",
|
||||
"en-GB",
|
||||
"en-GH",
|
||||
"en-HK",
|
||||
"en-IN",
|
||||
"en-IE",
|
||||
"en-IN",
|
||||
"en-KE",
|
||||
"en-NZ",
|
||||
"en-NG",
|
||||
"en-PK",
|
||||
"en-NZ",
|
||||
"en-PH",
|
||||
"en-PK",
|
||||
"en-SG",
|
||||
"en-ZA",
|
||||
"en-TZ",
|
||||
"en-GB",
|
||||
"en-US",
|
||||
"et-EE",
|
||||
"fil-PH",
|
||||
"fi-FI",
|
||||
"fr-BE",
|
||||
"fr-CA",
|
||||
"fr-FR",
|
||||
"fr-CH",
|
||||
"gl-ES",
|
||||
"ka-GE",
|
||||
"de-AT",
|
||||
"de-DE",
|
||||
"de-CH",
|
||||
"el-GR",
|
||||
"gu-IN",
|
||||
"iw-IL",
|
||||
"hi-IN",
|
||||
"hu-HU",
|
||||
"is-IS",
|
||||
"id-ID",
|
||||
"it-IT",
|
||||
"it-CH",
|
||||
"ja-JP",
|
||||
"jv-ID",
|
||||
"kn-IN",
|
||||
"kk-KZ",
|
||||
"km-KH",
|
||||
"ko-KR",
|
||||
"lo-LA",
|
||||
"lv-LV",
|
||||
"lt-LT",
|
||||
"mk-MK",
|
||||
"ms-MY",
|
||||
"ml-IN",
|
||||
"mr-IN",
|
||||
"mn-MN",
|
||||
"ne-NP",
|
||||
"no-NO",
|
||||
"fa-IR",
|
||||
"pl-PL",
|
||||
"pt-BR",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"ru-RU",
|
||||
"sr-RS",
|
||||
"si-LK",
|
||||
"sk-SK",
|
||||
"sl-SI",
|
||||
"en-ZA",
|
||||
"es-AR",
|
||||
"es-BO",
|
||||
"es-CL",
|
||||
@ -157,27 +105,81 @@ class GoogleGenerativeAISttEntity(
|
||||
"es-CR",
|
||||
"es-DO",
|
||||
"es-EC",
|
||||
"es-SV",
|
||||
"es-ES",
|
||||
"es-GT",
|
||||
"es-HN",
|
||||
"es-MX",
|
||||
"es-NI",
|
||||
"es-PA",
|
||||
"es-PY",
|
||||
"es-PE",
|
||||
"es-PR",
|
||||
"es-ES",
|
||||
"es-PY",
|
||||
"es-SV",
|
||||
"es-US",
|
||||
"es-UY",
|
||||
"es-VE",
|
||||
"et-EE",
|
||||
"eu-ES",
|
||||
"fa-IR",
|
||||
"fi-FI",
|
||||
"fil-PH",
|
||||
"fr-BE",
|
||||
"fr-CA",
|
||||
"fr-CH",
|
||||
"fr-FR",
|
||||
"ga-IE",
|
||||
"gl-ES",
|
||||
"gu-IN",
|
||||
"he-IL",
|
||||
"hi-IN",
|
||||
"hr-HR",
|
||||
"hu-HU",
|
||||
"hy-AM",
|
||||
"id-ID",
|
||||
"is-IS",
|
||||
"it-CH",
|
||||
"it-IT",
|
||||
"iw-IL",
|
||||
"ja-JP",
|
||||
"jv-ID",
|
||||
"ka-GE",
|
||||
"kk-KZ",
|
||||
"km-KH",
|
||||
"kn-IN",
|
||||
"ko-KR",
|
||||
"lb-LU",
|
||||
"lo-LA",
|
||||
"lt-LT",
|
||||
"lv-LV",
|
||||
"mk-MK",
|
||||
"ml-IN",
|
||||
"mn-MN",
|
||||
"mr-IN",
|
||||
"ms-MY",
|
||||
"my-MM",
|
||||
"nb-NO",
|
||||
"ne-NP",
|
||||
"nl-BE",
|
||||
"nl-NL",
|
||||
"no-NO",
|
||||
"pl-PL",
|
||||
"pt-BR",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"ru-RU",
|
||||
"si-LK",
|
||||
"sk-SK",
|
||||
"sl-SI",
|
||||
"sq-AL",
|
||||
"sr-RS",
|
||||
"su-ID",
|
||||
"sv-SE",
|
||||
"sw-KE",
|
||||
"sw-TZ",
|
||||
"sv-SE",
|
||||
"ta-IN",
|
||||
"ta-LK",
|
||||
"ta-MY",
|
||||
"ta-SG",
|
||||
"ta-LK",
|
||||
"te-IN",
|
||||
"th-TH",
|
||||
"tr-TR",
|
||||
@ -186,6 +188,9 @@ class GoogleGenerativeAISttEntity(
|
||||
"ur-PK",
|
||||
"uz-UZ",
|
||||
"vi-VN",
|
||||
"zh-CN",
|
||||
"zh-HK",
|
||||
"zh-TW",
|
||||
"zu-ZA",
|
||||
]
|
||||
|
||||
|
@ -48,10 +48,13 @@ class GoogleGenerativeAITextToSpeechEntity(
|
||||
|
||||
_attr_supported_options = [ATTR_VOICE]
|
||||
# See https://ai.google.dev/gemini-api/docs/speech-generation#languages
|
||||
# Note the documentation might not be up to date, e.g. el-GR is not listed
|
||||
# there but is supported.
|
||||
_attr_supported_languages = [
|
||||
"ar-EG",
|
||||
"bn-BD",
|
||||
"de-DE",
|
||||
"el-GR",
|
||||
"en-IN",
|
||||
"en-US",
|
||||
"es-US",
|
||||
|
@ -3,7 +3,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates or an entity ID that provides this information in its state, an entity ID with latitude and longitude attributes, or zone friendly name (case sensitive)",
|
||||
"description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates or an entity ID that provides this information in its state, an entity ID with latitude and longitude attributes, or a zone's friendly name (case-sensitive)",
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
|
@ -56,12 +56,12 @@ async def basic_group_options_schema(
|
||||
entity_selector: selector.Selector[Any] | vol.Schema
|
||||
if handler is None:
|
||||
entity_selector = selector.selector(
|
||||
{"entity": {"domain": domain, "multiple": True}}
|
||||
{"entity": {"domain": domain, "multiple": True, "reorder": True}}
|
||||
)
|
||||
else:
|
||||
entity_selector = entity_selector_without_own_entities(
|
||||
cast(SchemaOptionsFlowHandler, handler.parent_handler),
|
||||
selector.EntitySelectorConfig(domain=domain, multiple=True),
|
||||
selector.EntitySelectorConfig(domain=domain, multiple=True, reorder=True),
|
||||
)
|
||||
|
||||
return vol.Schema(
|
||||
@ -78,7 +78,9 @@ def basic_group_config_schema(domain: str | list[str]) -> vol.Schema:
|
||||
{
|
||||
vol.Required("name"): selector.TextSelector(),
|
||||
vol.Required(CONF_ENTITIES): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain=domain, multiple=True),
|
||||
selector.EntitySelectorConfig(
|
||||
domain=domain, multiple=True, reorder=True
|
||||
),
|
||||
),
|
||||
vol.Required(CONF_HIDE_MEMBERS, default=False): selector.BooleanSelector(),
|
||||
}
|
||||
|
@ -1,21 +1,104 @@
|
||||
"""The Growatt server PV inverter sensor integration."""
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from collections.abc import Mapping
|
||||
|
||||
from .const import PLATFORMS
|
||||
import growattServer
|
||||
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
|
||||
from .const import (
|
||||
CONF_PLANT_ID,
|
||||
DEFAULT_PLANT_ID,
|
||||
DEFAULT_URL,
|
||||
DEPRECATED_URLS,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
from .models import GrowattRuntimeData
|
||||
|
||||
|
||||
def get_device_list(
|
||||
api: growattServer.GrowattApi, config: Mapping[str, str]
|
||||
) -> tuple[list[dict[str, str]], str]:
|
||||
"""Retrieve the device list for the selected plant."""
|
||||
plant_id = config[CONF_PLANT_ID]
|
||||
|
||||
# Log in to api and fetch first plant if no plant id is defined.
|
||||
login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
|
||||
if (
|
||||
not login_response["success"]
|
||||
and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
|
||||
):
|
||||
raise ConfigEntryError("Username, Password or URL may be incorrect!")
|
||||
user_id = login_response["user"]["id"]
|
||||
if plant_id == DEFAULT_PLANT_ID:
|
||||
plant_info = api.plant_list(user_id)
|
||||
plant_id = plant_info["data"][0]["plantId"]
|
||||
|
||||
# Get a list of devices for specified plant to add sensors for.
|
||||
devices = api.device_list(plant_id)
|
||||
return devices, plant_id
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: config_entries.ConfigEntry
|
||||
hass: HomeAssistant, config_entry: GrowattConfigEntry
|
||||
) -> bool:
|
||||
"""Load the saved entities."""
|
||||
"""Set up Growatt from a config entry."""
|
||||
config = config_entry.data
|
||||
username = config[CONF_USERNAME]
|
||||
url = config.get(CONF_URL, DEFAULT_URL)
|
||||
|
||||
# If the URL has been deprecated then change to the default instead
|
||||
if url in DEPRECATED_URLS:
|
||||
url = DEFAULT_URL
|
||||
new_data = dict(config_entry.data)
|
||||
new_data[CONF_URL] = url
|
||||
hass.config_entries.async_update_entry(config_entry, data=new_data)
|
||||
|
||||
# Initialise the library with the username & a random id each time it is started
|
||||
api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username)
|
||||
api.server_url = url
|
||||
|
||||
devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config)
|
||||
|
||||
# Create a coordinator for the total sensors
|
||||
total_coordinator = GrowattCoordinator(
|
||||
hass, config_entry, plant_id, "total", plant_id
|
||||
)
|
||||
|
||||
# Create coordinators for each device
|
||||
device_coordinators = {
|
||||
device["deviceSn"]: GrowattCoordinator(
|
||||
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
|
||||
)
|
||||
for device in devices
|
||||
if device["deviceType"] in ["inverter", "tlx", "storage", "mix"]
|
||||
}
|
||||
|
||||
# Perform the first refresh for the total coordinator
|
||||
await total_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
# Perform the first refresh for each device coordinator
|
||||
for device_coordinator in device_coordinators.values():
|
||||
await device_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
# Store runtime data in the config entry
|
||||
config_entry.runtime_data = GrowattRuntimeData(
|
||||
total_coordinator=total_coordinator,
|
||||
devices=device_coordinators,
|
||||
)
|
||||
|
||||
# Set up all the entities
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: GrowattConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
|
210
homeassistant/components/growatt_server/coordinator.py
Normal file
210
homeassistant/components/growatt_server/coordinator.py
Normal file
@ -0,0 +1,210 @@
|
||||
"""Coordinator module for managing Growatt data fetching."""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import growattServer
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DEFAULT_URL, DOMAIN
|
||||
from .models import GrowattRuntimeData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .sensor.sensor_entity_description import GrowattSensorEntityDescription
|
||||
|
||||
type GrowattConfigEntry = ConfigEntry[GrowattRuntimeData]
|
||||
|
||||
SCAN_INTERVAL = datetime.timedelta(minutes=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Coordinator to manage Growatt data fetching."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: GrowattConfigEntry,
|
||||
device_id: str,
|
||||
device_type: str,
|
||||
plant_id: str,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.username = config_entry.data[CONF_USERNAME]
|
||||
self.password = config_entry.data[CONF_PASSWORD]
|
||||
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
|
||||
self.api = growattServer.GrowattApi(
|
||||
add_random_user_id=True, agent_identifier=self.username
|
||||
)
|
||||
|
||||
# Set server URL
|
||||
self.api.server_url = self.url
|
||||
|
||||
self.device_id = device_id
|
||||
self.device_type = device_type
|
||||
self.plant_id = plant_id
|
||||
|
||||
# Initialize previous_values to store historical data
|
||||
self.previous_values: dict[str, Any] = {}
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"{DOMAIN} ({device_id})",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
|
||||
def _sync_update_data(self) -> dict[str, Any]:
|
||||
"""Update data via library synchronously."""
|
||||
_LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type)
|
||||
|
||||
# Login in to the Growatt server
|
||||
self.api.login(self.username, self.password)
|
||||
|
||||
if self.device_type == "total":
|
||||
total_info = self.api.plant_info(self.device_id)
|
||||
del total_info["deviceList"]
|
||||
plant_money_text, currency = total_info["plantMoneyText"].split("/")
|
||||
total_info["plantMoneyText"] = plant_money_text
|
||||
total_info["currency"] = currency
|
||||
self.data = total_info
|
||||
elif self.device_type == "inverter":
|
||||
self.data = self.api.inverter_detail(self.device_id)
|
||||
elif self.device_type == "tlx":
|
||||
tlx_info = self.api.tlx_detail(self.device_id)
|
||||
self.data = tlx_info["data"]
|
||||
elif self.device_type == "storage":
|
||||
storage_info_detail = self.api.storage_params(self.device_id)
|
||||
storage_energy_overview = self.api.storage_energy_overview(
|
||||
self.plant_id, self.device_id
|
||||
)
|
||||
self.data = {
|
||||
**storage_info_detail["storageDetailBean"],
|
||||
**storage_energy_overview,
|
||||
}
|
||||
elif self.device_type == "mix":
|
||||
mix_info = self.api.mix_info(self.device_id)
|
||||
mix_totals = self.api.mix_totals(self.device_id, self.plant_id)
|
||||
mix_system_status = self.api.mix_system_status(
|
||||
self.device_id, self.plant_id
|
||||
)
|
||||
mix_detail = self.api.mix_detail(self.device_id, self.plant_id)
|
||||
|
||||
# Get the chart data and work out the time of the last entry
|
||||
mix_chart_entries = mix_detail["chartData"]
|
||||
sorted_keys = sorted(mix_chart_entries)
|
||||
|
||||
# Create datetime from the latest entry
|
||||
date_now = dt_util.now().date()
|
||||
last_updated_time = dt_util.parse_time(str(sorted_keys[-1]))
|
||||
mix_detail["lastdataupdate"] = datetime.datetime.combine(
|
||||
date_now,
|
||||
last_updated_time, # type: ignore[arg-type]
|
||||
dt_util.get_default_time_zone(),
|
||||
)
|
||||
|
||||
# Dashboard data for mix system
|
||||
dashboard_data = self.api.dashboard_data(self.plant_id)
|
||||
dashboard_values_for_mix = {
|
||||
"etouser_combined": float(dashboard_data["etouser"].replace("kWh", ""))
|
||||
}
|
||||
self.data = {
|
||||
**mix_info,
|
||||
**mix_totals,
|
||||
**mix_system_status,
|
||||
**mix_detail,
|
||||
**dashboard_values_for_mix,
|
||||
}
|
||||
_LOGGER.debug(
|
||||
"Finished updating data for %s (%s)",
|
||||
self.device_id,
|
||||
self.device_type,
|
||||
)
|
||||
|
||||
return self.data
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Asynchronously update data via library."""
|
||||
try:
|
||||
return await self.hass.async_add_executor_job(self._sync_update_data)
|
||||
except json.decoder.JSONDecodeError as err:
|
||||
_LOGGER.error("Unable to fetch data from Growatt server: %s", err)
|
||||
raise UpdateFailed(f"Error fetching data: {err}") from err
|
||||
|
||||
def get_currency(self):
|
||||
"""Get the currency."""
|
||||
return self.data.get("currency")
|
||||
|
||||
def get_data(
|
||||
self, entity_description: "GrowattSensorEntityDescription"
|
||||
) -> str | int | float | None:
|
||||
"""Get the data."""
|
||||
variable = entity_description.api_key
|
||||
api_value = self.data.get(variable)
|
||||
previous_value = self.previous_values.get(variable)
|
||||
return_value = api_value
|
||||
|
||||
# If we have a 'drop threshold' specified, then check it and correct if needed
|
||||
if (
|
||||
entity_description.previous_value_drop_threshold is not None
|
||||
and previous_value is not None
|
||||
and api_value is not None
|
||||
):
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"%s - Drop threshold specified (%s), checking for drop... API"
|
||||
" Value: %s, Previous Value: %s"
|
||||
),
|
||||
entity_description.name,
|
||||
entity_description.previous_value_drop_threshold,
|
||||
api_value,
|
||||
previous_value,
|
||||
)
|
||||
diff = float(api_value) - float(previous_value)
|
||||
|
||||
# Check if the value has dropped (negative value i.e. < 0) and it has only
|
||||
# dropped by a small amount, if so, use the previous value.
|
||||
# Note - The energy dashboard takes care of drops within 10%
|
||||
# of the current value, however if the value is low e.g. 0.2
|
||||
# and drops by 0.1 it classes as a reset.
|
||||
if -(entity_description.previous_value_drop_threshold) <= diff < 0:
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"Diff is negative, but only by a small amount therefore not a"
|
||||
" nightly reset, using previous value (%s) instead of api value"
|
||||
" (%s)"
|
||||
),
|
||||
previous_value,
|
||||
api_value,
|
||||
)
|
||||
return_value = previous_value
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s - No drop detected, using API value", entity_description.name
|
||||
)
|
||||
|
||||
# Lifetime total values should always be increasing, they will never reset,
|
||||
# however the API sometimes returns 0 values when the clock turns to 00:00
|
||||
# local time in that scenario we should just return the previous value
|
||||
if entity_description.never_resets and api_value == 0 and previous_value:
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"API value is 0, but this value should never reset, returning"
|
||||
" previous value (%s) instead"
|
||||
),
|
||||
previous_value,
|
||||
)
|
||||
return_value = previous_value
|
||||
|
||||
self.previous_values[variable] = return_value
|
||||
|
||||
return return_value
|
17
homeassistant/components/growatt_server/models.py
Normal file
17
homeassistant/components/growatt_server/models.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""Models for the Growatt server integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .coordinator import GrowattCoordinator
|
||||
|
||||
|
||||
@dataclass
|
||||
class GrowattRuntimeData:
|
||||
"""Runtime data for the Growatt integration."""
|
||||
|
||||
total_coordinator: GrowattCoordinator
|
||||
devices: dict[str, GrowattCoordinator]
|
@ -2,29 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
import growattServer
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import Throttle, dt as dt_util
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from ..const import (
|
||||
CONF_PLANT_ID,
|
||||
DEFAULT_PLANT_ID,
|
||||
DEFAULT_URL,
|
||||
DEPRECATED_URLS,
|
||||
DOMAIN,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
)
|
||||
from ..const import DOMAIN
|
||||
from ..coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
from .inverter import INVERTER_SENSOR_TYPES
|
||||
from .mix import MIX_SENSOR_TYPES
|
||||
from .sensor_entity_description import GrowattSensorEntityDescription
|
||||
@ -34,136 +21,97 @@ from .total import TOTAL_SENSOR_TYPES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = datetime.timedelta(minutes=5)
|
||||
|
||||
|
||||
def get_device_list(api, config):
|
||||
"""Retrieve the device list for the selected plant."""
|
||||
plant_id = config[CONF_PLANT_ID]
|
||||
|
||||
# Log in to api and fetch first plant if no plant id is defined.
|
||||
login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
|
||||
if (
|
||||
not login_response["success"]
|
||||
and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
|
||||
):
|
||||
raise ConfigEntryError("Username, Password or URL may be incorrect!")
|
||||
user_id = login_response["user"]["id"]
|
||||
if plant_id == DEFAULT_PLANT_ID:
|
||||
plant_info = api.plant_list(user_id)
|
||||
plant_id = plant_info["data"][0]["plantId"]
|
||||
|
||||
# Get a list of devices for specified plant to add sensors for.
|
||||
devices = api.device_list(plant_id)
|
||||
return [devices, plant_id]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: GrowattConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Growatt sensor."""
|
||||
config = {**config_entry.data}
|
||||
username = config[CONF_USERNAME]
|
||||
password = config[CONF_PASSWORD]
|
||||
url = config.get(CONF_URL, DEFAULT_URL)
|
||||
name = config[CONF_NAME]
|
||||
# Use runtime_data instead of hass.data
|
||||
data = config_entry.runtime_data
|
||||
|
||||
# If the URL has been deprecated then change to the default instead
|
||||
if url in DEPRECATED_URLS:
|
||||
_LOGGER.warning(
|
||||
"URL: %s has been deprecated, migrating to the latest default: %s",
|
||||
url,
|
||||
DEFAULT_URL,
|
||||
)
|
||||
url = DEFAULT_URL
|
||||
config[CONF_URL] = url
|
||||
hass.config_entries.async_update_entry(config_entry, data=config)
|
||||
entities: list[GrowattSensor] = []
|
||||
|
||||
# Initialise the library with the username & a random id each time it is started
|
||||
api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username)
|
||||
api.server_url = url
|
||||
|
||||
devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config)
|
||||
|
||||
probe = GrowattData(api, username, password, plant_id, "total")
|
||||
entities = [
|
||||
GrowattInverter(
|
||||
probe,
|
||||
name=f"{name} Total",
|
||||
unique_id=f"{plant_id}-{description.key}",
|
||||
# Add total sensors
|
||||
total_coordinator = data.total_coordinator
|
||||
entities.extend(
|
||||
GrowattSensor(
|
||||
total_coordinator,
|
||||
name=f"{config_entry.data['name']} Total",
|
||||
serial_id=config_entry.data["plant_id"],
|
||||
unique_id=f"{config_entry.data['plant_id']}-{description.key}",
|
||||
description=description,
|
||||
)
|
||||
for description in TOTAL_SENSOR_TYPES
|
||||
]
|
||||
|
||||
# Add sensors for each device in the specified plant.
|
||||
for device in devices:
|
||||
probe = GrowattData(
|
||||
api, username, password, device["deviceSn"], device["deviceType"]
|
||||
)
|
||||
sensor_descriptions: tuple[GrowattSensorEntityDescription, ...] = ()
|
||||
if device["deviceType"] == "inverter":
|
||||
sensor_descriptions = INVERTER_SENSOR_TYPES
|
||||
elif device["deviceType"] == "tlx":
|
||||
probe.plant_id = plant_id
|
||||
sensor_descriptions = TLX_SENSOR_TYPES
|
||||
elif device["deviceType"] == "storage":
|
||||
probe.plant_id = plant_id
|
||||
sensor_descriptions = STORAGE_SENSOR_TYPES
|
||||
elif device["deviceType"] == "mix":
|
||||
probe.plant_id = plant_id
|
||||
sensor_descriptions = MIX_SENSOR_TYPES
|
||||
|
||||
# Add sensors for each device
|
||||
for device_sn, device_coordinator in data.devices.items():
|
||||
sensor_descriptions: list = []
|
||||
if device_coordinator.device_type == "inverter":
|
||||
sensor_descriptions = list(INVERTER_SENSOR_TYPES)
|
||||
elif device_coordinator.device_type == "tlx":
|
||||
sensor_descriptions = list(TLX_SENSOR_TYPES)
|
||||
elif device_coordinator.device_type == "storage":
|
||||
sensor_descriptions = list(STORAGE_SENSOR_TYPES)
|
||||
elif device_coordinator.device_type == "mix":
|
||||
sensor_descriptions = list(MIX_SENSOR_TYPES)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Device type %s was found but is not supported right now",
|
||||
device["deviceType"],
|
||||
device_coordinator.device_type,
|
||||
)
|
||||
|
||||
entities.extend(
|
||||
[
|
||||
GrowattInverter(
|
||||
probe,
|
||||
name=f"{device['deviceAilas']}",
|
||||
unique_id=f"{device['deviceSn']}-{description.key}",
|
||||
GrowattSensor(
|
||||
device_coordinator,
|
||||
name=device_sn,
|
||||
serial_id=device_sn,
|
||||
unique_id=f"{device_sn}-{description.key}",
|
||||
description=description,
|
||||
)
|
||||
for description in sensor_descriptions
|
||||
]
|
||||
)
|
||||
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class GrowattInverter(SensorEntity):
|
||||
class GrowattSensor(CoordinatorEntity[GrowattCoordinator], SensorEntity):
|
||||
"""Representation of a Growatt Sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
entity_description: GrowattSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self, probe, name, unique_id, description: GrowattSensorEntityDescription
|
||||
self,
|
||||
coordinator: GrowattCoordinator,
|
||||
name: str,
|
||||
serial_id: str,
|
||||
unique_id: str,
|
||||
description: GrowattSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a PVOutput sensor."""
|
||||
self.probe = probe
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_icon = "mdi:solar-power"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, probe.device_id)},
|
||||
identifiers={(DOMAIN, serial_id)},
|
||||
manufacturer="Growatt",
|
||||
name=name,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
def native_value(self) -> str | int | float | None:
|
||||
"""Return the state of the sensor."""
|
||||
result = self.probe.get_data(self.entity_description)
|
||||
if self.entity_description.precision is not None:
|
||||
result = self.coordinator.get_data(self.entity_description)
|
||||
if (
|
||||
isinstance(result, (int, float))
|
||||
and self.entity_description.precision is not None
|
||||
):
|
||||
result = round(result, self.entity_description.precision)
|
||||
return result
|
||||
|
||||
@ -171,182 +119,5 @@ class GrowattInverter(SensorEntity):
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit of measurement of the sensor, if any."""
|
||||
if self.entity_description.currency:
|
||||
return self.probe.get_currency()
|
||||
return self.coordinator.get_currency()
|
||||
return super().native_unit_of_measurement
|
||||
|
||||
def update(self) -> None:
|
||||
"""Get the latest data from the Growat API and updates the state."""
|
||||
self.probe.update()
|
||||
|
||||
|
||||
class GrowattData:
|
||||
"""The class for handling data retrieval."""
|
||||
|
||||
def __init__(self, api, username, password, device_id, growatt_type):
|
||||
"""Initialize the probe."""
|
||||
|
||||
self.growatt_type = growatt_type
|
||||
self.api = api
|
||||
self.device_id = device_id
|
||||
self.plant_id = None
|
||||
self.data = {}
|
||||
self.previous_values = {}
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
@Throttle(SCAN_INTERVAL)
|
||||
def update(self):
|
||||
"""Update probe data."""
|
||||
self.api.login(self.username, self.password)
|
||||
_LOGGER.debug("Updating data for %s (%s)", self.device_id, self.growatt_type)
|
||||
try:
|
||||
if self.growatt_type == "total":
|
||||
total_info = self.api.plant_info(self.device_id)
|
||||
del total_info["deviceList"]
|
||||
# PlantMoneyText comes in as "3.1/€" split between value and currency
|
||||
plant_money_text, currency = total_info["plantMoneyText"].split("/")
|
||||
total_info["plantMoneyText"] = plant_money_text
|
||||
total_info["currency"] = currency
|
||||
self.data = total_info
|
||||
elif self.growatt_type == "inverter":
|
||||
inverter_info = self.api.inverter_detail(self.device_id)
|
||||
self.data = inverter_info
|
||||
elif self.growatt_type == "tlx":
|
||||
tlx_info = self.api.tlx_detail(self.device_id)
|
||||
self.data = tlx_info["data"]
|
||||
elif self.growatt_type == "storage":
|
||||
storage_info_detail = self.api.storage_params(self.device_id)[
|
||||
"storageDetailBean"
|
||||
]
|
||||
storage_energy_overview = self.api.storage_energy_overview(
|
||||
self.plant_id, self.device_id
|
||||
)
|
||||
self.data = {**storage_info_detail, **storage_energy_overview}
|
||||
elif self.growatt_type == "mix":
|
||||
mix_info = self.api.mix_info(self.device_id)
|
||||
mix_totals = self.api.mix_totals(self.device_id, self.plant_id)
|
||||
mix_system_status = self.api.mix_system_status(
|
||||
self.device_id, self.plant_id
|
||||
)
|
||||
|
||||
mix_detail = self.api.mix_detail(self.device_id, self.plant_id)
|
||||
# Get the chart data and work out the time of the last entry, use this
|
||||
# as the last time data was published to the Growatt Server
|
||||
mix_chart_entries = mix_detail["chartData"]
|
||||
sorted_keys = sorted(mix_chart_entries)
|
||||
|
||||
# Create datetime from the latest entry
|
||||
date_now = dt_util.now().date()
|
||||
last_updated_time = dt_util.parse_time(str(sorted_keys[-1]))
|
||||
mix_detail["lastdataupdate"] = datetime.datetime.combine(
|
||||
date_now, last_updated_time, dt_util.get_default_time_zone()
|
||||
)
|
||||
|
||||
# Dashboard data is largely inaccurate for mix system but it is the only
|
||||
# call with the ability to return the combined imported from grid value
|
||||
# that is the combination of charging AND load consumption
|
||||
dashboard_data = self.api.dashboard_data(self.plant_id)
|
||||
# Dashboard values have units e.g. "kWh" as part of their returned
|
||||
# string, so we remove it
|
||||
dashboard_values_for_mix = {
|
||||
# etouser is already used by the results from 'mix_detail' so we
|
||||
# rebrand it as 'etouser_combined'
|
||||
"etouser_combined": float(
|
||||
dashboard_data["etouser"].replace("kWh", "")
|
||||
)
|
||||
}
|
||||
self.data = {
|
||||
**mix_info,
|
||||
**mix_totals,
|
||||
**mix_system_status,
|
||||
**mix_detail,
|
||||
**dashboard_values_for_mix,
|
||||
}
|
||||
_LOGGER.debug(
|
||||
"Finished updating data for %s (%s)",
|
||||
self.device_id,
|
||||
self.growatt_type,
|
||||
)
|
||||
except json.decoder.JSONDecodeError:
|
||||
_LOGGER.error("Unable to fetch data from Growatt server")
|
||||
|
||||
def get_currency(self):
|
||||
"""Get the currency."""
|
||||
return self.data.get("currency")
|
||||
|
||||
def get_data(self, entity_description):
|
||||
"""Get the data."""
|
||||
_LOGGER.debug(
|
||||
"Data request for: %s",
|
||||
entity_description.name,
|
||||
)
|
||||
variable = entity_description.api_key
|
||||
api_value = self.data.get(variable)
|
||||
previous_value = self.previous_values.get(variable)
|
||||
return_value = api_value
|
||||
|
||||
# If we have a 'drop threshold' specified, then check it and correct if needed
|
||||
if (
|
||||
entity_description.previous_value_drop_threshold is not None
|
||||
and previous_value is not None
|
||||
and api_value is not None
|
||||
):
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"%s - Drop threshold specified (%s), checking for drop... API"
|
||||
" Value: %s, Previous Value: %s"
|
||||
),
|
||||
entity_description.name,
|
||||
entity_description.previous_value_drop_threshold,
|
||||
api_value,
|
||||
previous_value,
|
||||
)
|
||||
diff = float(api_value) - float(previous_value)
|
||||
|
||||
# Check if the value has dropped (negative value i.e. < 0) and it has only
|
||||
# dropped by a small amount, if so, use the previous value.
|
||||
# Note - The energy dashboard takes care of drops within 10%
|
||||
# of the current value, however if the value is low e.g. 0.2
|
||||
# and drops by 0.1 it classes as a reset.
|
||||
if -(entity_description.previous_value_drop_threshold) <= diff < 0:
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"Diff is negative, but only by a small amount therefore not a"
|
||||
" nightly reset, using previous value (%s) instead of api value"
|
||||
" (%s)"
|
||||
),
|
||||
previous_value,
|
||||
api_value,
|
||||
)
|
||||
return_value = previous_value
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s - No drop detected, using API value", entity_description.name
|
||||
)
|
||||
|
||||
# Lifetime total values should always be increasing, they will never reset,
|
||||
# however the API sometimes returns 0 values when the clock turns to 00:00
|
||||
# local time in that scenario we should just return the previous value
|
||||
# Scenarios:
|
||||
# 1 - System has a genuine 0 value when it it first commissioned:
|
||||
# - will return 0 until a non-zero value is registered
|
||||
# 2 - System has been running fine but temporarily resets to 0 briefly
|
||||
# at midnight:
|
||||
# - will return the previous value
|
||||
# 3 - HA is restarted during the midnight 'outage' - Not handled:
|
||||
# - Previous value will not exist meaning 0 will be returned
|
||||
# - This is an edge case that would be better handled by looking
|
||||
# up the previous value of the entity from the recorder
|
||||
if entity_description.never_resets and api_value == 0 and previous_value:
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"API value is 0, but this value should never reset, returning"
|
||||
" previous value (%s) instead"
|
||||
),
|
||||
previous_value,
|
||||
)
|
||||
return_value = previous_value
|
||||
|
||||
self.previous_values[variable] = return_value
|
||||
|
||||
return return_value
|
||||
|
@ -164,7 +164,6 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data={
|
||||
CONF_API_USER: str(login.id),
|
||||
CONF_API_KEY: login.apiToken,
|
||||
CONF_NAME: user.profile.name, # needed for api_call action
|
||||
CONF_URL: DEFAULT_URL,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
@ -200,7 +199,6 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data={
|
||||
**user_input,
|
||||
CONF_URL: user_input.get(CONF_URL, DEFAULT_URL),
|
||||
CONF_NAME: user.profile.name, # needed for api_call action
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -23,7 +23,6 @@ from habiticalib import (
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
@ -106,12 +105,6 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
|
||||
translation_placeholders={"reason": str(e)},
|
||||
) from e
|
||||
|
||||
if not self.config_entry.data.get(CONF_NAME):
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
data={**self.config_entry.data, CONF_NAME: user.data.profile.name},
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> HabiticaData:
|
||||
try:
|
||||
user = (await self.habitica.get_user()).data
|
||||
|
@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.const import CONF_NAME, CONF_URL
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@ -37,7 +37,7 @@ class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]):
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=NAME,
|
||||
name=coordinator.config_entry.data[CONF_NAME],
|
||||
name=coordinator.data.user.profile.name,
|
||||
configuration_url=(
|
||||
URL(coordinator.config_entry.data[CONF_URL])
|
||||
/ "profile"
|
||||
|
@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["habiticalib"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["habiticalib==0.4.0"]
|
||||
"requirements": ["habiticalib==0.4.1"]
|
||||
}
|
||||
|
@ -238,7 +238,7 @@
|
||||
"name": "OS Agent version"
|
||||
},
|
||||
"apparmor_version": {
|
||||
"name": "Apparmor version"
|
||||
"name": "AppArmor version"
|
||||
},
|
||||
"cpu_percent": {
|
||||
"name": "CPU percent"
|
||||
|
@ -2,11 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
|
||||
from .const import TRAVEL_MODE_PUBLIC
|
||||
from .const import CONF_TRAFFIC_MODE, TRAVEL_MODE_PUBLIC
|
||||
from .coordinator import (
|
||||
HereConfigEntry,
|
||||
HERERoutingDataUpdateCoordinator,
|
||||
@ -15,6 +17,8 @@ from .coordinator import (
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry) -> bool:
|
||||
"""Set up HERE Travel Time from a config entry."""
|
||||
@ -43,3 +47,28 @@ async def async_unload_entry(
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, config_entry: HereConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate an old config entry."""
|
||||
|
||||
if config_entry.version == 1 and config_entry.minor_version == 1:
|
||||
_LOGGER.debug(
|
||||
"Migrating from version %s.%s",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
options = dict(config_entry.options)
|
||||
options[CONF_TRAFFIC_MODE] = True
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, options=options, version=1, minor_version=2
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Migration to version %s.%s successful",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
return True
|
||||
|
@ -33,6 +33,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
EntitySelector,
|
||||
LocationSelector,
|
||||
TimeSelector,
|
||||
@ -50,6 +51,7 @@ from .const import (
|
||||
CONF_ORIGIN_LATITUDE,
|
||||
CONF_ORIGIN_LONGITUDE,
|
||||
CONF_ROUTE_MODE,
|
||||
CONF_TRAFFIC_MODE,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
ROUTE_MODE_FASTEST,
|
||||
@ -65,6 +67,7 @@ DEFAULT_OPTIONS = {
|
||||
CONF_ROUTE_MODE: ROUTE_MODE_FASTEST,
|
||||
CONF_ARRIVAL_TIME: None,
|
||||
CONF_DEPARTURE_TIME: None,
|
||||
CONF_TRAFFIC_MODE: True,
|
||||
}
|
||||
|
||||
|
||||
@ -102,6 +105,7 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for HERE Travel Time."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Init Config Flow."""
|
||||
@ -307,7 +311,9 @@ class HERETravelTimeOptionsFlow(OptionsFlow):
|
||||
"""Manage the HERE Travel Time options."""
|
||||
if user_input is not None:
|
||||
self._config = user_input
|
||||
if self._config[CONF_TRAFFIC_MODE]:
|
||||
return await self.async_step_time_menu()
|
||||
return self.async_create_entry(title="", data=self._config)
|
||||
|
||||
schema = self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
@ -318,12 +324,21 @@ class HERETravelTimeOptionsFlow(OptionsFlow):
|
||||
CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE]
|
||||
),
|
||||
): vol.In(ROUTE_MODES),
|
||||
vol.Optional(
|
||||
CONF_TRAFFIC_MODE,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_TRAFFIC_MODE, DEFAULT_OPTIONS[CONF_TRAFFIC_MODE]
|
||||
),
|
||||
): BooleanSelector(),
|
||||
}
|
||||
),
|
||||
{
|
||||
CONF_ROUTE_MODE: self.config_entry.options.get(
|
||||
CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE]
|
||||
),
|
||||
CONF_TRAFFIC_MODE: self.config_entry.options.get(
|
||||
CONF_TRAFFIC_MODE, DEFAULT_OPTIONS[CONF_TRAFFIC_MODE]
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -19,6 +19,7 @@ CONF_ARRIVAL = "arrival"
|
||||
CONF_DEPARTURE = "departure"
|
||||
CONF_ARRIVAL_TIME = "arrival_time"
|
||||
CONF_DEPARTURE_TIME = "departure_time"
|
||||
CONF_TRAFFIC_MODE = "traffic_mode"
|
||||
|
||||
DEFAULT_NAME = "HERE Travel Time"
|
||||
|
||||
|
@ -13,6 +13,7 @@ from here_routing import (
|
||||
Return,
|
||||
RoutingMode,
|
||||
Spans,
|
||||
TrafficMode,
|
||||
TransportMode,
|
||||
)
|
||||
import here_transit
|
||||
@ -44,6 +45,7 @@ from .const import (
|
||||
CONF_ORIGIN_LATITUDE,
|
||||
CONF_ORIGIN_LONGITUDE,
|
||||
CONF_ROUTE_MODE,
|
||||
CONF_TRAFFIC_MODE,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
ROUTE_MODE_FASTEST,
|
||||
@ -87,7 +89,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"Requesting route for origin: %s, destination: %s, route_mode: %s,"
|
||||
" mode: %s, arrival: %s, departure: %s"
|
||||
" mode: %s, arrival: %s, departure: %s, traffic_mode: %s"
|
||||
),
|
||||
params.origin,
|
||||
params.destination,
|
||||
@ -95,6 +97,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
|
||||
TransportMode(params.travel_mode),
|
||||
params.arrival,
|
||||
params.departure,
|
||||
params.traffic_mode,
|
||||
)
|
||||
|
||||
try:
|
||||
@ -109,6 +112,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
|
||||
routing_mode=params.route_mode,
|
||||
arrival_time=params.arrival,
|
||||
departure_time=params.departure,
|
||||
traffic_mode=params.traffic_mode,
|
||||
return_values=[Return.POLYINE, Return.SUMMARY],
|
||||
spans=[Spans.NAMES],
|
||||
)
|
||||
@ -350,6 +354,11 @@ def prepare_parameters(
|
||||
if config_entry.options[CONF_ROUTE_MODE] == ROUTE_MODE_FASTEST
|
||||
else RoutingMode.SHORT
|
||||
)
|
||||
traffic_mode = (
|
||||
TrafficMode.DISABLED
|
||||
if config_entry.options[CONF_TRAFFIC_MODE] is False
|
||||
else TrafficMode.DEFAULT
|
||||
)
|
||||
|
||||
return HERETravelTimeAPIParams(
|
||||
destination=destination,
|
||||
@ -358,6 +367,7 @@ def prepare_parameters(
|
||||
route_mode=route_mode,
|
||||
arrival=arrival,
|
||||
departure=departure,
|
||||
traffic_mode=traffic_mode,
|
||||
)
|
||||
|
||||
|
||||
|
@ -6,7 +6,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TypedDict
|
||||
|
||||
from here_routing import RoutingMode
|
||||
from here_routing import RoutingMode, TrafficMode
|
||||
|
||||
|
||||
class HERETravelTimeData(TypedDict):
|
||||
@ -32,3 +32,4 @@ class HERETravelTimeAPIParams:
|
||||
route_mode: RoutingMode
|
||||
arrival: datetime | None
|
||||
departure: datetime | None
|
||||
traffic_mode: TrafficMode
|
||||
|
@ -60,8 +60,11 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"traffic_mode": "Traffic mode",
|
||||
"traffic_mode": "Use traffic and time-aware routing",
|
||||
"route_mode": "Route mode"
|
||||
},
|
||||
"data_description": {
|
||||
"traffic_mode": "Needed for defining arrival/departure times"
|
||||
}
|
||||
},
|
||||
"time_menu": {
|
||||
|
@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.76", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.77", "babel==2.15.0"]
|
||||
}
|
||||
|
@ -193,11 +193,11 @@
|
||||
"consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte",
|
||||
"consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth",
|
||||
"consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk",
|
||||
"consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner",
|
||||
"consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner",
|
||||
"consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner Brauner",
|
||||
"consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser Brauner",
|
||||
"consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter",
|
||||
"consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun",
|
||||
"consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange",
|
||||
"consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener Melange",
|
||||
"consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white",
|
||||
"consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado",
|
||||
"consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado",
|
||||
@ -279,7 +279,7 @@
|
||||
"cooking_oven_program_heating_mode_intensive_heat": "Intensive heat",
|
||||
"cooking_oven_program_heating_mode_keep_warm": "Keep warm",
|
||||
"cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware",
|
||||
"cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products",
|
||||
"cooking_oven_program_heating_mode_frozen_heatup_special": "Special heat-up for frozen products",
|
||||
"cooking_oven_program_heating_mode_desiccation": "Desiccation",
|
||||
"cooking_oven_program_heating_mode_defrost": "Defrost",
|
||||
"cooking_oven_program_heating_mode_proof": "Proof",
|
||||
@ -316,8 +316,8 @@
|
||||
"laundry_care_washer_program_monsoon": "Monsoon",
|
||||
"laundry_care_washer_program_outdoor": "Outdoor",
|
||||
"laundry_care_washer_program_plush_toy": "Plush toy",
|
||||
"laundry_care_washer_program_shirts_blouses": "Shirts blouses",
|
||||
"laundry_care_washer_program_sport_fitness": "Sport fitness",
|
||||
"laundry_care_washer_program_shirts_blouses": "Shirts/blouses",
|
||||
"laundry_care_washer_program_sport_fitness": "Sport/fitness",
|
||||
"laundry_care_washer_program_towels": "Towels",
|
||||
"laundry_care_washer_program_water_proof": "Water proof",
|
||||
"laundry_care_washer_program_power_speed_59": "Power speed <59 min",
|
||||
@ -582,7 +582,7 @@
|
||||
},
|
||||
"consumer_products_cleaning_robot_option_cleaning_mode": {
|
||||
"name": "Cleaning mode",
|
||||
"description": "Defines the favoured cleaning mode."
|
||||
"description": "Defines the favored cleaning mode."
|
||||
},
|
||||
"consumer_products_coffee_maker_option_bean_amount": {
|
||||
"name": "Bean amount",
|
||||
@ -670,7 +670,7 @@
|
||||
},
|
||||
"cooking_oven_option_setpoint_temperature": {
|
||||
"name": "Setpoint temperature",
|
||||
"description": "Defines the target cavity temperature, which will be hold by the oven."
|
||||
"description": "Defines the target cavity temperature, which will be held by the oven."
|
||||
},
|
||||
"b_s_h_common_option_duration": {
|
||||
"name": "Duration",
|
||||
@ -1291,9 +1291,9 @@
|
||||
"state": {
|
||||
"cooking_hood_enum_type_color_temperature_custom": "Custom",
|
||||
"cooking_hood_enum_type_color_temperature_warm": "Warm",
|
||||
"cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to Neutral",
|
||||
"cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to neutral",
|
||||
"cooking_hood_enum_type_color_temperature_neutral": "Neutral",
|
||||
"cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to Cold",
|
||||
"cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to cold",
|
||||
"cooking_hood_enum_type_color_temperature_cold": "Cold"
|
||||
}
|
||||
},
|
||||
|
@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["homee"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyHomee==1.2.10"]
|
||||
}
|
||||
|
@ -28,16 +28,19 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not have options.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
@ -49,16 +52,16 @@ rules:
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
|
@ -19,6 +19,7 @@ PLATFORMS = [
|
||||
Platform.LOCK,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VALVE,
|
||||
Platform.WEATHER,
|
||||
]
|
||||
|
||||
|
@ -33,6 +33,7 @@ from homematicip.device import (
|
||||
TemperatureHumiditySensorOutdoor,
|
||||
TemperatureHumiditySensorWithoutDisplay,
|
||||
TiltVibrationSensor,
|
||||
WateringActuator,
|
||||
WeatherSensor,
|
||||
WeatherSensorPlus,
|
||||
WeatherSensorPro,
|
||||
@ -45,6 +46,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
DEGREE,
|
||||
LIGHT_LUX,
|
||||
@ -167,6 +169,29 @@ def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]:
|
||||
HomematicipTiltStateSensor(hap, device),
|
||||
HomematicipTiltAngleSensor(hap, device),
|
||||
],
|
||||
WateringActuator: lambda device: [
|
||||
entity
|
||||
for ch in device.functionalChannels
|
||||
if ch.functionalChannelType
|
||||
== FunctionalChannelType.WATERING_ACTUATOR_CHANNEL
|
||||
for entity in (
|
||||
HomematicipWaterFlowSensor(
|
||||
hap, device, channel=ch.index, post="currentWaterFlow"
|
||||
),
|
||||
HomematicipWaterVolumeSensor(
|
||||
hap,
|
||||
device,
|
||||
channel=ch.index,
|
||||
post="waterVolume",
|
||||
attribute="waterVolume",
|
||||
),
|
||||
HomematicipWaterVolumeSinceOpenSensor(
|
||||
hap,
|
||||
device,
|
||||
channel=ch.index,
|
||||
),
|
||||
)
|
||||
],
|
||||
WeatherSensor: lambda device: [
|
||||
HomematicipTemperatureSensor(hap, device),
|
||||
HomematicipHumiditySensor(hap, device),
|
||||
@ -267,6 +292,65 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class HomematicipWaterFlowSensor(HomematicipGenericEntity, SensorEntity):
|
||||
"""Representation of the HomematicIP watering flow sensor."""
|
||||
|
||||
_attr_native_unit_of_measurement = UnitOfVolumeFlowRate.LITERS_PER_MINUTE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(
|
||||
self, hap: HomematicipHAP, device: Device, channel: int, post: str
|
||||
) -> None:
|
||||
"""Initialize the watering flow sensor device."""
|
||||
super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state."""
|
||||
return self.functional_channel.waterFlow
|
||||
|
||||
|
||||
class HomematicipWaterVolumeSensor(HomematicipGenericEntity, SensorEntity):
|
||||
"""Representation of the HomematicIP watering volume sensor."""
|
||||
|
||||
_attr_native_unit_of_measurement = UnitOfVolume.LITERS
|
||||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hap: HomematicipHAP,
|
||||
device: Device,
|
||||
channel: int,
|
||||
post: str,
|
||||
attribute: str,
|
||||
) -> None:
|
||||
"""Initialize the watering volume sensor device."""
|
||||
super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True)
|
||||
self._attribute_name = attribute
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state."""
|
||||
return getattr(self.functional_channel, self._attribute_name, None)
|
||||
|
||||
|
||||
class HomematicipWaterVolumeSinceOpenSensor(HomematicipWaterVolumeSensor):
|
||||
"""Representation of the HomematicIP watering volume since open sensor."""
|
||||
|
||||
_attr_native_unit_of_measurement = UnitOfVolume.LITERS
|
||||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None:
|
||||
"""Initialize the watering flow volume since open device."""
|
||||
super().__init__(
|
||||
hap,
|
||||
device,
|
||||
channel=channel,
|
||||
post="waterVolumeSinceOpen",
|
||||
attribute="waterVolumeSinceOpen",
|
||||
)
|
||||
|
||||
|
||||
class HomematicipTiltAngleSensor(HomematicipGenericEntity, SensorEntity):
|
||||
"""Representation of the HomematicIP tilt angle sensor."""
|
||||
|
||||
@ -459,7 +543,9 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity):
|
||||
class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity):
|
||||
"""Representation of the HomematicIP absolute humidity sensor."""
|
||||
|
||||
_attr_native_unit_of_measurement = CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER
|
||||
_attr_device_class = SensorDeviceClass.ABSOLUTE_HUMIDITY
|
||||
_attr_native_unit_of_measurement = CONCENTRATION_GRAMS_PER_CUBIC_METER
|
||||
_attr_suggested_unit_of_measurement = CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device) -> None:
|
||||
@ -467,7 +553,7 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity):
|
||||
super().__init__(hap, device, post="Absolute Humidity")
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state."""
|
||||
if self.functional_channel is None:
|
||||
return None
|
||||
@ -481,8 +567,7 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity):
|
||||
):
|
||||
return None
|
||||
|
||||
# Convert from g/m³ to mg/m³
|
||||
return int(float(value) * 1000)
|
||||
return round(value, 3)
|
||||
|
||||
|
||||
class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity):
|
||||
|
59
homeassistant/components/homematicip_cloud/valve.py
Normal file
59
homeassistant/components/homematicip_cloud/valve.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""Support for HomematicIP Cloud valve devices."""
|
||||
|
||||
from homematicip.base.functionalChannels import FunctionalChannelType
|
||||
from homematicip.device import Device
|
||||
|
||||
from homeassistant.components.valve import (
|
||||
ValveDeviceClass,
|
||||
ValveEntity,
|
||||
ValveEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .entity import HomematicipGenericEntity
|
||||
from .hap import HomematicIPConfigEntry, HomematicipHAP
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomematicIPConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the HomematicIP valves from a config entry."""
|
||||
hap = config_entry.runtime_data
|
||||
entities = [
|
||||
HomematicipWateringValve(hap, device, ch.index)
|
||||
for device in hap.home.devices
|
||||
for ch in device.functionalChannels
|
||||
if ch.functionalChannelType == FunctionalChannelType.WATERING_ACTUATOR_CHANNEL
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class HomematicipWateringValve(HomematicipGenericEntity, ValveEntity):
|
||||
"""Representation of a HomematicIP valve."""
|
||||
|
||||
_attr_reports_position = False
|
||||
_attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
|
||||
_attr_device_class = ValveDeviceClass.WATER
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None:
|
||||
"""Initialize the valve."""
|
||||
super().__init__(
|
||||
hap, device=device, channel=channel, post="watering", is_multi_channel=True
|
||||
)
|
||||
|
||||
async def async_open_valve(self) -> None:
|
||||
"""Open the valve."""
|
||||
await self.functional_channel.set_watering_switch_state_async(True)
|
||||
|
||||
async def async_close_valve(self) -> None:
|
||||
"""Close valve."""
|
||||
await self.functional_channel.set_watering_switch_state_async(False)
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
"""Return if the valve is closed."""
|
||||
return self.functional_channel.wateringActive is False
|
86
homeassistant/components/huawei_lte/diagnostics.py
Normal file
86
homeassistant/components/huawei_lte/diagnostics.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""Diagnostics support for Huawei LTE."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
ENTRY_FIELDS_DATA_TO_REDACT = {
|
||||
"mac",
|
||||
"username",
|
||||
"password",
|
||||
}
|
||||
DEVICE_INFORMATION_DATA_TO_REDACT = {
|
||||
"SerialNumber",
|
||||
"Imei",
|
||||
"Imsi",
|
||||
"Iccid",
|
||||
"Msisdn",
|
||||
"MacAddress1",
|
||||
"MacAddress2",
|
||||
"WanIPAddress",
|
||||
"wan_dns_address",
|
||||
"WanIPv6Address",
|
||||
"wan_ipv6_dns_address",
|
||||
"Mccmnc",
|
||||
"WifiMacAddrWl0",
|
||||
"WifiMacAddrWl1",
|
||||
}
|
||||
DEVICE_SIGNAL_DATA_TO_REDACT = {
|
||||
"pci",
|
||||
"cell_id",
|
||||
"enodeb_id",
|
||||
"rac",
|
||||
"lac",
|
||||
"tac",
|
||||
"nei_cellid",
|
||||
"plmn",
|
||||
"bsic",
|
||||
}
|
||||
MONITORING_STATUS_DATA_TO_REDACT = {
|
||||
"PrimaryDns",
|
||||
"SecondaryDns",
|
||||
"PrimaryIPv6Dns",
|
||||
"SecondaryIPv6Dns",
|
||||
}
|
||||
NET_CURRENT_PLMN_DATA_TO_REDACT = {
|
||||
"net_current_plmn",
|
||||
}
|
||||
LAN_HOST_INFO_DATA_TO_REDACT = {
|
||||
"lan_host_info",
|
||||
}
|
||||
WLAN_WIFI_GUEST_NETWORK_SWITCH_DATA_TO_REDACT = {
|
||||
"Ssid",
|
||||
"WifiSsid",
|
||||
}
|
||||
WLAN_MULTI_BASIC_SETTINGS_DATA_TO_REDACT = {
|
||||
"WifiMac",
|
||||
}
|
||||
TO_REDACT = {
|
||||
*ENTRY_FIELDS_DATA_TO_REDACT,
|
||||
*DEVICE_INFORMATION_DATA_TO_REDACT,
|
||||
*DEVICE_SIGNAL_DATA_TO_REDACT,
|
||||
*MONITORING_STATUS_DATA_TO_REDACT,
|
||||
*NET_CURRENT_PLMN_DATA_TO_REDACT,
|
||||
*LAN_HOST_INFO_DATA_TO_REDACT,
|
||||
*WLAN_WIFI_GUEST_NETWORK_SWITCH_DATA_TO_REDACT,
|
||||
*WLAN_MULTI_BASIC_SETTINGS_DATA_TO_REDACT,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return async_redact_data(
|
||||
{
|
||||
"entry": entry.data,
|
||||
"router": hass.data[DOMAIN].routers[entry.entry_id].data,
|
||||
},
|
||||
TO_REDACT,
|
||||
)
|
@ -14,11 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AutomowerConfigEntry
|
||||
from .coordinator import AutomowerDataUpdateCoordinator
|
||||
from .entity import (
|
||||
AutomowerAvailableEntity,
|
||||
_check_error_free,
|
||||
handle_sending_exception,
|
||||
)
|
||||
from .entity import AutomowerControlEntity, handle_sending_exception
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -45,7 +41,6 @@ MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = (
|
||||
AutomowerButtonEntityDescription(
|
||||
key="sync_clock",
|
||||
translation_key="sync_clock",
|
||||
available_fn=_check_error_free,
|
||||
press_fn=lambda session, mower_id: session.commands.set_datetime(mower_id),
|
||||
),
|
||||
)
|
||||
@ -71,7 +66,7 @@ async def async_setup_entry(
|
||||
_async_add_new_devices(set(coordinator.data))
|
||||
|
||||
|
||||
class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity):
|
||||
class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity):
|
||||
"""Defining the AutomowerButtonEntity."""
|
||||
|
||||
entity_description: AutomowerButtonEntityDescription
|
||||
|
@ -58,9 +58,6 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
self.new_devices_callbacks: list[Callable[[set[str]], None]] = []
|
||||
self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = []
|
||||
self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = []
|
||||
self._devices_last_update: set[str] = set()
|
||||
self._zones_last_update: dict[str, set[str]] = {}
|
||||
self._areas_last_update: dict[str, set[int]] = {}
|
||||
|
||||
@override
|
||||
@callback
|
||||
@ -87,10 +84,14 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
"""Handle data updates and process dynamic entity management."""
|
||||
if self.data is not None:
|
||||
self._async_add_remove_devices()
|
||||
for mower_id in self.data:
|
||||
if self.data[mower_id].capabilities.stay_out_zones:
|
||||
if any(
|
||||
mower_data.capabilities.stay_out_zones
|
||||
for mower_data in self.data.values()
|
||||
):
|
||||
self._async_add_remove_stay_out_zones()
|
||||
if self.data[mower_id].capabilities.work_areas:
|
||||
if any(
|
||||
mower_data.capabilities.work_areas for mower_data in self.data.values()
|
||||
):
|
||||
self._async_add_remove_work_areas()
|
||||
|
||||
@callback
|
||||
@ -161,42 +162,34 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
)
|
||||
|
||||
def _async_add_remove_devices(self) -> None:
|
||||
"""Add new device, remove non-existing device."""
|
||||
"""Add new devices and remove orphaned devices from the registry."""
|
||||
current_devices = set(self.data)
|
||||
|
||||
# Skip update if no changes
|
||||
if current_devices == self._devices_last_update:
|
||||
return
|
||||
|
||||
# Process removed devices
|
||||
removed_devices = self._devices_last_update - current_devices
|
||||
if removed_devices:
|
||||
_LOGGER.debug("Removed devices: %s", ", ".join(map(str, removed_devices)))
|
||||
self._remove_device(removed_devices)
|
||||
|
||||
# Process new device
|
||||
new_devices = current_devices - self._devices_last_update
|
||||
if new_devices:
|
||||
_LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices)))
|
||||
self._add_new_devices(new_devices)
|
||||
|
||||
# Update device state
|
||||
self._devices_last_update = current_devices
|
||||
|
||||
def _remove_device(self, removed_devices: set[str]) -> None:
|
||||
"""Remove device from the registry."""
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for mower_id in removed_devices:
|
||||
if device := device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, str(mower_id))}
|
||||
):
|
||||
|
||||
registered_devices: set[str] = {
|
||||
str(mower_id)
|
||||
for device in device_registry.devices.get_devices_for_config_entry_id(
|
||||
self.config_entry.entry_id
|
||||
)
|
||||
for domain, mower_id in device.identifiers
|
||||
if domain == DOMAIN
|
||||
}
|
||||
|
||||
orphaned_devices = registered_devices - current_devices
|
||||
if orphaned_devices:
|
||||
_LOGGER.debug("Removing orphaned devices: %s", orphaned_devices)
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for mower_id in orphaned_devices:
|
||||
dev = device_registry.async_get_device(identifiers={(DOMAIN, mower_id)})
|
||||
if dev is not None:
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
device_id=dev.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
def _add_new_devices(self, new_devices: set[str]) -> None:
|
||||
"""Add new device and trigger callbacks."""
|
||||
new_devices = current_devices - registered_devices
|
||||
if new_devices:
|
||||
_LOGGER.debug("New devices found: %s", new_devices)
|
||||
for mower_callback in self.new_devices_callbacks:
|
||||
mower_callback(new_devices)
|
||||
|
||||
@ -209,42 +202,39 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
and mower_data.stay_out_zones is not None
|
||||
}
|
||||
|
||||
if not self._zones_last_update:
|
||||
self._zones_last_update = current_zones
|
||||
return
|
||||
|
||||
if current_zones == self._zones_last_update:
|
||||
return
|
||||
|
||||
self._zones_last_update = self._update_stay_out_zones(current_zones)
|
||||
|
||||
def _update_stay_out_zones(
|
||||
self, current_zones: dict[str, set[str]]
|
||||
) -> dict[str, set[str]]:
|
||||
"""Update stay-out zones by adding and removing as needed."""
|
||||
new_zones = {
|
||||
mower_id: zones - self._zones_last_update.get(mower_id, set())
|
||||
for mower_id, zones in current_zones.items()
|
||||
}
|
||||
removed_zones = {
|
||||
mower_id: self._zones_last_update.get(mower_id, set()) - zones
|
||||
for mower_id, zones in current_zones.items()
|
||||
}
|
||||
|
||||
for mower_id, zones in new_zones.items():
|
||||
for zone_callback in self.new_zones_callbacks:
|
||||
zone_callback(mower_id, set(zones))
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
for mower_id, zones in removed_zones.items():
|
||||
for entity_entry in er.async_entries_for_config_entry(
|
||||
entries = er.async_entries_for_config_entry(
|
||||
entity_registry, self.config_entry.entry_id
|
||||
):
|
||||
for zone in zones:
|
||||
if entity_entry.unique_id.startswith(f"{mower_id}_{zone}"):
|
||||
entity_registry.async_remove(entity_entry.entity_id)
|
||||
)
|
||||
|
||||
return current_zones
|
||||
registered_zones: dict[str, set[str]] = {}
|
||||
for mower_id in self.data:
|
||||
registered_zones[mower_id] = set()
|
||||
for entry in entries:
|
||||
uid = entry.unique_id
|
||||
if uid.startswith(f"{mower_id}_") and uid.endswith("_stay_out_zones"):
|
||||
zone_id = uid.removeprefix(f"{mower_id}_").removesuffix(
|
||||
"_stay_out_zones"
|
||||
)
|
||||
registered_zones[mower_id].add(zone_id)
|
||||
|
||||
for mower_id, current_ids in current_zones.items():
|
||||
known_ids = registered_zones.get(mower_id, set())
|
||||
|
||||
new_zones = current_ids - known_ids
|
||||
removed_zones = known_ids - current_ids
|
||||
|
||||
if new_zones:
|
||||
_LOGGER.debug("New stay-out zones: %s", new_zones)
|
||||
for zone_callback in self.new_zones_callbacks:
|
||||
zone_callback(mower_id, new_zones)
|
||||
|
||||
if removed_zones:
|
||||
_LOGGER.debug("Removing stay-out zones: %s", removed_zones)
|
||||
for entry in entries:
|
||||
for zone_id in removed_zones:
|
||||
if entry.unique_id == f"{mower_id}_{zone_id}_stay_out_zones":
|
||||
entity_registry.async_remove(entry.entity_id)
|
||||
|
||||
def _async_add_remove_work_areas(self) -> None:
|
||||
"""Add new work areas, remove non-existing work areas."""
|
||||
@ -254,39 +244,36 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
if mower_data.capabilities.work_areas and mower_data.work_areas is not None
|
||||
}
|
||||
|
||||
if not self._areas_last_update:
|
||||
self._areas_last_update = current_areas
|
||||
return
|
||||
|
||||
if current_areas == self._areas_last_update:
|
||||
return
|
||||
|
||||
self._areas_last_update = self._update_work_areas(current_areas)
|
||||
|
||||
def _update_work_areas(
|
||||
self, current_areas: dict[str, set[int]]
|
||||
) -> dict[str, set[int]]:
|
||||
"""Update work areas by adding and removing as needed."""
|
||||
new_areas = {
|
||||
mower_id: areas - self._areas_last_update.get(mower_id, set())
|
||||
for mower_id, areas in current_areas.items()
|
||||
}
|
||||
removed_areas = {
|
||||
mower_id: self._areas_last_update.get(mower_id, set()) - areas
|
||||
for mower_id, areas in current_areas.items()
|
||||
}
|
||||
|
||||
for mower_id, areas in new_areas.items():
|
||||
for area_callback in self.new_areas_callbacks:
|
||||
area_callback(mower_id, set(areas))
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
for mower_id, areas in removed_areas.items():
|
||||
for entity_entry in er.async_entries_for_config_entry(
|
||||
entries = er.async_entries_for_config_entry(
|
||||
entity_registry, self.config_entry.entry_id
|
||||
):
|
||||
for area in areas:
|
||||
if entity_entry.unique_id.startswith(f"{mower_id}_{area}_"):
|
||||
entity_registry.async_remove(entity_entry.entity_id)
|
||||
)
|
||||
|
||||
return current_areas
|
||||
registered_areas: dict[str, set[int]] = {}
|
||||
for mower_id in self.data:
|
||||
registered_areas[mower_id] = set()
|
||||
for entry in entries:
|
||||
uid = entry.unique_id
|
||||
if uid.startswith(f"{mower_id}_") and uid.endswith("_work_area"):
|
||||
parts = uid.removeprefix(f"{mower_id}_").split("_")
|
||||
area_id_str = parts[0] if parts else None
|
||||
if area_id_str and area_id_str.isdigit():
|
||||
registered_areas[mower_id].add(int(area_id_str))
|
||||
|
||||
for mower_id, current_ids in current_areas.items():
|
||||
known_ids = registered_areas.get(mower_id, set())
|
||||
|
||||
new_areas = current_ids - known_ids
|
||||
removed_areas = known_ids - current_ids
|
||||
|
||||
if new_areas:
|
||||
_LOGGER.debug("New work areas: %s", new_areas)
|
||||
for area_callback in self.new_areas_callbacks:
|
||||
area_callback(mower_id, new_areas)
|
||||
|
||||
if removed_areas:
|
||||
_LOGGER.debug("Removing work areas: %s", removed_areas)
|
||||
for entry in entries:
|
||||
for area_id in removed_areas:
|
||||
if entry.unique_id.startswith(f"{mower_id}_{area_id}_"):
|
||||
entity_registry.async_remove(entry.entity_id)
|
||||
|
@ -37,15 +37,6 @@ ERROR_STATES = [
|
||||
]
|
||||
|
||||
|
||||
@callback
|
||||
def _check_error_free(mower_attributes: MowerAttributes) -> bool:
|
||||
"""Check if the mower has any errors."""
|
||||
return (
|
||||
mower_attributes.mower.state not in ERROR_STATES
|
||||
or mower_attributes.mower.activity not in ERROR_ACTIVITIES
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _work_area_translation_key(work_area_id: int, key: str) -> str:
|
||||
"""Return the translation key."""
|
||||
@ -120,25 +111,20 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):
|
||||
return super().available and self.mower_id in self.coordinator.data
|
||||
|
||||
|
||||
class AutomowerAvailableEntity(AutomowerBaseEntity):
|
||||
class AutomowerControlEntity(AutomowerBaseEntity):
|
||||
"""Replies available when the mower is connected."""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the device is available."""
|
||||
return super().available and self.mower_attributes.metadata.connected
|
||||
return (
|
||||
super().available
|
||||
and self.mower_attributes.metadata.connected
|
||||
and self.mower_attributes.mower.state != MowerStates.OFF
|
||||
)
|
||||
|
||||
|
||||
class AutomowerControlEntity(AutomowerAvailableEntity):
|
||||
"""Replies available when the mower is connected and not in error state."""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the device is available."""
|
||||
return super().available and _check_error_free(self.mower_attributes)
|
||||
|
||||
|
||||
class WorkAreaAvailableEntity(AutomowerAvailableEntity):
|
||||
class WorkAreaAvailableEntity(AutomowerControlEntity):
|
||||
"""Base entity for work areas."""
|
||||
|
||||
def __init__(
|
||||
|
@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import AutomowerConfigEntry
|
||||
from .const import DOMAIN, ERROR_STATES
|
||||
from .coordinator import AutomowerDataUpdateCoordinator
|
||||
from .entity import AutomowerAvailableEntity, handle_sending_exception
|
||||
from .entity import AutomowerBaseEntity, handle_sending_exception
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -89,7 +89,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity):
|
||||
class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity):
|
||||
"""Defining each mower Entity."""
|
||||
|
||||
_attr_name = None
|
||||
|
@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.0.0"]
|
||||
"requirements": ["aioautomower==2.1.1"]
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ from operator import attrgetter
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aioautomower.model import (
|
||||
ExternalReasons,
|
||||
InactiveReasons,
|
||||
MowerAttributes,
|
||||
MowerModes,
|
||||
@ -190,11 +191,37 @@ RESTRICTED_REASONS: list = [
|
||||
RestrictedReasons.PARK_OVERRIDE,
|
||||
RestrictedReasons.SENSOR,
|
||||
RestrictedReasons.WEEK_SCHEDULE,
|
||||
ExternalReasons.AMAZON_ALEXA,
|
||||
ExternalReasons.DEVELOPER_PORTAL,
|
||||
ExternalReasons.GARDENA_SMART_SYSTEM,
|
||||
ExternalReasons.GOOGLE_ASSISTANT,
|
||||
ExternalReasons.HOME_ASSISTANT,
|
||||
ExternalReasons.IFTTT,
|
||||
ExternalReasons.IFTTT_APPLETS,
|
||||
ExternalReasons.IFTTT_CALENDAR_CONNECTION,
|
||||
ExternalReasons.SMART_ROUTINE,
|
||||
ExternalReasons.SMART_ROUTINE_FROST_GUARD,
|
||||
ExternalReasons.SMART_ROUTINE_RAIN_GUARD,
|
||||
ExternalReasons.SMART_ROUTINE_WILDLIFE_PROTECTION,
|
||||
]
|
||||
|
||||
STATE_NO_WORK_AREA_ACTIVE = "no_work_area_active"
|
||||
|
||||
|
||||
@callback
|
||||
def _get_restricted_reason(data: MowerAttributes) -> str:
|
||||
"""Return the restricted reason.
|
||||
|
||||
If there is an external reason, return that instead, if it's available.
|
||||
"""
|
||||
if (
|
||||
data.planner.restricted_reason == RestrictedReasons.EXTERNAL
|
||||
and data.planner.external_reason is not None
|
||||
):
|
||||
return data.planner.external_reason
|
||||
return data.planner.restricted_reason
|
||||
|
||||
|
||||
@callback
|
||||
def _get_work_area_names(data: MowerAttributes) -> list[str]:
|
||||
"""Return a list with all work area names."""
|
||||
@ -400,7 +427,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
|
||||
translation_key="restricted_reason",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
option_fn=lambda data: RESTRICTED_REASONS,
|
||||
value_fn=attrgetter("planner.restricted_reason"),
|
||||
value_fn=_get_restricted_reason,
|
||||
),
|
||||
AutomowerSensorEntityDescription(
|
||||
key="inactive_reason",
|
||||
|
@ -242,16 +242,28 @@
|
||||
"restricted_reason": {
|
||||
"name": "Restricted reason",
|
||||
"state": {
|
||||
"none": "No restrictions",
|
||||
"week_schedule": "Week schedule",
|
||||
"park_override": "Park override",
|
||||
"sensor": "Weather timer",
|
||||
"all_work_areas_completed": "All work areas completed",
|
||||
"amazon_alexa": "Amazon Alexa",
|
||||
"daily_limit": "Daily limit",
|
||||
"developer_portal": "Developer Portal",
|
||||
"external": "External",
|
||||
"fota": "Firmware Over-the-Air update running",
|
||||
"frost": "Frost",
|
||||
"all_work_areas_completed": "All work areas completed",
|
||||
"external": "External",
|
||||
"not_applicable": "Not applicable"
|
||||
"gardena_smart_system": "Gardena Smart System",
|
||||
"google_assistant": "Google Assistant",
|
||||
"home_assistant": "Home Assistant",
|
||||
"ifttt_applets": "IFTTT applets",
|
||||
"ifttt_calendar_connection": "IFTTT calendar connection",
|
||||
"ifttt": "IFTTT",
|
||||
"none": "No restrictions",
|
||||
"not_applicable": "Not applicable",
|
||||
"park_override": "Park override",
|
||||
"sensor": "Weather timer",
|
||||
"smart_routine_frost_guard": "Frost guard",
|
||||
"smart_routine_rain_guard": "Rain guard",
|
||||
"smart_routine_wildlife_protection": "Wildlife protection",
|
||||
"smart_routine": "Generic smart routine",
|
||||
"week_schedule": "Week schedule"
|
||||
}
|
||||
},
|
||||
"total_charging_time": {
|
||||
|
@ -27,7 +27,6 @@ async def async_setup_entry(
|
||||
class HuumDoorSensor(HuumBaseEntity, BinarySensorEntity):
|
||||
"""Representation of a BinarySensor."""
|
||||
|
||||
_attr_name = "Door"
|
||||
_attr_device_class = BinarySensorDeviceClass.DOOR
|
||||
|
||||
def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None:
|
||||
|
@ -89,7 +89,10 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set hvac mode."""
|
||||
if hvac_mode == HVACMode.HEAT:
|
||||
await self._turn_on(self.target_temperature)
|
||||
# Make sure to send integers
|
||||
# The temperature is not always an integer if the user uses Fahrenheit
|
||||
temperature = int(self.target_temperature)
|
||||
await self._turn_on(temperature)
|
||||
elif hvac_mode == HVACMode.OFF:
|
||||
await self.coordinator.huum.turn_off()
|
||||
await self.coordinator.async_refresh()
|
||||
@ -99,6 +102,7 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
if temperature is None or self.hvac_mode != HVACMode.HEAT:
|
||||
return
|
||||
temperature = int(temperature)
|
||||
|
||||
await self._turn_on(temperature)
|
||||
await self.coordinator.async_refresh()
|
||||
|
@ -4,4 +4,8 @@ from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "huum"
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE]
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT]
|
||||
|
||||
CONFIG_STEAMER = 1
|
||||
CONFIG_LIGHT = 2
|
||||
CONFIG_STEAMER_AND_LIGHT = 3
|
||||
|
62
homeassistant/components/huum/light.py
Normal file
62
homeassistant/components/huum/light.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""Control for light."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.light import ColorMode, LightEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import CONFIG_LIGHT, CONFIG_STEAMER_AND_LIGHT
|
||||
from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator
|
||||
from .entity import HuumBaseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HuumConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up light if applicable."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
# Light is configured for this sauna.
|
||||
if coordinator.data.config in [CONFIG_LIGHT, CONFIG_STEAMER_AND_LIGHT]:
|
||||
async_add_entities([HuumLight(coordinator)])
|
||||
|
||||
|
||||
class HuumLight(HuumBaseEntity, LightEntity):
|
||||
"""Representation of a light."""
|
||||
|
||||
_attr_translation_key = "light"
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
_attr_color_mode = ColorMode.ONOFF
|
||||
|
||||
def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None:
|
||||
"""Initialize the light."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_unique_id = coordinator.config_entry.entry_id
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the current light status."""
|
||||
return self.coordinator.data.light == 1
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn device on."""
|
||||
if not self.is_on:
|
||||
await self._toggle_light()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn device off."""
|
||||
if self.is_on:
|
||||
await self._toggle_light()
|
||||
|
||||
async def _toggle_light(self) -> None:
|
||||
await self.coordinator.huum.toggle_light()
|
||||
await self.coordinator.async_refresh()
|
@ -18,5 +18,12 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"light": {
|
||||
"light": {
|
||||
"name": "[%key:component::light::title%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,11 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from pydrawise import Zone
|
||||
from pydrawise import Controller, Zone
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@ -81,8 +81,10 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Hydrawise binary_sensor platform."""
|
||||
coordinators = config_entry.runtime_data
|
||||
|
||||
def _add_new_controllers(controllers: Iterable[Controller]) -> None:
|
||||
entities: list[HydrawiseBinarySensor] = []
|
||||
for controller in coordinators.main.data.controllers.values():
|
||||
for controller in controllers:
|
||||
entities.extend(
|
||||
HydrawiseBinarySensor(coordinators.main, description, controller)
|
||||
for description in CONTROLLER_BINARY_SENSORS
|
||||
@ -98,14 +100,27 @@ async def async_setup_entry(
|
||||
for description in RAIN_SENSOR_BINARY_SENSOR
|
||||
if "rain sensor" in sensor.model.name.lower()
|
||||
)
|
||||
entities.extend(
|
||||
async_add_entities(entities)
|
||||
|
||||
def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None:
|
||||
async_add_entities(
|
||||
HydrawiseZoneBinarySensor(
|
||||
coordinators.main, description, controller, zone_id=zone.id
|
||||
)
|
||||
for zone in controller.zones
|
||||
for zone, controller in zones
|
||||
for description in ZONE_BINARY_SENSORS
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
_add_new_controllers(coordinators.main.data.controllers.values())
|
||||
_add_new_zones(
|
||||
[
|
||||
(zone, coordinators.main.data.zone_id_to_controller[zone.id])
|
||||
for zone in coordinators.main.data.zones.values()
|
||||
]
|
||||
)
|
||||
coordinators.main.new_controllers_callbacks.append(_add_new_controllers)
|
||||
coordinators.main.new_zones_callbacks.append(_add_new_zones)
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(SERVICE_RESUME, None, "resume")
|
||||
platform.async_register_entity_service(
|
||||
|
@ -13,6 +13,7 @@ DOMAIN = "hydrawise"
|
||||
DEFAULT_WATERING_TIME = timedelta(minutes=15)
|
||||
|
||||
MANUFACTURER = "Hydrawise"
|
||||
MODEL_ZONE = "Zone"
|
||||
|
||||
MAIN_SCAN_INTERVAL = timedelta(minutes=5)
|
||||
WATER_USE_SCAN_INTERVAL = timedelta(minutes=60)
|
||||
|
@ -2,17 +2,26 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from pydrawise import HydrawiseBase
|
||||
from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.util.dt import now
|
||||
|
||||
from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MAIN_SCAN_INTERVAL,
|
||||
MODEL_ZONE,
|
||||
WATER_USE_SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
type HydrawiseConfigEntry = ConfigEntry[HydrawiseUpdateCoordinators]
|
||||
|
||||
@ -24,6 +33,7 @@ class HydrawiseData:
|
||||
user: User
|
||||
controllers: dict[int, Controller] = field(default_factory=dict)
|
||||
zones: dict[int, Zone] = field(default_factory=dict)
|
||||
zone_id_to_controller: dict[int, Controller] = field(default_factory=dict)
|
||||
sensors: dict[int, Sensor] = field(default_factory=dict)
|
||||
daily_water_summary: dict[int, ControllerWaterUseSummary] = field(
|
||||
default_factory=dict
|
||||
@ -68,6 +78,13 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
|
||||
update_interval=MAIN_SCAN_INTERVAL,
|
||||
)
|
||||
self.api = api
|
||||
self.new_controllers_callbacks: list[
|
||||
Callable[[Iterable[Controller]], None]
|
||||
] = []
|
||||
self.new_zones_callbacks: list[
|
||||
Callable[[Iterable[tuple[Zone, Controller]]], None]
|
||||
] = []
|
||||
self.async_add_listener(self._add_remove_zones)
|
||||
|
||||
async def _async_update_data(self) -> HydrawiseData:
|
||||
"""Fetch the latest data from Hydrawise."""
|
||||
@ -80,10 +97,81 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
|
||||
controller.zones = await self.api.get_zones(controller)
|
||||
for zone in controller.zones:
|
||||
data.zones[zone.id] = zone
|
||||
data.zone_id_to_controller[zone.id] = controller
|
||||
for sensor in controller.sensors:
|
||||
data.sensors[sensor.id] = sensor
|
||||
return data
|
||||
|
||||
@callback
|
||||
def _add_remove_zones(self) -> None:
|
||||
"""Add newly discovered zones and remove nonexistent ones."""
|
||||
if self.data is None:
|
||||
# Likely a setup error; ignore.
|
||||
# Despite what mypy thinks, this is still reachable. Without this check,
|
||||
# the test_connect_retry test in test_init.py fails.
|
||||
return # type: ignore[unreachable]
|
||||
|
||||
device_registry = dr.async_get(self.hass)
|
||||
devices = dr.async_entries_for_config_entry(
|
||||
device_registry, self.config_entry.entry_id
|
||||
)
|
||||
previous_zones: set[str] = set()
|
||||
previous_zones_by_id: dict[str, DeviceEntry] = {}
|
||||
previous_controllers: set[str] = set()
|
||||
previous_controllers_by_id: dict[str, DeviceEntry] = {}
|
||||
for device in devices:
|
||||
for domain, identifier in device.identifiers:
|
||||
if domain == DOMAIN:
|
||||
if device.model == MODEL_ZONE:
|
||||
previous_zones.add(identifier)
|
||||
previous_zones_by_id[identifier] = device
|
||||
else:
|
||||
previous_controllers.add(identifier)
|
||||
previous_controllers_by_id[identifier] = device
|
||||
continue
|
||||
|
||||
current_zones = {str(zone_id) for zone_id in self.data.zones}
|
||||
current_controllers = {
|
||||
str(controller_id) for controller_id in self.data.controllers
|
||||
}
|
||||
|
||||
if removed_zones := previous_zones - current_zones:
|
||||
LOGGER.debug("Removed zones: %s", ", ".join(removed_zones))
|
||||
for zone_id in removed_zones:
|
||||
device_registry.async_update_device(
|
||||
device_id=previous_zones_by_id[zone_id].id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
if removed_controllers := previous_controllers - current_controllers:
|
||||
LOGGER.debug("Removed controllers: %s", ", ".join(removed_controllers))
|
||||
for controller_id in removed_controllers:
|
||||
device_registry.async_update_device(
|
||||
device_id=previous_controllers_by_id[controller_id].id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
if new_controller_ids := current_controllers - previous_controllers:
|
||||
LOGGER.debug("New controllers found: %s", ", ".join(new_controller_ids))
|
||||
new_controllers = [
|
||||
self.data.controllers[controller_id]
|
||||
for controller_id in map(int, new_controller_ids)
|
||||
]
|
||||
for new_controller_callback in self.new_controllers_callbacks:
|
||||
new_controller_callback(new_controllers)
|
||||
|
||||
if new_zone_ids := current_zones - previous_zones:
|
||||
LOGGER.debug("New zones found: %s", ", ".join(new_zone_ids))
|
||||
new_zones = [
|
||||
(
|
||||
self.data.zones[zone_id],
|
||||
self.data.zone_id_to_controller[zone_id],
|
||||
)
|
||||
for zone_id in map(int, new_zone_ids)
|
||||
]
|
||||
for new_zone_callback in self.new_zones_callbacks:
|
||||
new_zone_callback(new_zones)
|
||||
|
||||
|
||||
class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
|
||||
"""Data Update Coordinator for Hydrawise Water Use.
|
||||
|
@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .const import DOMAIN, MANUFACTURER, MODEL_ZONE
|
||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
||||
|
||||
|
||||
@ -40,7 +40,9 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]):
|
||||
identifiers={(DOMAIN, self._device_id)},
|
||||
name=self.zone.name if zone_id is not None else controller.name,
|
||||
model=(
|
||||
"Zone" if zone_id is not None else controller.hardware.model.description
|
||||
MODEL_ZONE
|
||||
if zone_id is not None
|
||||
else controller.hardware.model.description
|
||||
),
|
||||
manufacturer=MANUFACTURER,
|
||||
)
|
||||
|
@ -2,12 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pydrawise.schema import ControllerWaterUseSummary
|
||||
from pydrawise.schema import Controller, ControllerWaterUseSummary, Zone
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@ -31,7 +31,9 @@ class HydrawiseSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
|
||||
def _get_water_use(sensor: HydrawiseSensor) -> ControllerWaterUseSummary:
|
||||
return sensor.coordinator.data.daily_water_summary[sensor.controller.id]
|
||||
return sensor.coordinator.data.daily_water_summary.get(
|
||||
sensor.controller.id, ControllerWaterUseSummary()
|
||||
)
|
||||
|
||||
|
||||
WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
|
||||
@ -133,44 +135,65 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Hydrawise sensor platform."""
|
||||
coordinators = config_entry.runtime_data
|
||||
|
||||
def _has_flow_sensor(controller: Controller) -> bool:
|
||||
daily_water_use_summary = coordinators.water_use.data.daily_water_summary.get(
|
||||
controller.id, ControllerWaterUseSummary()
|
||||
)
|
||||
return daily_water_use_summary.total_use is not None
|
||||
|
||||
def _add_new_controllers(controllers: Iterable[Controller]) -> None:
|
||||
entities: list[HydrawiseSensor] = []
|
||||
for controller in coordinators.main.data.controllers.values():
|
||||
for controller in controllers:
|
||||
entities.extend(
|
||||
HydrawiseSensor(coordinators.water_use, description, controller)
|
||||
for description in WATER_USE_CONTROLLER_SENSORS
|
||||
)
|
||||
entities.extend(
|
||||
HydrawiseSensor(
|
||||
coordinators.water_use, description, controller, zone_id=zone.id
|
||||
)
|
||||
for zone in controller.zones
|
||||
for description in WATER_USE_ZONE_SENSORS
|
||||
)
|
||||
entities.extend(
|
||||
HydrawiseSensor(coordinators.main, description, controller, zone_id=zone.id)
|
||||
for zone in controller.zones
|
||||
for description in ZONE_SENSORS
|
||||
)
|
||||
if (
|
||||
coordinators.water_use.data.daily_water_summary[controller.id].total_use
|
||||
is not None
|
||||
):
|
||||
# we have a flow sensor for this controller
|
||||
if _has_flow_sensor(controller):
|
||||
entities.extend(
|
||||
HydrawiseSensor(coordinators.water_use, description, controller)
|
||||
for description in FLOW_CONTROLLER_SENSORS
|
||||
)
|
||||
entities.extend(
|
||||
async_add_entities(entities)
|
||||
|
||||
def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None:
|
||||
async_add_entities(
|
||||
[
|
||||
HydrawiseSensor(
|
||||
coordinators.water_use, description, controller, zone_id=zone.id
|
||||
)
|
||||
for zone, controller in zones
|
||||
for description in WATER_USE_ZONE_SENSORS
|
||||
]
|
||||
+ [
|
||||
HydrawiseSensor(
|
||||
coordinators.main, description, controller, zone_id=zone.id
|
||||
)
|
||||
for zone, controller in zones
|
||||
for description in ZONE_SENSORS
|
||||
]
|
||||
+ [
|
||||
HydrawiseSensor(
|
||||
coordinators.water_use,
|
||||
description,
|
||||
controller,
|
||||
zone_id=zone.id,
|
||||
)
|
||||
for zone in controller.zones
|
||||
for zone, controller in zones
|
||||
for description in FLOW_ZONE_SENSORS
|
||||
if _has_flow_sensor(controller)
|
||||
]
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
_add_new_controllers(coordinators.main.data.controllers.values())
|
||||
_add_new_zones(
|
||||
[
|
||||
(zone, coordinators.main.data.zone_id_to_controller[zone.id])
|
||||
for zone in coordinators.main.data.zones.values()
|
||||
]
|
||||
)
|
||||
coordinators.main.new_controllers_callbacks.append(_add_new_controllers)
|
||||
coordinators.main.new_zones_callbacks.append(_add_new_zones)
|
||||
|
||||
|
||||
class HydrawiseSensor(HydrawiseEntity, SensorEntity):
|
||||
|
@ -2,12 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from collections.abc import Callable, Coroutine, Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pydrawise import HydrawiseBase, Zone
|
||||
from pydrawise import Controller, HydrawiseBase, Zone
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
SwitchDeviceClass,
|
||||
@ -66,13 +66,22 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Hydrawise switch platform."""
|
||||
coordinators = config_entry.runtime_data
|
||||
|
||||
def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None:
|
||||
async_add_entities(
|
||||
HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id)
|
||||
for controller in coordinators.main.data.controllers.values()
|
||||
for zone in controller.zones
|
||||
for zone, controller in zones
|
||||
for description in SWITCH_TYPES
|
||||
)
|
||||
|
||||
_add_new_zones(
|
||||
[
|
||||
(zone, coordinators.main.data.zone_id_to_controller[zone.id])
|
||||
for zone in coordinators.main.data.zones.values()
|
||||
]
|
||||
)
|
||||
coordinators.main.new_zones_callbacks.append(_add_new_zones)
|
||||
|
||||
|
||||
class HydrawiseSwitch(HydrawiseEntity, SwitchEntity):
|
||||
"""A switch implementation for Hydrawise device."""
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user