Merge branch 'dev' of github.com:home-assistant/core into target_trigger

This commit is contained in:
abmantis 2025-07-29 14:49:43 +01:00
commit 9e8372ce95
575 changed files with 24014 additions and 4698 deletions

View File

@ -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"

View File

@ -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
View File

@ -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

View File

@ -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()),

View File

@ -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(

View File

@ -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."""

View File

@ -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"]
}

View File

@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "silver",
"requirements": ["aioamazondevices==3.5.0"]
"requirements": ["aioamazondevices==3.5.1"]
}

View File

@ -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:

View File

@ -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

View File

@ -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,
}

View 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"
},
)

View File

@ -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",

View File

@ -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%]"

View File

@ -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)},

View File

@ -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"]
}

View File

@ -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": {

View File

@ -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:

View File

@ -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"

View File

@ -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"
}
}
}
}

View File

@ -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"
}
}
}
}

View 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]

View File

@ -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"
]
}

View File

@ -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()

View File

@ -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
}

View File

@ -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],
(
self._voice
if language == self._language
else DEFAULT_VOICES[language]
),
),
gender=options.get(ATTR_GENDER),
),

View File

@ -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"]
}

View File

@ -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

View 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}",
},
)

View 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

View File

@ -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"],

View 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."
}
}
}

View File

@ -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")
try:
return await self._connect_mydevolo(user_input)
except CredentialsInvalid:
return self._show_form(step_id="user", errors={"base": "invalid_auth"})
errors: dict[str, str] = {}
if user_input is not None:
try:
return await self._connect_mydevolo(user_input)
except CredentialsInvalid:
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,42 +55,47 @@ 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")
try:
return await self._connect_mydevolo(user_input)
except CredentialsInvalid:
return self._show_form(
step_id="zeroconf_confirm", errors={"base": "invalid_auth"}
)
errors: dict[str, str] = {}
if user_input is not None:
try:
return await self._connect_mydevolo(user_input)
except CredentialsInvalid:
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")
try:
return await self._connect_mydevolo(user_input)
except CredentialsInvalid:
return self._show_form(
step_id="reauth_confirm", errors={"base": "invalid_auth"}
)
except UuidChanged:
return self._show_form(
step_id="reauth_confirm", errors={"base": "reauth_failed"}
)
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:
errors["base"] = "invalid_auth"
except UuidChanged:
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:
"""Connect to mydevolo."""
@ -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
)

View File

@ -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."

View File

@ -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": [
{

View File

@ -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

View File

@ -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"

View File

@ -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."
}
}
}

View File

@ -222,7 +222,7 @@
"data": {
"time_between_update": "Minimum time between entity updates [s]"
},
"title": "DSMR Options"
"title": "DSMR options"
}
}
}

View File

@ -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."
}
}
}

View File

@ -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)

View File

@ -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."""

View File

@ -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."""

View File

@ -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." }

View File

@ -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

View File

@ -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"
],

View File

@ -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}"
}
}
}

View File

@ -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,

View File

@ -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"]
}

View File

@ -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

View File

@ -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": {

View File

@ -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)

View File

@ -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"]
}

View File

@ -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
}

View File

@ -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,
)
)

View File

@ -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",
]

View File

@ -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",

View File

@ -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%]",

View File

@ -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(),
}

View File

@ -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)

View 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

View 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]

View File

@ -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}",
description=description,
)
for description in sensor_descriptions
]
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

View File

@ -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
},
)

View File

@ -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

View File

@ -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"

View File

@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["habiticalib"],
"quality_scale": "platinum",
"requirements": ["habiticalib==0.4.0"]
"requirements": ["habiticalib==0.4.1"]
}

View File

@ -238,7 +238,7 @@
"name": "OS Agent version"
},
"apparmor_version": {
"name": "Apparmor version"
"name": "AppArmor version"
},
"cpu_percent": {
"name": "CPU percent"

View File

@ -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

View File

@ -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
return await self.async_step_time_menu()
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]
),
},
)

View File

@ -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"

View File

@ -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,
)

View File

@ -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

View File

@ -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": {

View File

@ -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"]
}

View File

@ -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"
}
},

View File

@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["homee"],
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["pyHomee==1.2.10"]
}

View File

@ -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

View File

@ -19,6 +19,7 @@ PLATFORMS = [
Platform.LOCK,
Platform.SENSOR,
Platform.SWITCH,
Platform.VALVE,
Platform.WEATHER,
]

View File

@ -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):

View 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

View 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,
)

View File

@ -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

View File

@ -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,11 +84,15 @@ 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:
self._async_add_remove_stay_out_zones()
if self.data[mower_id].capabilities.work_areas:
self._async_add_remove_work_areas()
if any(
mower_data.capabilities.stay_out_zones
for mower_data in self.data.values()
):
self._async_add_remove_stay_out_zones()
if any(
mower_data.capabilities.work_areas for mower_data in self.data.values()
):
self._async_add_remove_work_areas()
@callback
def handle_websocket_updates(self, ws_data: MowerDictionary) -> None:
@ -161,44 +162,36 @@ 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))}
):
device_registry.async_update_device(
device_id=device.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."""
for mower_callback in self.new_devices_callbacks:
mower_callback(new_devices)
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=dev.id,
remove_config_entry_id=self.config_entry.entry_id,
)
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)
def _async_add_remove_stay_out_zones(self) -> None:
"""Add new stay-out zones, remove non-existing stay-out zones."""
@ -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(
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)
entries = er.async_entries_for_config_entry(
entity_registry, self.config_entry.entry_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(
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)
entries = er.async_entries_for_config_entry(
entity_registry, self.config_entry.entry_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)

View File

@ -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__(

View File

@ -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

View File

@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2.0.0"]
"requirements": ["aioautomower==2.1.1"]
}

View File

@ -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",

View File

@ -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": {

View File

@ -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:

View File

@ -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()

View File

@ -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

View 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()

View File

@ -18,5 +18,12 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"entity": {
"light": {
"light": {
"name": "[%key:component::light::title%]"
}
}
}
}

