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 uses: actions/checkout@v4.2.2
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3.29.2 uses: github/codeql-action/init@v3.29.4
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.29.2 uses: github/codeql-action/analyze@v3.29.4
with: with:
category: "/language:python" category: "/language:python"

View File

@ -501,6 +501,7 @@ homeassistant.components.tag.*
homeassistant.components.tailscale.* homeassistant.components.tailscale.*
homeassistant.components.tailwind.* homeassistant.components.tailwind.*
homeassistant.components.tami4.* homeassistant.components.tami4.*
homeassistant.components.tankerkoenig.*
homeassistant.components.tautulli.* homeassistant.components.tautulli.*
homeassistant.components.tcp.* homeassistant.components.tcp.*
homeassistant.components.technove.* homeassistant.components.technove.*
@ -546,6 +547,7 @@ homeassistant.components.valve.*
homeassistant.components.velbus.* homeassistant.components.velbus.*
homeassistant.components.vlc_telnet.* homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.* homeassistant.components.vodafone_station.*
homeassistant.components.volvo.*
homeassistant.components.wake_on_lan.* homeassistant.components.wake_on_lan.*
homeassistant.components.wake_word.* homeassistant.components.wake_word.*
homeassistant.components.wallbox.* homeassistant.components.wallbox.*

2
CODEOWNERS generated
View File