View File

@ -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,31 +81,46 @@ async def async_setup_entry(
) -> None:
"""Set up the Hydrawise binary_sensor platform."""
coordinators = config_entry.runtime_data
entities: list[HydrawiseBinarySensor] = []
for controller in coordinators.main.data.controllers.values():
entities.extend(
HydrawiseBinarySensor(coordinators.main, description, controller)
for description in CONTROLLER_BINARY_SENSORS
)
entities.extend(
HydrawiseBinarySensor(
coordinators.main,
description,
controller,
sensor_id=sensor.id,
def _add_new_controllers(controllers: Iterable[Controller]) -> None:
entities: list[HydrawiseBinarySensor] = []
for controller in controllers:
entities.extend(
HydrawiseBinarySensor(coordinators.main, description, controller)
for description in CONTROLLER_BINARY_SENSORS
)
for sensor in controller.sensors
for description in RAIN_SENSOR_BINARY_SENSOR
if "rain sensor" in sensor.model.name.lower()
)
entities.extend(
entities.extend(
HydrawiseBinarySensor(
coordinators.main,
description,
controller,
sensor_id=sensor.id,
)
for sensor in controller.sensors
for description in RAIN_SENSOR_BINARY_SENSOR
if "rain sensor" in sensor.model.name.lower()
)
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(

View File

@ -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)

View File

@ -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.

View File

@ -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,
)

View File

@ -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
entities: list[HydrawiseSensor] = []
for controller in coordinators.main.data.controllers.values():
entities.extend(
HydrawiseSensor(coordinators.water_use, description, controller)
for description in WATER_USE_CONTROLLER_SENSORS
def _has_flow_sensor(controller: Controller) -> bool:
daily_water_use_summary = coordinators.water_use.data.daily_water_summary.get(
controller.id, ControllerWaterUseSummary()
)
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
return daily_water_use_summary.total_use is not None
def _add_new_controllers(controllers: Iterable[Controller]) -> None:
entities: list[HydrawiseSensor] = []
for controller in controllers:
entities.extend(
HydrawiseSensor(coordinators.water_use, description, controller)
for description in FLOW_CONTROLLER_SENSORS
for description in WATER_USE_CONTROLLER_SENSORS
)
entities.extend(
if _has_flow_sensor(controller):
entities.extend(
HydrawiseSensor(coordinators.water_use, description, controller)
for description in FLOW_CONTROLLER_SENSORS
)
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
)
async_add_entities(entities)
if _has_flow_sensor(controller)
]
)
_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):

View File

@ -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,12 +66,21 @@ async def async_setup_entry(
) -> None:
"""Set up the Hydrawise switch platform."""
coordinators = config_entry.runtime_data
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 description in SWITCH_TYPES
def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None:
async_add_entities(
HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id)
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):

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