@ -1706,6 +1706,8 @@ build.json @home-assistant/supervisor
/tests/components/voip/ @balloob @synesthesiam @jaminh /tests/components/voip/ @balloob @synesthesiam @jaminh
/homeassistant/components/volumio/ @OnFreund /homeassistant/components/volumio/ @OnFreund
/tests/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund
/homeassistant/components/volvo/ @thomasddn
/tests/components/volvo/ @thomasddn
/homeassistant/components/volvooncall/ @molobrakos /homeassistant/components/volvooncall/ @molobrakos
/tests/components/volvooncall/ @molobrakos /tests/components/volvooncall/ @molobrakos
/homeassistant/components/vulcan/ @Antoni-Czaplicki /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]: def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
"""Get domains of components to set up.""" """Get domains of components to set up."""
# Filter out the repeating and common config section [homeassistant] # The common config section [homeassistant] could be filtered here,
domains = { # but that is not necessary, since it corresponds to the core integration,
domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN # that is always unconditionally loaded.
} domains = {cv.domain_key(key) for key in config}
# Add config entry and default domains # Add config entry and default domains
if not hass.config.recovery_mode: if not hass.config.recovery_mode:
@ -726,34 +726,28 @@ async def _async_resolve_domains_and_preload(
together with all their dependencies. together with all their dependencies.
""" """
domains_to_setup = _get_domains(hass, config) domains_to_setup = _get_domains(hass, config)
platform_integrations = conf_util.extract_platform_integrations(
config, BASE_PLATFORMS # Also process all base platforms since we do not require the manifest
) # to list them as dependencies.
# Ensure base platforms that have platform integrations are added to `domains`, # We want to later avoid lock contention when multiple integrations try to load
# so they can be setup first instead of discovering them later when a config # their manifests at once.
# entry setup task notices that it's needed and there is already a long line
# to use the import executor.
# #
# Additionally process integrations that are defined under base platforms
# to speed things up.
# For example if we have # For example if we have
# sensor: # sensor:
# - platform: template # - platform: template
# #
# `template` has to be loaded to validate the config for sensor # `template` has to be loaded to validate the config for sensor.
# so we want to start loading `sensor` as soon as we know # The more platforms under `sensor:`, the longer
# it will be needed. The more platforms under `sensor:`, the longer
# it will take to finish setup for `sensor` because each of these # it will take to finish setup for `sensor` because each of these
# platforms has to be imported before we can validate the config. # platforms has to be imported before we can validate the config.
# #
# Thankfully we are migrating away from the platform pattern # Thankfully we are migrating away from the platform pattern
# so this will be less of a problem in the future. # so this will be less of a problem in the future.
domains_to_setup.update(platform_integrations) platform_integrations = conf_util.extract_platform_integrations(
config, BASE_PLATFORMS
# 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.
additional_domains_to_process = { additional_domains_to_process = {
*BASE_PLATFORMS, *BASE_PLATFORMS,
*chain.from_iterable(platform_integrations.values()), *chain.from_iterable(platform_integrations.values()),

View File

@ -45,6 +45,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
) )
errors = {} errors = {}
await self.async_set_unique_id(user_input[CONF_ID])
self._abort_if_unique_id_configured()
try: try:
await airthings.get_token( await airthings.get_token(
@ -60,9 +62,6 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: 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_create_entry(title="Airthings", data=user_input)
return self.async_show_form( return self.async_show_form(

View File

@ -150,7 +150,7 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
entities = [ entities = [
AirthingsHeaterEnergySensor( AirthingsDeviceSensor(
coordinator, coordinator,
airthings_device, airthings_device,
SENSORS[sensor_types], SENSORS[sensor_types],
@ -162,7 +162,7 @@ async def async_setup_entry(
async_add_entities(entities) async_add_entities(entities)
class AirthingsHeaterEnergySensor( class AirthingsDeviceSensor(
CoordinatorEntity[AirthingsDataUpdateCoordinator], SensorEntity CoordinatorEntity[AirthingsDataUpdateCoordinator], SensorEntity
): ):
"""Representation of a Airthings Sensor device.""" """Representation of a Airthings Sensor device."""

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"], "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", "iot_class": "cloud_polling",
"loggers": ["aioamazondevices"], "loggers": ["aioamazondevices"],
"quality_scale": "silver", "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-known-limitations: todo
docs-supported-devices: done docs-supported-devices: done
docs-supported-functions: done docs-supported-functions: done
docs-troubleshooting: todo docs-troubleshooting: done
docs-use-cases: done docs-use-cases: done
dynamic-devices: todo dynamic-devices: todo
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done
entity-translations: done entity-translations: done
exception-translations: todo exception-translations: done
icon-translations: done icon-translations: done
reconfiguration-flow: todo reconfiguration-flow: todo
repair-issues: repair-issues:

View File

@ -14,6 +14,7 @@ from homeassistant.util.hass_dict import HassKey
from .analytics import Analytics from .analytics import Analytics
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
from .http import AnalyticsDevicesView
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) 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)
websocket_api.async_register_command(hass, websocket_analytics_preferences) websocket_api.async_register_command(hass, websocket_analytics_preferences)
hass.http.register_view(AnalyticsDevicesView)
hass.data[DATA_COMPONENT] = analytics hass.data[DATA_COMPONENT] = analytics
return True 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.const import ATTR_DOMAIN, BASE_PLATFORMS, __version__ as HA_VERSION
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError 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.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.storage import Store 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 @dataclass
class AnalyticsData: class AnalyticsData:
"""Analytics data.""" """Analytics data."""
@ -184,7 +189,7 @@ class Analytics:
return return
if self._data.uuid is None: 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)) await self._store.async_save(dataclass_asdict(self._data))
if self.supervisor: if self.supervisor:
@ -381,3 +386,83 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
).values(): ).values():
domains.update(platforms) domains.update(platforms)
return domains 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", "name": "Analytics",
"after_dependencies": ["energy", "hassio", "recorder"], "after_dependencies": ["energy", "hassio", "recorder"],
"codeowners": ["@home-assistant/core", "@ludeeus"], "codeowners": ["@home-assistant/core", "@ludeeus"],
"dependencies": ["api", "websocket_api"], "dependencies": ["api", "websocket_api", "http"],
"documentation": "https://www.home-assistant.io/integrations/analytics", "documentation": "https://www.home-assistant.io/integrations/analytics",
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",

View File

@ -10,7 +10,7 @@
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "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": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"

View File

@ -311,11 +311,13 @@ def _create_token_stats(
class AnthropicBaseLLMEntity(Entity): class AnthropicBaseLLMEntity(Entity):
"""Anthropic base LLM entity.""" """Anthropic base LLM entity."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the entity.""" """Initialize the entity."""
self.entry = entry self.entry = entry
self.subentry = subentry self.subentry = subentry
self._attr_name = subentry.title
self._attr_unique_id = subentry.subentry_id self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo( self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)}, identifiers={(DOMAIN, subentry.subentry_id)},

View File

@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august", "documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"], "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": { "preset1": {
"name": "Favourite 1", "name": "Favorite 1",
"state_attributes": { "state_attributes": {
"event_type": { "event_type": {
"state": { "state": {
@ -107,7 +107,7 @@
} }
}, },
"preset2": { "preset2": {
"name": "Favourite 2", "name": "Favorite 2",
"state_attributes": { "state_attributes": {
"event_type": { "event_type": {
"state": { "state": {
@ -121,7 +121,7 @@
} }
}, },
"preset3": { "preset3": {
"name": "Favourite 3", "name": "Favorite 3",
"state_attributes": { "state_attributes": {
"event_type": { "event_type": {
"state": { "state": {
@ -135,7 +135,7 @@
} }
}, },
"preset4": { "preset4": {
"name": "Favourite 4", "name": "Favorite 4",
"state_attributes": { "state_attributes": {
"event_type": { "event_type": {
"state": { "state": {

View File

@ -15,23 +15,31 @@ from bluecurrent_api.exceptions import (
) )
from homeassistant.config_entries import ConfigEntry 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.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send 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] type BlueCurrentConfigEntry = ConfigEntry[Connector]
PLATFORMS = [Platform.BUTTON, Platform.SENSOR] PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
CHARGE_POINTS = "CHARGE_POINTS" CHARGE_POINTS = "CHARGE_POINTS"
DATA = "data" DATA = "data"
DELAY = 5 DELAY = 5
GRID = "GRID" GRID = "GRID"
OBJECT = "object" OBJECT = "object"
VALUE_TYPES = ["CH_STATUS"] VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS]
async def async_setup_entry( async def async_setup_entry(
@ -94,7 +102,7 @@ class Connector:
elif object_name in VALUE_TYPES: elif object_name in VALUE_TYPES:
value_data: dict = message[DATA] value_data: dict = message[DATA]
evse_id = value_data.pop(EVSE_ID) 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 # gets grid key / values
elif GRID in object_name: elif GRID in object_name:
@ -106,26 +114,37 @@ class Connector:
"""Handle incoming chargepoint data.""" """Handle incoming chargepoint data."""
await asyncio.gather( await asyncio.gather(
*( *(
self.handle_charge_point( self.handle_charge_point(entry[EVSE_ID], entry)
entry[EVSE_ID], entry[MODEL_TYPE], entry[ATTR_NAME]
)
for entry in charge_points_data for entry in charge_points_data
), ),
self.client.get_grid_status(charge_points_data[0][EVSE_ID]), 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.""" """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) 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.""" """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.""" """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) self.dispatch_charge_point_update_signal(evse_id)
def dispatch_charge_point_update_signal(self, evse_id: str) -> None: 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" EVSE_ID = "evse_id"
MODEL_TYPE = "model_type" 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": { "stop_charge_session": {
"default": "mdi:stop" "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": { "reset": {
"name": "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-adapters==2.0.0",
"bluetooth-auto-recovery==1.5.2", "bluetooth-auto-recovery==1.5.2",
"bluetooth-data-tools==1.28.2", "bluetooth-data-tools==1.28.2",
"dbus-fast==2.43.0", "dbus-fast==2.44.2",
"habluetooth==4.0.1" "habluetooth==4.0.1"
] ]
} }

View File

@ -10,14 +10,8 @@ import random
from typing import Any from typing import Any
from aiohttp import ClientError, ClientResponseError from aiohttp import ClientError, ClientResponseError
from hass_nabucasa import Cloud, CloudError from hass_nabucasa import Cloud, CloudApiError, CloudApiNonRetryableError, CloudError
from hass_nabucasa.api import CloudApiError, CloudApiNonRetryableError from hass_nabucasa.files import FilesError, StorageType, StoredFile, calculate_b64md5
from hass_nabucasa.cloud_api import (
FilesHandlerListEntry,
async_files_delete_file,
async_files_list,
)
from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5
from homeassistant.components.backup import ( from homeassistant.components.backup import (
AgentBackup, AgentBackup,
@ -186,8 +180,7 @@ class CloudBackupAgent(BackupAgent):
""" """
backup = await self._async_get_backup(backup_id) backup = await self._async_get_backup(backup_id)
try: try:
await async_files_delete_file( await self._cloud.files.delete(
self._cloud,
storage_type=StorageType.BACKUP, storage_type=StorageType.BACKUP,
filename=backup["Key"], filename=backup["Key"],
) )
@ -199,12 +192,10 @@ class CloudBackupAgent(BackupAgent):
backups = await self._async_list_backups() backups = await self._async_list_backups()
return [AgentBackup.from_dict(backup["Metadata"]) for backup in 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.""" """List backups."""
try: try:
backups = await async_files_list( backups = await self._cloud.files.list(storage_type=StorageType.BACKUP)
self._cloud, storage_type=StorageType.BACKUP
)
except (ClientError, CloudError) as err: except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to list backups") from err raise BackupAgentError("Failed to list backups") from err
@ -220,7 +211,7 @@ class CloudBackupAgent(BackupAgent):
backup = await self._async_get_backup(backup_id) backup = await self._async_get_backup(backup_id)
return AgentBackup.from_dict(backup["Metadata"]) 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.""" """Return a backup."""
backups = await self._async_list_backups() backups = await self._async_list_backups()

View File

@ -13,6 +13,6 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"], "loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==0.108.0"], "requirements": ["hass-nabucasa==0.110.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@ -17,6 +17,8 @@ from homeassistant.components.tts import (
PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA,
Provider, Provider,
TextToSpeechEntity, TextToSpeechEntity,
TTSAudioRequest,
TTSAudioResponse,
TtsAudioType, TtsAudioType,
Voice, Voice,
) )
@ -332,7 +334,7 @@ class CloudTTSEntity(TextToSpeechEntity):
def default_options(self) -> dict[str, str]: def default_options(self) -> dict[str, str]:
"""Return a dict include default options.""" """Return a dict include default options."""
return { return {
ATTR_AUDIO_OUTPUT: AudioOutput.MP3, ATTR_AUDIO_OUTPUT: AudioOutput.MP3.value,
} }
@property @property
@ -433,6 +435,29 @@ class CloudTTSEntity(TextToSpeechEntity):
return (options[ATTR_AUDIO_OUTPUT], data) 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): class CloudProvider(Provider):
"""Home Assistant Cloud speech API provider.""" """Home Assistant Cloud speech API provider."""
@ -526,9 +551,11 @@ class CloudProvider(Provider):
language=language, language=language,
voice=options.get( voice=options.get(
ATTR_VOICE, ATTR_VOICE,
self._voice (
if language == self._language self._voice
else DEFAULT_VOICES[language], if language == self._language
else DEFAULT_VOICES[language]
),
), ),
gender=options.get(ATTR_GENDER), gender=options.get(ATTR_GENDER),
), ),

View File

@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/compensation", "documentation": "https://www.home-assistant.io/integrations/compensation",
"iot_class": "calculated", "iot_class": "calculated",
"quality_scale": "legacy", "quality_scale": "legacy",
"requirements": ["numpy==2.3.0"] "requirements": ["numpy==2.3.2"]
} }

View File

@ -2,9 +2,10 @@
import logging import logging
from datadog import initialize, statsd from datadog import DogStatsd, initialize
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,
CONF_PORT, 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 import config_validation as cv, state as state_helper
from homeassistant.helpers.typing import ConfigType 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__) _LOGGER = logging.getLogger(__name__)
CONF_RATE = "rate" type DatadogConfigEntry = ConfigEntry[DogStatsd]
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 8125
DEFAULT_PREFIX = "hass"
DEFAULT_RATE = 1
DOMAIN = "datadog"
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
@ -43,63 +49,85 @@ CONFIG_SCHEMA = vol.Schema(
) )
def setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Datadog component.""" """Set up the Datadog integration from YAML, initiating config flow import."""
if DOMAIN not in config:
return True
conf = config[DOMAIN] hass.async_create_task(
host = conf[CONF_HOST] hass.config_entries.flow.async_init(
port = conf[CONF_PORT] DOMAIN,
sample_rate = conf[CONF_RATE] context={"source": SOURCE_IMPORT},
prefix = conf[CONF_PREFIX] 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) initialize(statsd_host=host, statsd_port=port)
def logbook_entry_listener(event): def logbook_entry_listener(event):
"""Listen for logbook entries and send them as events."""
name = event.data.get("name") name = event.data.get("name")
message = event.data.get("message") message = event.data.get("message")
statsd.event( entry.runtime_data.event(
title="Home Assistant", title="Home Assistant",
text=f"%%% \n **{name}** {message} \n %%%", message=f"%%% \n **{name}** {message} \n %%%",
tags=[ tags=[
f"entity:{event.data.get('entity_id')}", f"entity:{event.data.get('entity_id')}",
f"domain:{event.data.get('domain')}", f"domain:{event.data.get('domain')}",
], ],
) )
_LOGGER.debug("Sent event %s", event.data.get("entity_id"))
def state_changed_listener(event): def state_changed_listener(event):
"""Listen for new messages on the bus and sends them to Datadog."""
state = event.data.get("new_state") state = event.data.get("new_state")
if state is None or state.state == STATE_UNKNOWN: if state is None or state.state == STATE_UNKNOWN:
return return
states = dict(state.attributes)
metric = f"{prefix}.{state.domain}" metric = f"{prefix}.{state.domain}"
tags = [f"entity:{state.entity_id}"] tags = [f"entity:{state.entity_id}"]
for key, value in states.items(): for key, value in state.attributes.items():
if isinstance(value, (float, int)): if isinstance(value, (float, int, bool)):
attribute = f"{metric}.{key.replace(' ', '_')}"
value = int(value) if isinstance(value, bool) else value value = int(value) if isinstance(value, bool) else value
statsd.gauge(attribute, value, sample_rate=sample_rate, tags=tags) attribute = f"{metric}.{key.replace(' ', '_')}"
entry.runtime_data.gauge(
_LOGGER.debug("Sent metric %s: %s (tags: %s)", attribute, value, tags) attribute, value, sample_rate=sample_rate, tags=tags
)
try: try:
value = state_helper.state_as_number(state) value = state_helper.state_as_number(state)
entry.runtime_data.gauge(metric, value, sample_rate=sample_rate, tags=tags)
except ValueError: except ValueError:
_LOGGER.debug("Error sending %s: %s (tags: %s)", metric, state.state, tags) pass
return
statsd.gauge(metric, value, sample_rate=sample_rate, tags=tags) entry.async_on_unload(
hass.bus.async_listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener)
_LOGGER.debug("Sent metric %s: %s (tags: %s)", metric, value, tags) )
entry.async_on_unload(
hass.bus.listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener) hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed_listener)
hass.bus.listen(EVENT_STATE_CHANGED, state_changed_listener) )
return True 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", "domain": "datadog",
"name": "Datadog", "name": "Datadog",
"codeowners": [], "codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/datadog", "documentation": "https://www.home-assistant.io/integrations/datadog",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["datadog"], "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 import voluptuous as vol
from homeassistant.config_entries import ( from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
SOURCE_REAUTH,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import configure_mydevolo from . import configure_mydevolo
from .const import DOMAIN, SUPPORTED_MODEL_TYPES from .const import DOMAIN, SUPPORTED_MODEL_TYPES
from .exceptions import CredentialsInvalid, UuidChanged from .exceptions import CredentialsInvalid, UuidChanged
DATA_SCHEMA = vol.Schema(
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
)
class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a devolo HomeControl config flow.""" """Handle a devolo HomeControl config flow."""
VERSION = 1 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( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle a flow initiated by the user.""" """Handle a flow initiated by the user."""
if user_input is None: errors: dict[str, str] = {}
return self._show_form(step_id="user")
try: if user_input is not None:
return await self._connect_mydevolo(user_input) try:
except CredentialsInvalid: return await self._connect_mydevolo(user_input)
return self._show_form(step_id="user", errors={"base": "invalid_auth"}) 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( async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo self, discovery_info: ZeroconfServiceInfo
@ -61,42 +55,47 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle a flow initiated by zeroconf.""" """Handle a flow initiated by zeroconf."""
if user_input is None: errors: dict[str, str] = {}
return self._show_form(step_id="zeroconf_confirm")
try: if user_input is not None:
return await self._connect_mydevolo(user_input) try:
except CredentialsInvalid: return await self._connect_mydevolo(user_input)
return self._show_form( except CredentialsInvalid:
step_id="zeroconf_confirm", errors={"base": "invalid_auth"} errors["base"] = "invalid_auth"
)
return self.async_show_form(
step_id="zeroconf_confirm", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_reauth( async def async_step_reauth(
self, entry_data: Mapping[str, Any] self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle reauthentication.""" """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() return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm( async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle a flow initiated by reauthentication.""" """Handle a flow initiated by reauthentication."""
if user_input is None: errors: dict[str, str] = {}
return self._show_form(step_id="reauth_confirm") data_schema = vol.Schema(
try: {
return await self._connect_mydevolo(user_input) vol.Required(CONF_USERNAME, default=self.init_data[CONF_USERNAME]): str,
except CredentialsInvalid: vol.Required(CONF_PASSWORD): str,
return self._show_form( }
step_id="reauth_confirm", errors={"base": "invalid_auth"} )
)
except UuidChanged: if user_input is not None:
return self._show_form( try:
step_id="reauth_confirm", errors={"base": "reauth_failed"} 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: async def _connect_mydevolo(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Connect to mydevolo.""" """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. # The old user and the new user are not the same. This could mess-up everything as all unique IDs might change.
raise UuidChanged raise UuidChanged
reauth_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort( return self.async_update_reload_and_abort(
self._reauth_entry, data=user_input, unique_id=uuid 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 {},
) )

View File

@ -61,7 +61,7 @@
"message": "Failed to connect to devolo Home Control central unit {gateway_id}." "message": "Failed to connect to devolo Home Control central unit {gateway_id}."
}, },
"invalid_auth": { "invalid_auth": {
"message": "Authentication failed. Please re-authenticaticate with your mydevolo account." "message": "Authentication failed. Please re-authenticate with your mydevolo account."
}, },
"maintenance": { "maintenance": {
"message": "devolo Home Control is currently in maintenance mode." "message": "devolo Home Control is currently in maintenance mode."

View File

@ -8,6 +8,7 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["devolo_plc_api"], "loggers": ["devolo_plc_api"],
"quality_scale": "silver",
"requirements": ["devolo-plc-api==1.5.1"], "requirements": ["devolo-plc-api==1.5.1"],
"zeroconf": [ "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" "message": "Device {title} did not respond"
}, },
"password_protected": { "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": { "password_wrong": {
"message": "The used password is wrong" "message": "The used password is wrong"

View File

@ -2,11 +2,11 @@
"services": { "services": {
"order": { "order": {
"name": "Order", "name": "Order",
"description": "Places a set of orders with Dominos Pizza.", "description": "Places a set of orders with Domino's Pizza.",
"fields": { "fields": {
"order_entity_id": { "order_entity_id": {
"name": "Order entity", "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": { "data": {
"time_between_update": "Minimum time between entity updates [s]" "time_between_update": "Minimum time between entity updates [s]"
}, },
"title": "DSMR Options" "title": "DSMR options"
} }
} }
} }

View File

@ -263,7 +263,7 @@
"issues": { "issues": {
"cannot_subscribe_mqtt_topic": { "cannot_subscribe_mqtt_topic": {
"title": "Cannot subscribe to MQTT topic {topic_title}", "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 dataclasses import dataclass
from deebot_client.capabilities import CapabilityEvent 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 deebot_client.events.water_info import MopAttachedEvent
from sucks import VacBot
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
BinarySensorEntityDescription, BinarySensorEntityDescription,
) )
@ -16,7 +18,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry from . import EcovacsConfigEntry
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity from .entity import (
EcovacsCapabilityEntityDescription,
EcovacsDescriptionEntity,
EcovacsLegacyEntity,
)
from .util import get_supported_entities from .util import get_supported_entities
@ -47,12 +53,23 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Add entities for passed config_entry in HA.""" """Add entities for passed config_entry in HA."""
controller = config_entry.runtime_data
async_add_entities( async_add_entities(
get_supported_entities( get_supported_entities(
config_entry.runtime_data, EcovacsBinarySensor, ENTITY_DESCRIPTIONS 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]( class EcovacsBinarySensor[EventT: Event](
EcovacsDescriptionEntity[CapabilityEvent[EventT]], EcovacsDescriptionEntity[CapabilityEvent[EventT]],
@ -71,3 +88,33 @@ class EcovacsBinarySensor[EventT: Event](
self.async_write_ha_state() self.async_write_ha_state()
self._subscribe(self._capability.event, on_event) 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.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from . import EcovacsConfigEntry from . import EcovacsConfigEntry
@ -225,7 +226,7 @@ async def async_setup_entry(
async_add_entities(entities) async_add_entities(entities)
async def _add_legacy_entities() -> None: async def _add_legacy_lifespan_entities() -> None:
entities = [] entities = []
for device in controller.legacy_devices: for device in controller.legacy_devices:
for description in LEGACY_LIFESPAN_SENSORS: for description in LEGACY_LIFESPAN_SENSORS:
@ -242,14 +243,21 @@ async def async_setup_entry(
async_add_entities(entities) async_add_entities(entities)
def _fire_ecovacs_legacy_lifespan_event(_: Any) -> None: 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: for device in controller.legacy_devices:
config_entry.async_on_unload( config_entry.async_on_unload(
device.lifespanEvents.subscribe( device.lifespanEvents.subscribe(
_fire_ecovacs_legacy_lifespan_event _fire_ecovacs_legacy_lifespan_event
).unsubscribe ).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( class EcovacsSensor(
@ -344,6 +352,44 @@ class EcovacsErrorSensor(
self._subscribe(self._capability.event, on_event) 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): class EcovacsLegacyLifespanSensor(EcovacsLegacyEntity, SensorEntity):
"""Legacy Lifespan sensor.""" """Legacy Lifespan sensor."""

View File

@ -22,7 +22,6 @@ from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_platform from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.util import slugify from homeassistant.util import slugify
from . import EcovacsConfigEntry from . import EcovacsConfigEntry
@ -71,8 +70,7 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
_attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH] _attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH]
_attr_supported_features = ( _attr_supported_features = (
VacuumEntityFeature.BATTERY VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.CLEAN_SPOT | VacuumEntityFeature.CLEAN_SPOT
| VacuumEntityFeature.STOP | VacuumEntityFeature.STOP
| VacuumEntityFeature.START | VacuumEntityFeature.START
@ -89,11 +87,6 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
lambda _: self.schedule_update_ha_state() 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._event_listeners.append(
self.device.lifespanEvents.subscribe( self.device.lifespanEvents.subscribe(
lambda _: self.schedule_update_ha_state() lambda _: self.schedule_update_ha_state()
@ -137,21 +130,6 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
return None 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 @property
def fan_speed(self) -> str | None: def fan_speed(self) -> str | None:
"""Return the fan speed of the vacuum cleaner.""" """Return the fan speed of the vacuum cleaner."""

View File

@ -7,7 +7,7 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["eheimdigital"], "loggers": ["eheimdigital"],
"quality_scale": "bronze", "quality_scale": "platinum",
"requirements": ["eheimdigital==1.3.0"], "requirements": ["eheimdigital==1.3.0"],
"zeroconf": [ "zeroconf": [
{ "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }

View File

@ -46,22 +46,24 @@ rules:
diagnostics: done diagnostics: done
discovery-update-info: done discovery-update-info: done
discovery: done discovery: done
docs-data-update: todo docs-data-update: done
docs-examples: todo docs-examples: done
docs-known-limitations: todo docs-known-limitations: done
docs-supported-devices: done docs-supported-devices: done
docs-supported-functions: done docs-supported-functions: done
docs-troubleshooting: todo docs-troubleshooting: done
docs-use-cases: todo docs-use-cases: done
dynamic-devices: done dynamic-devices: done
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done
entity-translations: done entity-translations: done
exception-translations: done exception-translations: done
icon-translations: todo icon-translations: done
reconfiguration-flow: done reconfiguration-flow: done
repair-issues: todo repair-issues:
status: exempt
comment: No repairs.
stale-devices: done stale-devices: done
# Platinum # Platinum

View File

@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"], "mqtt": ["esphome/discover/#"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": [ "requirements": [
"aioesphomeapi==37.0.2", "aioesphomeapi==37.1.2",
"esphome-dashboard-api==1.3.0", "esphome-dashboard-api==1.3.0",
"bleak-esphome==3.1.0" "bleak-esphome==3.1.0"
], ],

View File

@ -214,7 +214,7 @@
"message": "Unable to establish a connection" "message": "Unable to establish a connection"
}, },
"update_failed": { "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_HIGH,
FAN_LOW, FAN_LOW,
FAN_MEDIUM, FAN_MEDIUM,
FAN_OFF,
SWING_BOTH, SWING_BOTH,
SWING_HORIZONTAL, SWING_HORIZONTAL,
SWING_OFF, SWING_OFF,
@ -31,6 +32,7 @@ from .coordinator import FGLairConfigEntry, FGLairCoordinator
from .entity import FGLairEntity from .entity import FGLairEntity
HA_TO_FUJI_FAN = { HA_TO_FUJI_FAN = {
FAN_OFF: FanSpeed.QUIET,
FAN_LOW: FanSpeed.LOW, FAN_LOW: FanSpeed.LOW,
FAN_MEDIUM: FanSpeed.MEDIUM, FAN_MEDIUM: FanSpeed.MEDIUM,
FAN_HIGH: FanSpeed.HIGH, FAN_HIGH: FanSpeed.HIGH,

View File

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam",
"iot_class": "cloud_polling", "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 import asyncio
from collections.abc import Mapping from collections.abc import Mapping
import contextlib import contextlib
from datetime import datetime, timedelta from datetime import datetime
from errno import EHOSTUNREACH, EIO from errno import EHOSTUNREACH, EIO
import io import io
import logging import logging
@ -52,9 +52,8 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import config_validation as cv, template as template_helper 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.helpers.httpx_client import get_async_client
from homeassistant.setup import async_prepare_setup_platform
from homeassistant.util import slugify from homeassistant.util import slugify
from .camera import GenericCamera, generate_auth from .camera import GenericCamera, generate_auth
@ -569,18 +568,9 @@ async def ws_start_preview(
) )
user_input = flow.preview_image_settings user_input = flow.preview_image_settings
# Create an EntityPlatform, needed for name translations # Create PlatformData, needed for name translations
platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN) platform_data = PlatformData(hass=hass, domain=CAMERA_DOMAIN, platform_name=DOMAIN)
entity_platform = EntityPlatform( await platform_data.async_load_translations()
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()
ha_still_url = None ha_still_url = None
ha_stream_url = None ha_stream_url = None

View File

@ -12,7 +12,7 @@
} }
}, },
"confirm_discovery": { "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": { "error": {

View File

@ -230,7 +230,7 @@ async def async_setup_entry(
calendar_info = calendars[calendar_id] calendar_info = calendars[calendar_id]
else: else:
calendar_info = get_calendar_info( 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) new_calendars.append(calendar_info)
@ -467,7 +467,7 @@ class GoogleCalendarEntity(
else: else:
start = DateOrDatetime(date=dtstart) start = DateOrDatetime(date=dtstart)
end = DateOrDatetime(date=dtend) end = DateOrDatetime(date=dtend)
event = Event.parse_obj( event = Event.model_validate(
{ {
EVENT_SUMMARY: kwargs[EVENT_SUMMARY], EVENT_SUMMARY: kwargs[EVENT_SUMMARY],
"start": start, "start": start,
@ -538,7 +538,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) ->
if EVENT_IN in call.data: if EVENT_IN in call.data:
if EVENT_IN_DAYS in call.data[EVENT_IN]: 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]) start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS])
end_in = start_in + timedelta(days=1) 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) end = DateOrDatetime(date=end_in)
elif EVENT_IN_WEEKS in call.data[EVENT_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]) start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS])
end_in = start_in + timedelta(days=1) end_in = start_in + timedelta(days=1)

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google", "documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["googleapiclient"], "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", "yue-Hant-HK",
"zu-ZA", "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.exceptions import GoogleAPIError, Unauthenticated
from google.api_core.retry import AsyncRetry from google.api_core.retry import AsyncRetry
from google.cloud import speech_v1 from google.cloud import speech_v1
from propcache.api import cached_property
from homeassistant.components.stt import ( from homeassistant.components.stt import (
AudioBitRates, AudioBitRates,
@ -30,6 +31,7 @@ from .const import (
CONF_STT_MODEL, CONF_STT_MODEL,
DEFAULT_STT_MODEL, DEFAULT_STT_MODEL,
DOMAIN, DOMAIN,
HA_TO_GOOGLE_STT_LANG_MAP,
STT_LANGUAGES, STT_LANGUAGES,
) )
@ -68,10 +70,14 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity):
self._client = client self._client = client
self._model = entry.options.get(CONF_STT_MODEL, DEFAULT_STT_MODEL) self._model = entry.options.get(CONF_STT_MODEL, DEFAULT_STT_MODEL)
@property @cached_property
def supported_languages(self) -> list[str]: def supported_languages(self) -> list[str]:
"""Return a list of supported languages.""" """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 @property
def supported_formats(self) -> list[AudioFormats]: def supported_formats(self) -> list[AudioFormats]:
@ -102,6 +108,10 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity):
self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] self, metadata: SpeechMetadata, stream: AsyncIterable[bytes]
) -> SpeechResult: ) -> SpeechResult:
"""Process an audio stream to STT service.""" """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( streaming_config = speech_v1.StreamingRecognitionConfig(
config=speech_v1.RecognitionConfig( config=speech_v1.RecognitionConfig(
encoding=( encoding=(
@ -110,7 +120,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity):
else speech_v1.RecognitionConfig.AudioEncoding.LINEAR16 else speech_v1.RecognitionConfig.AudioEncoding.LINEAR16
), ),
sample_rate_hertz=metadata.sample_rate, sample_rate_hertz=metadata.sample_rate,
language_code=metadata.language, language_code=language_code,
model=self._model, model=self._model,
) )
) )

View File

@ -53,103 +53,51 @@ class GoogleGenerativeAISttEntity(
"""Return a list of supported languages.""" """Return a list of supported languages."""
return [ return [
"af-ZA", "af-ZA",
"sq-AL",
"am-ET", "am-ET",
"ar-DZ", "ar-AE",
"ar-BH", "ar-BH",
"ar-DZ",
"ar-EG", "ar-EG",
"ar-IQ",
"ar-IL", "ar-IL",
"ar-IQ",
"ar-JO", "ar-JO",
"ar-KW", "ar-KW",
"ar-LB", "ar-LB",
"ar-MA", "ar-MA",
"ar-OM", "ar-OM",
"ar-PS",
"ar-QA", "ar-QA",
"ar-SA", "ar-SA",
"ar-PS",
"ar-TN", "ar-TN",
"ar-AE",
"ar-YE", "ar-YE",
"hy-AM",
"az-AZ", "az-AZ",
"eu-ES", "bg-BG",
"bn-BD", "bn-BD",
"bn-IN", "bn-IN",
"bs-BA", "bs-BA",
"bg-BG",
"my-MM",
"ca-ES", "ca-ES",
"zh-CN",
"zh-TW",
"hr-HR",
"cs-CZ", "cs-CZ",
"da-DK", "da-DK",
"nl-BE", "de-AT",
"nl-NL", "de-CH",
"de-DE",
"el-GR",
"en-AU", "en-AU",
"en-CA", "en-CA",
"en-GB",
"en-GH", "en-GH",
"en-HK", "en-HK",
"en-IN",
"en-IE", "en-IE",
"en-IN",
"en-KE", "en-KE",
"en-NZ",
"en-NG", "en-NG",
"en-PK", "en-NZ",
"en-PH", "en-PH",
"en-PK",
"en-SG", "en-SG",
"en-ZA",
"en-TZ", "en-TZ",
"en-GB",
"en-US", "en-US",
"et-EE", "en-ZA",
"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",
"es-AR", "es-AR",
"es-BO", "es-BO",
"es-CL", "es-CL",
@ -157,27 +105,81 @@ class GoogleGenerativeAISttEntity(
"es-CR", "es-CR",
"es-DO", "es-DO",
"es-EC", "es-EC",
"es-SV", "es-ES",
"es-GT", "es-GT",
"es-HN", "es-HN",
"es-MX", "es-MX",
"es-NI", "es-NI",
"es-PA", "es-PA",
"es-PY",
"es-PE", "es-PE",
"es-PR", "es-PR",
"es-ES", "es-PY",
"es-SV",
"es-US", "es-US",
"es-UY", "es-UY",
"es-VE", "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", "su-ID",
"sv-SE",
"sw-KE", "sw-KE",
"sw-TZ", "sw-TZ",
"sv-SE",
"ta-IN", "ta-IN",
"ta-LK",
"ta-MY", "ta-MY",
"ta-SG", "ta-SG",
"ta-LK",
"te-IN", "te-IN",
"th-TH", "th-TH",
"tr-TR", "tr-TR",
@ -186,6 +188,9 @@ class GoogleGenerativeAISttEntity(
"ur-PK", "ur-PK",
"uz-UZ", "uz-UZ",
"vi-VN", "vi-VN",
"zh-CN",
"zh-HK",
"zh-TW",
"zu-ZA", "zu-ZA",
] ]

View File

@ -48,10 +48,13 @@ class GoogleGenerativeAITextToSpeechEntity(
_attr_supported_options = [ATTR_VOICE] _attr_supported_options = [ATTR_VOICE]
# See https://ai.google.dev/gemini-api/docs/speech-generation#languages # 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 = [ _attr_supported_languages = [
"ar-EG", "ar-EG",
"bn-BD", "bn-BD",
"de-DE", "de-DE",
"el-GR",
"en-IN", "en-IN",
"en-US", "en-US",
"es-US", "es-US",

View File

@ -3,7 +3,7 @@
"config": { "config": {
"step": { "step": {
"user": { "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": { "data": {
"name": "[%key:common::config_flow::data::name%]", "name": "[%key:common::config_flow::data::name%]",
"api_key": "[%key:common::config_flow::data::api_key%]", "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 entity_selector: selector.Selector[Any] | vol.Schema
if handler is None: if handler is None:
entity_selector = selector.selector( entity_selector = selector.selector(
{"entity": {"domain": domain, "multiple": True}} {"entity": {"domain": domain, "multiple": True, "reorder": True}}
) )
else: else:
entity_selector = entity_selector_without_own_entities( entity_selector = entity_selector_without_own_entities(
cast(SchemaOptionsFlowHandler, handler.parent_handler), cast(SchemaOptionsFlowHandler, handler.parent_handler),
selector.EntitySelectorConfig(domain=domain, multiple=True), selector.EntitySelectorConfig(domain=domain, multiple=True, reorder=True),
) )
return vol.Schema( 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("name"): selector.TextSelector(),
vol.Required(CONF_ENTITIES): selector.EntitySelector( 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(), vol.Required(CONF_HIDE_MEMBERS, default=False): selector.BooleanSelector(),
} }

View File

@ -1,21 +1,104 @@
"""The Growatt server PV inverter sensor integration.""" """The Growatt server PV inverter sensor integration."""
from homeassistant import config_entries from collections.abc import Mapping
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
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( async def async_setup_entry(
hass: HomeAssistant, entry: config_entries.ConfigEntry hass: HomeAssistant, config_entry: GrowattConfigEntry
) -> bool: ) -> 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 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.""" """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 from __future__ import annotations
import datetime
import json
import logging import logging
import growattServer
from homeassistant.components.sensor import SensorEntity 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.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 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 ( from ..const import DOMAIN
CONF_PLANT_ID, from ..coordinator import GrowattConfigEntry, GrowattCoordinator
DEFAULT_PLANT_ID,
DEFAULT_URL,
DEPRECATED_URLS,
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
)
from .inverter import INVERTER_SENSOR_TYPES from .inverter import INVERTER_SENSOR_TYPES
from .mix import MIX_SENSOR_TYPES from .mix import MIX_SENSOR_TYPES
from .sensor_entity_description import GrowattSensorEntityDescription from .sensor_entity_description import GrowattSensorEntityDescription
@ -34,136 +21,97 @@ from .total import TOTAL_SENSOR_TYPES
_LOGGER = logging.getLogger(__name__) _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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: GrowattConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the Growatt sensor.""" """Set up the Growatt sensor."""
config = {**config_entry.data} # Use runtime_data instead of hass.data
username = config[CONF_USERNAME] data = config_entry.runtime_data
password = config[CONF_PASSWORD]
url = config.get(CONF_URL, DEFAULT_URL)
name = config[CONF_NAME]
# If the URL has been deprecated then change to the default instead entities: list[GrowattSensor] = []
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)
# Initialise the library with the username & a random id each time it is started # Add total sensors
api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username) total_coordinator = data.total_coordinator
api.server_url = url entities.extend(
GrowattSensor(
devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) total_coordinator,
name=f"{config_entry.data['name']} Total",
probe = GrowattData(api, username, password, plant_id, "total") serial_id=config_entry.data["plant_id"],
entities = [ unique_id=f"{config_entry.data['plant_id']}-{description.key}",
GrowattInverter(
probe,
name=f"{name} Total",
unique_id=f"{plant_id}-{description.key}",
description=description, description=description,
) )
for description in TOTAL_SENSOR_TYPES for description in TOTAL_SENSOR_TYPES
] )
# Add sensors for each device in the specified plant. # Add sensors for each device
for device in devices: for device_sn, device_coordinator in data.devices.items():
probe = GrowattData( sensor_descriptions: list = []
api, username, password, device["deviceSn"], device["deviceType"] if device_coordinator.device_type == "inverter":
) sensor_descriptions = list(INVERTER_SENSOR_TYPES)
sensor_descriptions: tuple[GrowattSensorEntityDescription, ...] = () elif device_coordinator.device_type == "tlx":
if device["deviceType"] == "inverter": sensor_descriptions = list(TLX_SENSOR_TYPES)
sensor_descriptions = INVERTER_SENSOR_TYPES elif device_coordinator.device_type == "storage":
elif device["deviceType"] == "tlx": sensor_descriptions = list(STORAGE_SENSOR_TYPES)
probe.plant_id = plant_id elif device_coordinator.device_type == "mix":
sensor_descriptions = TLX_SENSOR_TYPES sensor_descriptions = list(MIX_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
else: else:
_LOGGER.debug( _LOGGER.debug(
"Device type %s was found but is not supported right now", "Device type %s was found but is not supported right now",
device["deviceType"], device_coordinator.device_type,
) )
entities.extend( entities.extend(
[ GrowattSensor(
GrowattInverter( device_coordinator,
probe, name=device_sn,
name=f"{device['deviceAilas']}", serial_id=device_sn,
unique_id=f"{device['deviceSn']}-{description.key}", unique_id=f"{device_sn}-{description.key}",
description=description, description=description,
) )
for description in sensor_descriptions 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.""" """Representation of a Growatt Sensor."""
_attr_has_entity_name = True _attr_has_entity_name = True
entity_description: GrowattSensorEntityDescription entity_description: GrowattSensorEntityDescription
def __init__( def __init__(
self, probe, name, unique_id, description: GrowattSensorEntityDescription self,
coordinator: GrowattCoordinator,
name: str,
serial_id: str,
unique_id: str,
description: GrowattSensorEntityDescription,
) -> None: ) -> None:
"""Initialize a PVOutput sensor.""" """Initialize a PVOutput sensor."""
self.probe = probe super().__init__(coordinator)
self.entity_description = description self.entity_description = description
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
self._attr_icon = "mdi:solar-power" self._attr_icon = "mdi:solar-power"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, probe.device_id)}, identifiers={(DOMAIN, serial_id)},
manufacturer="Growatt", manufacturer="Growatt",
name=name, name=name,
) )
@property @property
def native_value(self): def native_value(self) -> str | int | float | None:
"""Return the state of the sensor.""" """Return the state of the sensor."""
result = self.probe.get_data(self.entity_description) result = self.coordinator.get_data(self.entity_description)
if self.entity_description.precision is not None: if (
isinstance(result, (int, float))
and self.entity_description.precision is not None
):
result = round(result, self.entity_description.precision) result = round(result, self.entity_description.precision)
return result return result
@ -171,182 +119,5 @@ class GrowattInverter(SensorEntity):
def native_unit_of_measurement(self) -> str | None: def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement of the sensor, if any.""" """Return the unit of measurement of the sensor, if any."""
if self.entity_description.currency: if self.entity_description.currency:
return self.probe.get_currency() return self.coordinator.get_currency()
return super().native_unit_of_measurement 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={ data={
CONF_API_USER: str(login.id), CONF_API_USER: str(login.id),
CONF_API_KEY: login.apiToken, CONF_API_KEY: login.apiToken,
CONF_NAME: user.profile.name, # needed for api_call action
CONF_URL: DEFAULT_URL, CONF_URL: DEFAULT_URL,
CONF_VERIFY_SSL: True, CONF_VERIFY_SSL: True,
}, },
@ -200,7 +199,6 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
data={ data={
**user_input, **user_input,
CONF_URL: user_input.get(CONF_URL, DEFAULT_URL), 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.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ( from homeassistant.exceptions import (
ConfigEntryAuthFailed, ConfigEntryAuthFailed,
@ -106,12 +105,6 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
translation_placeholders={"reason": str(e)}, translation_placeholders={"reason": str(e)},
) from 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: async def _async_update_data(self) -> HabiticaData:
try: try:
user = (await self.habitica.get_user()).data user = (await self.habitica.get_user()).data

View File

@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
from yarl import URL 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.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -37,7 +37,7 @@ class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]):
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=NAME, model=NAME,
name=coordinator.config_entry.data[CONF_NAME], name=coordinator.data.user.profile.name,
configuration_url=( configuration_url=(
URL(coordinator.config_entry.data[CONF_URL]) URL(coordinator.config_entry.data[CONF_URL])
/ "profile" / "profile"

View File

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

View File

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

View File

@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
import logging
from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.start import async_at_started 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 ( from .coordinator import (
HereConfigEntry, HereConfigEntry,
HERERoutingDataUpdateCoordinator, HERERoutingDataUpdateCoordinator,
@ -15,6 +17,8 @@ from .coordinator import (
PLATFORMS = [Platform.SENSOR] PLATFORMS = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry) -> bool:
"""Set up HERE Travel Time from a config entry.""" """Set up HERE Travel Time from a config entry."""
@ -43,3 +47,28 @@ async def async_unload_entry(
) -> bool: ) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) 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.core import callback
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import ( from homeassistant.helpers.selector import (
BooleanSelector,
EntitySelector, EntitySelector,
LocationSelector, LocationSelector,
TimeSelector, TimeSelector,
@ -50,6 +51,7 @@ from .const import (
CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LATITUDE,
CONF_ORIGIN_LONGITUDE, CONF_ORIGIN_LONGITUDE,
CONF_ROUTE_MODE, CONF_ROUTE_MODE,
CONF_TRAFFIC_MODE,
DEFAULT_NAME, DEFAULT_NAME,
DOMAIN, DOMAIN,
ROUTE_MODE_FASTEST, ROUTE_MODE_FASTEST,
@ -65,6 +67,7 @@ DEFAULT_OPTIONS = {
CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_ROUTE_MODE: ROUTE_MODE_FASTEST,
CONF_ARRIVAL_TIME: None, CONF_ARRIVAL_TIME: None,
CONF_DEPARTURE_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.""" """Handle a config flow for HERE Travel Time."""
VERSION = 1 VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None: def __init__(self) -> None:
"""Init Config Flow.""" """Init Config Flow."""
@ -307,7 +311,9 @@ class HERETravelTimeOptionsFlow(OptionsFlow):
"""Manage the HERE Travel Time options.""" """Manage the HERE Travel Time options."""
if user_input is not None: if user_input is not None:
self._config = user_input 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( schema = self.add_suggested_values_to_schema(
vol.Schema( vol.Schema(
@ -318,12 +324,21 @@ class HERETravelTimeOptionsFlow(OptionsFlow):
CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE] CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE]
), ),
): vol.In(ROUTE_MODES), ): 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: self.config_entry.options.get(
CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE] 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_DEPARTURE = "departure"
CONF_ARRIVAL_TIME = "arrival_time" CONF_ARRIVAL_TIME = "arrival_time"
CONF_DEPARTURE_TIME = "departure_time" CONF_DEPARTURE_TIME = "departure_time"
CONF_TRAFFIC_MODE = "traffic_mode"
DEFAULT_NAME = "HERE Travel Time" DEFAULT_NAME = "HERE Travel Time"

View File

@ -13,6 +13,7 @@ from here_routing import (
Return, Return,
RoutingMode, RoutingMode,
Spans, Spans,
TrafficMode,
TransportMode, TransportMode,
) )
import here_transit import here_transit
@ -44,6 +45,7 @@ from .const import (
CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LATITUDE,
CONF_ORIGIN_LONGITUDE, CONF_ORIGIN_LONGITUDE,
CONF_ROUTE_MODE, CONF_ROUTE_MODE,
CONF_TRAFFIC_MODE,
DEFAULT_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL,
DOMAIN, DOMAIN,
ROUTE_MODE_FASTEST, ROUTE_MODE_FASTEST,
@ -87,7 +89,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
_LOGGER.debug( _LOGGER.debug(
( (
"Requesting route for origin: %s, destination: %s, route_mode: %s," "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.origin,
params.destination, params.destination,
@ -95,6 +97,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
TransportMode(params.travel_mode), TransportMode(params.travel_mode),
params.arrival, params.arrival,
params.departure, params.departure,
params.traffic_mode,
) )
try: try:
@ -109,6 +112,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
routing_mode=params.route_mode, routing_mode=params.route_mode,
arrival_time=params.arrival, arrival_time=params.arrival,
departure_time=params.departure, departure_time=params.departure,
traffic_mode=params.traffic_mode,
return_values=[Return.POLYINE, Return.SUMMARY], return_values=[Return.POLYINE, Return.SUMMARY],
spans=[Spans.NAMES], spans=[Spans.NAMES],
) )
@ -350,6 +354,11 @@ def prepare_parameters(
if config_entry.options[CONF_ROUTE_MODE] == ROUTE_MODE_FASTEST if config_entry.options[CONF_ROUTE_MODE] == ROUTE_MODE_FASTEST
else RoutingMode.SHORT else RoutingMode.SHORT
) )
traffic_mode = (
TrafficMode.DISABLED
if config_entry.options[CONF_TRAFFIC_MODE] is False
else TrafficMode.DEFAULT
)
return HERETravelTimeAPIParams( return HERETravelTimeAPIParams(
destination=destination, destination=destination,
@ -358,6 +367,7 @@ def prepare_parameters(
route_mode=route_mode, route_mode=route_mode,
arrival=arrival, arrival=arrival,
departure=departure, departure=departure,
traffic_mode=traffic_mode,
) )

View File

@ -6,7 +6,7 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import TypedDict from typing import TypedDict
from here_routing import RoutingMode from here_routing import RoutingMode, TrafficMode
class HERETravelTimeData(TypedDict): class HERETravelTimeData(TypedDict):
@ -32,3 +32,4 @@ class HERETravelTimeAPIParams:
route_mode: RoutingMode route_mode: RoutingMode
arrival: datetime | None arrival: datetime | None
departure: datetime | None departure: datetime | None
traffic_mode: TrafficMode

View File

@ -60,8 +60,11 @@
"step": { "step": {
"init": { "init": {
"data": { "data": {
"traffic_mode": "Traffic mode", "traffic_mode": "Use traffic and time-aware routing",
"route_mode": "Route mode" "route_mode": "Route mode"
},
"data_description": {
"traffic_mode": "Needed for defining arrival/departure times"
} }
}, },
"time_menu": { "time_menu": {

View File

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday", "documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling", "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_caffe_latte": "Caffe latte",
"consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth", "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_beverage_warm_milk": "Warm milk",
"consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner 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_grosser_brauner": "Grosser Brauner",
"consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter", "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_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_flat_white": "Flat white",
"consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado", "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado",
"consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe 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_intensive_heat": "Intensive heat",
"cooking_oven_program_heating_mode_keep_warm": "Keep warm", "cooking_oven_program_heating_mode_keep_warm": "Keep warm",
"cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware", "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_desiccation": "Desiccation",
"cooking_oven_program_heating_mode_defrost": "Defrost", "cooking_oven_program_heating_mode_defrost": "Defrost",
"cooking_oven_program_heating_mode_proof": "Proof", "cooking_oven_program_heating_mode_proof": "Proof",
@ -316,8 +316,8 @@
"laundry_care_washer_program_monsoon": "Monsoon", "laundry_care_washer_program_monsoon": "Monsoon",
"laundry_care_washer_program_outdoor": "Outdoor", "laundry_care_washer_program_outdoor": "Outdoor",
"laundry_care_washer_program_plush_toy": "Plush toy", "laundry_care_washer_program_plush_toy": "Plush toy",
"laundry_care_washer_program_shirts_blouses": "Shirts blouses", "laundry_care_washer_program_shirts_blouses": "Shirts/blouses",
"laundry_care_washer_program_sport_fitness": "Sport fitness", "laundry_care_washer_program_sport_fitness": "Sport/fitness",
"laundry_care_washer_program_towels": "Towels", "laundry_care_washer_program_towels": "Towels",
"laundry_care_washer_program_water_proof": "Water proof", "laundry_care_washer_program_water_proof": "Water proof",
"laundry_care_washer_program_power_speed_59": "Power speed <59 min", "laundry_care_washer_program_power_speed_59": "Power speed <59 min",
@ -582,7 +582,7 @@
}, },
"consumer_products_cleaning_robot_option_cleaning_mode": { "consumer_products_cleaning_robot_option_cleaning_mode": {
"name": "Cleaning mode", "name": "Cleaning mode",
"description": "Defines the favoured cleaning mode." "description": "Defines the favored cleaning mode."
}, },
"consumer_products_coffee_maker_option_bean_amount": { "consumer_products_coffee_maker_option_bean_amount": {
"name": "Bean amount", "name": "Bean amount",
@ -670,7 +670,7 @@
}, },
"cooking_oven_option_setpoint_temperature": { "cooking_oven_option_setpoint_temperature": {
"name": "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": { "b_s_h_common_option_duration": {
"name": "Duration", "name": "Duration",
@ -1291,9 +1291,9 @@
"state": { "state": {
"cooking_hood_enum_type_color_temperature_custom": "Custom", "cooking_hood_enum_type_color_temperature_custom": "Custom",
"cooking_hood_enum_type_color_temperature_warm": "Warm", "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": "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" "cooking_hood_enum_type_color_temperature_cold": "Cold"
} }
}, },

View File

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

View File

@ -28,16 +28,19 @@ rules:
unique-config-entry: done unique-config-entry: done
# Silver # Silver
action-exceptions: todo action-exceptions: done
config-entry-unloading: done config-entry-unloading: done
docs-configuration-parameters: todo docs-configuration-parameters:
docs-installation-parameters: todo status: exempt
comment: |
The integration does not have options.
docs-installation-parameters: done
entity-unavailable: done entity-unavailable: done
integration-owner: done integration-owner: done
log-when-unavailable: done log-when-unavailable: done
parallel-updates: done parallel-updates: done
reauthentication-flow: todo reauthentication-flow: done
test-coverage: todo test-coverage: done
# Gold # Gold
devices: done devices: done
@ -49,16 +52,16 @@ rules:
docs-known-limitations: todo docs-known-limitations: todo
docs-supported-devices: todo docs-supported-devices: todo
docs-supported-functions: todo docs-supported-functions: todo
docs-troubleshooting: todo docs-troubleshooting: done
docs-use-cases: todo docs-use-cases: todo
dynamic-devices: todo dynamic-devices: todo
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done
entity-translations: done entity-translations: done
exception-translations: todo exception-translations: done
icon-translations: done icon-translations: done
reconfiguration-flow: todo reconfiguration-flow: done
repair-issues: todo repair-issues: todo
stale-devices: todo stale-devices: todo

View File

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

View File

@ -33,6 +33,7 @@ from homematicip.device import (
TemperatureHumiditySensorOutdoor, TemperatureHumiditySensorOutdoor,
TemperatureHumiditySensorWithoutDisplay, TemperatureHumiditySensorWithoutDisplay,
TiltVibrationSensor, TiltVibrationSensor,
WateringActuator,
WeatherSensor, WeatherSensor,
WeatherSensorPlus, WeatherSensorPlus,
WeatherSensorPro, WeatherSensorPro,
@ -45,6 +46,7 @@ from homeassistant.components.sensor import (
SensorStateClass, SensorStateClass,
) )
from homeassistant.const import ( from homeassistant.const import (
CONCENTRATION_GRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
DEGREE, DEGREE,
LIGHT_LUX, LIGHT_LUX,
@ -167,6 +169,29 @@ def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]:
HomematicipTiltStateSensor(hap, device), HomematicipTiltStateSensor(hap, device),
HomematicipTiltAngleSensor(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: [ WeatherSensor: lambda device: [
HomematicipTemperatureSensor(hap, device), HomematicipTemperatureSensor(hap, device),
HomematicipHumiditySensor(hap, device), HomematicipHumiditySensor(hap, device),
@ -267,6 +292,65 @@ async def async_setup_entry(
async_add_entities(entities) 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): class HomematicipTiltAngleSensor(HomematicipGenericEntity, SensorEntity):
"""Representation of the HomematicIP tilt angle sensor.""" """Representation of the HomematicIP tilt angle sensor."""
@ -459,7 +543,9 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity):
class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity):
"""Representation of the HomematicIP absolute humidity sensor.""" """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 _attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, hap: HomematicipHAP, device) -> None: def __init__(self, hap: HomematicipHAP, device) -> None:
@ -467,7 +553,7 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity):
super().__init__(hap, device, post="Absolute Humidity") super().__init__(hap, device, post="Absolute Humidity")
@property @property
def native_value(self) -> int | None: def native_value(self) -> float | None:
"""Return the state.""" """Return the state."""
if self.functional_channel is None: if self.functional_channel is None:
return None return None
@ -481,8 +567,7 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity):
): ):
return None return None
# Convert from g/m³ to mg/m³ return round(value, 3)
return int(float(value) * 1000)
class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): 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 . import AutomowerConfigEntry
from .coordinator import AutomowerDataUpdateCoordinator from .coordinator import AutomowerDataUpdateCoordinator
from .entity import ( from .entity import AutomowerControlEntity, handle_sending_exception
AutomowerAvailableEntity,
_check_error_free,
handle_sending_exception,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -45,7 +41,6 @@ MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = (
AutomowerButtonEntityDescription( AutomowerButtonEntityDescription(
key="sync_clock", key="sync_clock",
translation_key="sync_clock", translation_key="sync_clock",
available_fn=_check_error_free,
press_fn=lambda session, mower_id: session.commands.set_datetime(mower_id), 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)) _async_add_new_devices(set(coordinator.data))
class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity): class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity):
"""Defining the AutomowerButtonEntity.""" """Defining the AutomowerButtonEntity."""
entity_description: AutomowerButtonEntityDescription entity_description: AutomowerButtonEntityDescription

View File

@ -58,9 +58,6 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
self.new_devices_callbacks: list[Callable[[set[str]], None]] = [] self.new_devices_callbacks: list[Callable[[set[str]], None]] = []
self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = [] self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = []
self.new_areas_callbacks: list[Callable[[str, set[int]], 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 @override
@callback @callback
@ -87,11 +84,15 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
"""Handle data updates and process dynamic entity management.""" """Handle data updates and process dynamic entity management."""
if self.data is not None: if self.data is not None:
self._async_add_remove_devices() self._async_add_remove_devices()
for mower_id in self.data: if any(
if self.data[mower_id].capabilities.stay_out_zones: mower_data.capabilities.stay_out_zones
self._async_add_remove_stay_out_zones() for mower_data in self.data.values()
if self.data[mower_id].capabilities.work_areas: ):
self._async_add_remove_work_areas() 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 @callback
def handle_websocket_updates(self, ws_data: MowerDictionary) -> None: def handle_websocket_updates(self, ws_data: MowerDictionary) -> None:
@ -161,44 +162,36 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
) )
def _async_add_remove_devices(self) -> None: 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) 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) 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: registered_devices: set[str] = {
"""Add new device and trigger callbacks.""" str(mower_id)
for mower_callback in self.new_devices_callbacks: for device in device_registry.devices.get_devices_for_config_entry_id(
mower_callback(new_devices) 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: def _async_add_remove_stay_out_zones(self) -> None:
"""Add new stay-out zones, remove non-existing stay-out zones.""" """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 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) entity_registry = er.async_get(self.hass)
for mower_id, zones in removed_zones.items(): entries = er.async_entries_for_config_entry(
for entity_entry in er.async_entries_for_config_entry( entity_registry, self.config_entry.entry_id
entity_registry, self.config_entry.entry_id )
):
for zone in zones:
if entity_entry.unique_id.startswith(f"{mower_id}_{zone}"):
entity_registry.async_remove(entity_entry.entity_id)
return current_zones registered_zones: dict[str, set[str]] = {}
for mower_id in self.data:
registered_zones[mower_id] = set()
for entry in entries:
uid = entry.unique_id
if uid.startswith(f"{mower_id}_") and uid.endswith("_stay_out_zones"):
zone_id = uid.removeprefix(f"{mower_id}_").removesuffix(
"_stay_out_zones"
)
registered_zones[mower_id].add(zone_id)
for mower_id, current_ids in current_zones.items():
known_ids = registered_zones.get(mower_id, set())
new_zones = current_ids - known_ids
removed_zones = known_ids - current_ids
if new_zones:
_LOGGER.debug("New stay-out zones: %s", new_zones)
for zone_callback in self.new_zones_callbacks:
zone_callback(mower_id, new_zones)
if removed_zones:
_LOGGER.debug("Removing stay-out zones: %s", removed_zones)
for entry in entries:
for zone_id in removed_zones:
if entry.unique_id == f"{mower_id}_{zone_id}_stay_out_zones":
entity_registry.async_remove(entry.entity_id)
def _async_add_remove_work_areas(self) -> None: def _async_add_remove_work_areas(self) -> None:
"""Add new work areas, remove non-existing work areas.""" """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 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) entity_registry = er.async_get(self.hass)
for mower_id, areas in removed_areas.items(): entries = er.async_entries_for_config_entry(
for entity_entry in er.async_entries_for_config_entry( entity_registry, self.config_entry.entry_id
entity_registry, self.config_entry.entry_id )
):
for area in areas:
if entity_entry.unique_id.startswith(f"{mower_id}_{area}_"):
entity_registry.async_remove(entity_entry.entity_id)
return current_areas registered_areas: dict[str, set[int]] = {}
for mower_id in self.data:
registered_areas[mower_id] = set()
for entry in entries:
uid = entry.unique_id
if uid.startswith(f"{mower_id}_") and uid.endswith("_work_area"):
parts = uid.removeprefix(f"{mower_id}_").split("_")
area_id_str = parts[0] if parts else None
if area_id_str and area_id_str.isdigit():
registered_areas[mower_id].add(int(area_id_str))
for mower_id, current_ids in current_areas.items():
known_ids = registered_areas.get(mower_id, set())
new_areas = current_ids - known_ids
removed_areas = known_ids - current_ids
if new_areas:
_LOGGER.debug("New work areas: %s", new_areas)
for area_callback in self.new_areas_callbacks:
area_callback(mower_id, new_areas)
if removed_areas:
_LOGGER.debug("Removing work areas: %s", removed_areas)
for entry in entries:
for area_id in removed_areas:
if entry.unique_id.startswith(f"{mower_id}_{area_id}_"):
entity_registry.async_remove(entry.entity_id)

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 @callback
def _work_area_translation_key(work_area_id: int, key: str) -> str: def _work_area_translation_key(work_area_id: int, key: str) -> str:
"""Return the translation key.""" """Return the translation key."""
@ -120,25 +111,20 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):
return super().available and self.mower_id in self.coordinator.data return super().available and self.mower_id in self.coordinator.data
class AutomowerAvailableEntity(AutomowerBaseEntity): class AutomowerControlEntity(AutomowerBaseEntity):
"""Replies available when the mower is connected.""" """Replies available when the mower is connected."""
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if the device is available.""" """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): class WorkAreaAvailableEntity(AutomowerControlEntity):
"""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):
"""Base entity for work areas.""" """Base entity for work areas."""
def __init__( def __init__(

View File

@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AutomowerConfigEntry from . import AutomowerConfigEntry
from .const import DOMAIN, ERROR_STATES from .const import DOMAIN, ERROR_STATES
from .coordinator import AutomowerDataUpdateCoordinator from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerAvailableEntity, handle_sending_exception from .entity import AutomowerBaseEntity, handle_sending_exception
_LOGGER = logging.getLogger(__name__) _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.""" """Defining each mower Entity."""
_attr_name = None _attr_name = None

View File

@ -8,5 +8,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aioautomower"], "loggers": ["aioautomower"],
"quality_scale": "silver", "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 typing import TYPE_CHECKING, Any
from aioautomower.model import ( from aioautomower.model import (
ExternalReasons,
InactiveReasons, InactiveReasons,
MowerAttributes, MowerAttributes,
MowerModes, MowerModes,
@ -190,11 +191,37 @@ RESTRICTED_REASONS: list = [
RestrictedReasons.PARK_OVERRIDE, RestrictedReasons.PARK_OVERRIDE,
RestrictedReasons.SENSOR, RestrictedReasons.SENSOR,
RestrictedReasons.WEEK_SCHEDULE, 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" 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 @callback
def _get_work_area_names(data: MowerAttributes) -> list[str]: def _get_work_area_names(data: MowerAttributes) -> list[str]:
"""Return a list with all work area names.""" """Return a list with all work area names."""
@ -400,7 +427,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
translation_key="restricted_reason", translation_key="restricted_reason",
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
option_fn=lambda data: RESTRICTED_REASONS, option_fn=lambda data: RESTRICTED_REASONS,
value_fn=attrgetter("planner.restricted_reason"), value_fn=_get_restricted_reason,
), ),
AutomowerSensorEntityDescription( AutomowerSensorEntityDescription(
key="inactive_reason", key="inactive_reason",

View File

@ -242,16 +242,28 @@
"restricted_reason": { "restricted_reason": {
"name": "Restricted reason", "name": "Restricted reason",
"state": { "state": {
"none": "No restrictions", "all_work_areas_completed": "All work areas completed",
"week_schedule": "Week schedule", "amazon_alexa": "Amazon Alexa",
"park_override": "Park override",
"sensor": "Weather timer",
"daily_limit": "Daily limit", "daily_limit": "Daily limit",
"developer_portal": "Developer Portal",
"external": "External",
"fota": "Firmware Over-the-Air update running", "fota": "Firmware Over-the-Air update running",
"frost": "Frost", "frost": "Frost",
"all_work_areas_completed": "All work areas completed", "gardena_smart_system": "Gardena Smart System",
"external": "External", "google_assistant": "Google Assistant",
"not_applicable": "Not applicable" "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": { "total_charging_time": {

View File

@ -27,7 +27,6 @@ async def async_setup_entry(
class HuumDoorSensor(HuumBaseEntity, BinarySensorEntity): class HuumDoorSensor(HuumBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor.""" """Representation of a BinarySensor."""
_attr_name = "Door"
_attr_device_class = BinarySensorDeviceClass.DOOR _attr_device_class = BinarySensorDeviceClass.DOOR
def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: 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: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode.""" """Set hvac mode."""
if hvac_mode == HVACMode.HEAT: 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: elif hvac_mode == HVACMode.OFF:
await self.coordinator.huum.turn_off() await self.coordinator.huum.turn_off()
await self.coordinator.async_refresh() await self.coordinator.async_refresh()
@ -99,6 +102,7 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
temperature = kwargs.get(ATTR_TEMPERATURE) temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None or self.hvac_mode != HVACMode.HEAT: if temperature is None or self.hvac_mode != HVACMode.HEAT:
return return
temperature = int(temperature)
await self._turn_on(temperature) await self._turn_on(temperature)
await self.coordinator.async_refresh() await self.coordinator.async_refresh()

View File

@ -4,4 +4,8 @@ from homeassistant.const import Platform
DOMAIN = "huum" 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": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "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 __future__ import annotations
from collections.abc import Callable from collections.abc import Callable, Iterable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pydrawise import Zone from pydrawise import Controller, Zone
import voluptuous as vol import voluptuous as vol
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
@ -81,31 +81,46 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Hydrawise binary_sensor platform.""" """Set up the Hydrawise binary_sensor platform."""
coordinators = config_entry.runtime_data coordinators = config_entry.runtime_data
entities: list[HydrawiseBinarySensor] = []
for controller in coordinators.main.data.controllers.values(): def _add_new_controllers(controllers: Iterable[Controller]) -> None:
entities.extend( entities: list[HydrawiseBinarySensor] = []
HydrawiseBinarySensor(coordinators.main, description, controller) for controller in controllers:
for description in CONTROLLER_BINARY_SENSORS entities.extend(
) HydrawiseBinarySensor(coordinators.main, description, controller)
entities.extend( for description in CONTROLLER_BINARY_SENSORS
HydrawiseBinarySensor(
coordinators.main,
description,
controller,
sensor_id=sensor.id,
) )
for sensor in controller.sensors entities.extend(
for description in RAIN_SENSOR_BINARY_SENSOR HydrawiseBinarySensor(
if "rain sensor" in sensor.model.name.lower() coordinators.main,
) description,
entities.extend( 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( HydrawiseZoneBinarySensor(
coordinators.main, description, controller, zone_id=zone.id coordinators.main, description, controller, zone_id=zone.id
) )
for zone in controller.zones for zone, controller in zones
for description in ZONE_BINARY_SENSORS 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 = entity_platform.async_get_current_platform()
platform.async_register_entity_service(SERVICE_RESUME, None, "resume") platform.async_register_entity_service(SERVICE_RESUME, None, "resume")
platform.async_register_entity_service( platform.async_register_entity_service(

View File

@ -13,6 +13,7 @@ DOMAIN = "hydrawise"
DEFAULT_WATERING_TIME = timedelta(minutes=15) DEFAULT_WATERING_TIME = timedelta(minutes=15)
MANUFACTURER = "Hydrawise" MANUFACTURER = "Hydrawise"
MODEL_ZONE = "Zone"
MAIN_SCAN_INTERVAL = timedelta(minutes=5) MAIN_SCAN_INTERVAL = timedelta(minutes=5)
WATER_USE_SCAN_INTERVAL = timedelta(minutes=60) WATER_USE_SCAN_INTERVAL = timedelta(minutes=60)

View File

@ -2,17 +2,26 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pydrawise import HydrawiseBase from pydrawise import HydrawiseBase
from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone
from homeassistant.config_entries import ConfigEntry 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.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.dt import now 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] type HydrawiseConfigEntry = ConfigEntry[HydrawiseUpdateCoordinators]
@ -24,6 +33,7 @@ class HydrawiseData:
user: User user: User
controllers: dict[int, Controller] = field(default_factory=dict) controllers: dict[int, Controller] = field(default_factory=dict)
zones: dict[int, Zone] = 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) sensors: dict[int, Sensor] = field(default_factory=dict)
daily_water_summary: dict[int, ControllerWaterUseSummary] = field( daily_water_summary: dict[int, ControllerWaterUseSummary] = field(
default_factory=dict default_factory=dict
@ -68,6 +78,13 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
update_interval=MAIN_SCAN_INTERVAL, update_interval=MAIN_SCAN_INTERVAL,
) )
self.api = api 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: async def _async_update_data(self) -> HydrawiseData:
"""Fetch the latest data from Hydrawise.""" """Fetch the latest data from Hydrawise."""
@ -80,10 +97,81 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
controller.zones = await self.api.get_zones(controller) controller.zones = await self.api.get_zones(controller)
for zone in controller.zones: for zone in controller.zones:
data.zones[zone.id] = zone data.zones[zone.id] = zone
data.zone_id_to_controller[zone.id] = controller
for sensor in controller.sensors: for sensor in controller.sensors:
data.sensors[sensor.id] = sensor data.sensors[sensor.id] = sensor
return data 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): class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
"""Data Update Coordinator for Hydrawise Water Use. """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.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER from .const import DOMAIN, MANUFACTURER, MODEL_ZONE
from .coordinator import HydrawiseDataUpdateCoordinator from .coordinator import HydrawiseDataUpdateCoordinator
@ -40,7 +40,9 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]):
identifiers={(DOMAIN, self._device_id)}, identifiers={(DOMAIN, self._device_id)},
name=self.zone.name if zone_id is not None else controller.name, name=self.zone.name if zone_id is not None else controller.name,
model=( 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, manufacturer=MANUFACTURER,
) )

View File

@ -2,12 +2,12 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable, Iterable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any
from pydrawise.schema import ControllerWaterUseSummary from pydrawise.schema import Controller, ControllerWaterUseSummary, Zone
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -31,7 +31,9 @@ class HydrawiseSensorEntityDescription(SensorEntityDescription):
def _get_water_use(sensor: HydrawiseSensor) -> ControllerWaterUseSummary: 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, ...] = ( WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
@ -133,44 +135,65 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Hydrawise sensor platform.""" """Set up the Hydrawise sensor platform."""
coordinators = config_entry.runtime_data coordinators = config_entry.runtime_data
entities: list[HydrawiseSensor] = []
for controller in coordinators.main.data.controllers.values(): def _has_flow_sensor(controller: Controller) -> bool:
entities.extend( daily_water_use_summary = coordinators.water_use.data.daily_water_summary.get(
HydrawiseSensor(coordinators.water_use, description, controller) controller.id, ControllerWaterUseSummary()
for description in WATER_USE_CONTROLLER_SENSORS
) )
entities.extend( return daily_water_use_summary.total_use is not None
HydrawiseSensor(
coordinators.water_use, description, controller, zone_id=zone.id def _add_new_controllers(controllers: Iterable[Controller]) -> None:
) entities: list[HydrawiseSensor] = []
for zone in controller.zones for controller in controllers:
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
entities.extend( entities.extend(
HydrawiseSensor(coordinators.water_use, description, controller) 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( HydrawiseSensor(
coordinators.water_use, coordinators.water_use,
description, description,
controller, controller,
zone_id=zone.id, zone_id=zone.id,
) )
for zone in controller.zones for zone, controller in zones
for description in FLOW_ZONE_SENSORS for description in FLOW_ZONE_SENSORS
) if _has_flow_sensor(controller)
async_add_entities(entities) ]
)
_add_new_controllers(coordinators.main.data.controllers.values())
_add_new_zones(
[
(zone, coordinators.main.data.zone_id_to_controller[zone.id])
for zone in coordinators.main.data.zones.values()
]
)
coordinators.main.new_controllers_callbacks.append(_add_new_controllers)
coordinators.main.new_zones_callbacks.append(_add_new_zones)
class HydrawiseSensor(HydrawiseEntity, SensorEntity): class HydrawiseSensor(HydrawiseEntity, SensorEntity):

View File

@ -2,12 +2,12 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine, Iterable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any
from pydrawise import HydrawiseBase, Zone from pydrawise import Controller, HydrawiseBase, Zone
from homeassistant.components.switch import ( from homeassistant.components.switch import (
SwitchDeviceClass, SwitchDeviceClass,
@ -66,12 +66,21 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Hydrawise switch platform.""" """Set up the Hydrawise switch platform."""
coordinators = config_entry.runtime_data coordinators = config_entry.runtime_data
async_add_entities(
HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id) def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None:
for controller in coordinators.main.data.controllers.values() async_add_entities(
for zone in controller.zones HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id)
for description in SWITCH_TYPES 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): class HydrawiseSwitch(HydrawiseEntity, SwitchEntity):

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