Merge branch 'dev' into mill

This commit is contained in:
Daniel Hjelseth Høyer 2024-11-19 08:52:19 +01:00 committed by GitHub
commit 43eb8277df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
309 changed files with 6505 additions and 2578 deletions

View File

@ -10,7 +10,7 @@ on:
env:
BUILD_TYPE: core
DEFAULT_PYTHON: "3.12"
DEFAULT_PYTHON: "3.13"
PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"

View File

@ -1248,12 +1248,11 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@v4.6.0
uses: codecov/codecov-action@v5.0.2
with:
fail_ci_if_error: true
flags: full-suite
token: ${{ secrets.CODECOV_TOKEN }}
version: v0.6.0
pytest-partial:
runs-on: ubuntu-24.04
@ -1387,8 +1386,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@v4.6.0
uses: codecov/codecov-action@v5.0.2
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
version: v0.6.0

View File

@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.27.3
uses: github/codeql-action/init@v3.27.4
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.27.3
uses: github/codeql-action/analyze@v3.27.4
with:
category: "/language:python"

View File

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.3
rev: v0.7.4
hooks:
- id: ruff
args:
@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v5.0.0
hooks:
- id: check-executables-have-shebangs
stages: [manual]

View File

@ -40,6 +40,8 @@ build.json @home-assistant/supervisor
# Integrations
/homeassistant/components/abode/ @shred86
/tests/components/abode/ @shred86
/homeassistant/components/acaia/ @zweckj
/tests/components/acaia/ @zweckj
/homeassistant/components/accuweather/ @bieniu
/tests/components/accuweather/ @bieniu
/homeassistant/components/acmeda/ @atmurray
@ -972,8 +974,6 @@ build.json @home-assistant/supervisor
/tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio
/tests/components/nasweb/ @nasWebio
/homeassistant/components/neato/ @Santobert
/tests/components/neato/ @Santobert
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM
/homeassistant/components/ness_alarm/ @nickw444
/tests/components/ness_alarm/ @nickw444
@ -1487,8 +1487,8 @@ build.json @home-assistant/supervisor
/tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/homeassistant/components/template/ @PhracturedBlue @home-assistant/core
/tests/components/template/ @PhracturedBlue @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77
/tests/components/tesla_fleet/ @Bre77
/homeassistant/components/tesla_wall_connector/ @einarhauks

View File

@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.06.1
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.06.1
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.06.1
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.06.1
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.06.1
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.11.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.11.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.11.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.11.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.11.0
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io

View File

@ -177,17 +177,17 @@ class TotpAuthModule(MultiFactorAuthModule):
class TotpSetupFlow(SetupFlow):
"""Handler for the setup flow."""
_auth_module: TotpAuthModule
_ota_secret: str
_url: str
_image: str
def __init__(
self, auth_module: TotpAuthModule, setup_schema: vol.Schema, user: User
) -> None:
"""Initialize the setup flow."""
super().__init__(auth_module, setup_schema, user.id)
# to fix typing complaint
self._auth_module: TotpAuthModule = auth_module
self._user = user
self._ota_secret: str = ""
self._url: str | None = None
self._image: str | None = None
async def async_step_init(
self, user_input: dict[str, str] | None = None
@ -214,12 +214,11 @@ class TotpSetupFlow(SetupFlow):
errors["base"] = "invalid_code"
else:
hass = self._auth_module.hass
(
self._ota_secret,
self._url,
self._image,
) = await hass.async_add_executor_job(
) = await self._auth_module.hass.async_add_executor_job(
_generate_secret_and_qr_code,
str(self._user.name),
)

View File

@ -515,7 +515,7 @@ async def async_from_config_dict(
issue_registry.async_create_issue(
hass,
core.DOMAIN,
"python_version",
f"python_version_{required_python_version}",
is_fixable=False,
severity=issue_registry.IssueSeverity.WARNING,
breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE,

View File

@ -0,0 +1,31 @@
"""Initialize the Acaia component."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AcaiaConfigEntry, AcaiaCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool:
"""Set up acaia as config entry."""
coordinator = AcaiaCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,58 @@
"""Binary sensor platform for Acaia scales."""
from collections.abc import Callable
from dataclasses import dataclass
from aioacaia.acaiascale import AcaiaScale
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import AcaiaConfigEntry
from .entity import AcaiaEntity
@dataclass(kw_only=True, frozen=True)
class AcaiaBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Description for Acaia binary sensor entities."""
is_on_fn: Callable[[AcaiaScale], bool]
BINARY_SENSORS: tuple[AcaiaBinarySensorEntityDescription, ...] = (
AcaiaBinarySensorEntityDescription(
key="timer_running",
translation_key="timer_running",
device_class=BinarySensorDeviceClass.RUNNING,
is_on_fn=lambda scale: scale.timer_running,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AcaiaConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up binary sensors."""
coordinator = entry.runtime_data
async_add_entities(
AcaiaBinarySensor(coordinator, description) for description in BINARY_SENSORS
)
class AcaiaBinarySensor(AcaiaEntity, BinarySensorEntity):
"""Representation of an Acaia binary sensor."""
entity_description: AcaiaBinarySensorEntityDescription
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self.entity_description.is_on_fn(self._scale)

View File

@ -0,0 +1,61 @@
"""Button entities for Acaia scales."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from aioacaia.acaiascale import AcaiaScale
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import AcaiaConfigEntry
from .entity import AcaiaEntity
@dataclass(kw_only=True, frozen=True)
class AcaiaButtonEntityDescription(ButtonEntityDescription):
"""Description for acaia button entities."""
press_fn: Callable[[AcaiaScale], Coroutine[Any, Any, None]]
BUTTONS: tuple[AcaiaButtonEntityDescription, ...] = (
AcaiaButtonEntityDescription(
key="tare",
translation_key="tare",
press_fn=lambda scale: scale.tare(),
),
AcaiaButtonEntityDescription(
key="reset_timer",
translation_key="reset_timer",
press_fn=lambda scale: scale.reset_timer(),
),
AcaiaButtonEntityDescription(
key="start_stop",
translation_key="start_stop",
press_fn=lambda scale: scale.start_stop_timer(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AcaiaConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up button entities and services."""
coordinator = entry.runtime_data
async_add_entities(AcaiaButton(coordinator, description) for description in BUTTONS)
class AcaiaButton(AcaiaEntity, ButtonEntity):
"""Representation of an Acaia button."""
entity_description: AcaiaButtonEntityDescription
async def async_press(self) -> None:
"""Handle the button press."""
await self.entity_description.press_fn(self._scale)

View File

@ -0,0 +1,149 @@
"""Config flow for Acaia integration."""
import logging
from typing import Any
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice
from aioacaia.helpers import is_new_scale
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_IS_NEW_STYLE_SCALE, DOMAIN
_LOGGER = logging.getLogger(__name__)
class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for acaia."""
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered: dict[str, Any] = {}
self._discovered_devices: dict[str, str] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
mac = format_mac(user_input[CONF_ADDRESS])
try:
is_new_style_scale = await is_new_scale(mac)
except AcaiaDeviceNotFound:
errors["base"] = "device_not_found"
except AcaiaError:
_LOGGER.exception("Error occurred while connecting to the scale")
errors["base"] = "unknown"
except AcaiaUnknownDevice:
return self.async_abort(reason="unsupported_device")
else:
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
if not errors:
return self.async_create_entry(
title=self._discovered_devices[user_input[CONF_ADDRESS]],
data={
CONF_ADDRESS: mac,
CONF_IS_NEW_STYLE_SCALE: is_new_style_scale,
},
)
for device in async_discovered_service_info(self.hass):
self._discovered_devices[device.address] = device.name
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
options = [
SelectOptionDict(
value=device_mac,
label=f"{device_name} ({device_mac})",
)
for device_mac, device_name in self._discovered_devices.items()
]
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
)
)
}
),
errors=errors,
)
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle a discovered Bluetooth device."""
self._discovered[CONF_ADDRESS] = mac = format_mac(discovery_info.address)
self._discovered[CONF_NAME] = discovery_info.name
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
try:
self._discovered[CONF_IS_NEW_STYLE_SCALE] = await is_new_scale(
discovery_info.address
)
except AcaiaDeviceNotFound:
_LOGGER.debug("Device not found during discovery")
return self.async_abort(reason="device_not_found")
except AcaiaError:
_LOGGER.debug(
"Error occurred while connecting to the scale during discovery",
exc_info=True,
)
return self.async_abort(reason="unknown")
except AcaiaUnknownDevice:
_LOGGER.debug("Unsupported device during discovery")
return self.async_abort(reason="unsupported_device")
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle confirmation of Bluetooth discovery."""
if user_input is not None:
return self.async_create_entry(
title=self._discovered[CONF_NAME],
data={
CONF_ADDRESS: self._discovered[CONF_ADDRESS],
CONF_IS_NEW_STYLE_SCALE: self._discovered[CONF_IS_NEW_STYLE_SCALE],
},
)
self.context["title_placeholders"] = placeholders = {
CONF_NAME: self._discovered[CONF_NAME]
}
self._set_confirm_only()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders=placeholders,
)

View File

@ -0,0 +1,4 @@
"""Constants for component."""
DOMAIN = "acaia"
CONF_IS_NEW_STYLE_SCALE = "is_new_style_scale"

View File

@ -0,0 +1,86 @@
"""Coordinator for Acaia integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from aioacaia.acaiascale import AcaiaScale
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_IS_NEW_STYLE_SCALE
SCAN_INTERVAL = timedelta(seconds=15)
_LOGGER = logging.getLogger(__name__)
type AcaiaConfigEntry = ConfigEntry[AcaiaCoordinator]
class AcaiaCoordinator(DataUpdateCoordinator[None]):
"""Class to handle fetching data from the scale."""
config_entry: AcaiaConfigEntry
def __init__(self, hass: HomeAssistant, entry: AcaiaConfigEntry) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
name="acaia coordinator",
update_interval=SCAN_INTERVAL,
config_entry=entry,
)
self._scale = AcaiaScale(
address_or_ble_device=entry.data[CONF_ADDRESS],
name=entry.title,
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
notify_callback=self.async_update_listeners,
)
@property
def scale(self) -> AcaiaScale:
"""Return the scale object."""
return self._scale
async def _async_update_data(self) -> None:
"""Fetch data."""
# scale is already connected, return
if self._scale.connected:
return
# scale is not connected, try to connect
try:
await self._scale.connect(setup_tasks=False)
except (AcaiaDeviceNotFound, AcaiaError, TimeoutError) as ex:
_LOGGER.debug(
"Could not connect to scale: %s, Error: %s",
self.config_entry.data[CONF_ADDRESS],
ex,
)
self._scale.device_disconnected_handler(notify=False)
return
# connected, set up background tasks
if not self._scale.heartbeat_task or self._scale.heartbeat_task.done():
self._scale.heartbeat_task = self.config_entry.async_create_background_task(
hass=self.hass,
target=self._scale.send_heartbeats(),
name="acaia_heartbeat_task",
)
if not self._scale.process_queue_task or self._scale.process_queue_task.done():
self._scale.process_queue_task = (
self.config_entry.async_create_background_task(
hass=self.hass,
target=self._scale.process_queue(),
name="acaia_process_queue_task",
)
)

View File

@ -0,0 +1,40 @@
"""Base class for Acaia entities."""
from dataclasses import dataclass
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AcaiaCoordinator
@dataclass
class AcaiaEntity(CoordinatorEntity[AcaiaCoordinator]):
"""Common elements for all entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AcaiaCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._scale = coordinator.scale
self._attr_unique_id = f"{self._scale.mac}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._scale.mac)},
manufacturer="Acaia",
model=self._scale.model,
suggested_area="Kitchen",
)
@property
def available(self) -> bool:
"""Returns whether entity is available."""
return super().available and self._scale.connected

View File

@ -0,0 +1,24 @@
{
"entity": {
"binary_sensor": {
"timer_running": {
"default": "mdi:timer",
"state": {
"on": "mdi:timer-play",
"off": "mdi:timer-off"
}
}
},
"button": {
"tare": {
"default": "mdi:scale-balance"
},
"reset_timer": {
"default": "mdi:timer-refresh"
},
"start_stop": {
"default": "mdi:timer-play"
}
}
}
}

View File

@ -0,0 +1,29 @@
{
"domain": "acaia",
"name": "Acaia",
"bluetooth": [
{
"manufacturer_id": 16962
},
{
"local_name": "ACAIA*"
},
{
"local_name": "PYXIS-*"
},
{
"local_name": "LUNAR-*"
},
{
"local_name": "PROCHBT001"
}
],
"codeowners": ["@zweckj"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/acaia",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioacaia"],
"requirements": ["aioacaia==0.1.6"]
}

View File

@ -0,0 +1,135 @@
"""Sensor platform for Acaia."""
from collections.abc import Callable
from dataclasses import dataclass
from aioacaia.acaiascale import AcaiaDeviceState, AcaiaScale
from aioacaia.const import UnitMass as AcaiaUnitOfMass
from homeassistant.components.sensor import (
RestoreSensor,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorExtraStoredData,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfMass
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import AcaiaConfigEntry
from .entity import AcaiaEntity
@dataclass(kw_only=True, frozen=True)
class AcaiaSensorEntityDescription(SensorEntityDescription):
"""Description for Acaia sensor entities."""
value_fn: Callable[[AcaiaScale], int | float | None]
@dataclass(kw_only=True, frozen=True)
class AcaiaDynamicUnitSensorEntityDescription(AcaiaSensorEntityDescription):
"""Description for Acaia sensor entities with dynamic units."""
unit_fn: Callable[[AcaiaDeviceState], str] | None = None
SENSORS: tuple[AcaiaSensorEntityDescription, ...] = (
AcaiaDynamicUnitSensorEntityDescription(
key="weight",
device_class=SensorDeviceClass.WEIGHT,
native_unit_of_measurement=UnitOfMass.GRAMS,
state_class=SensorStateClass.MEASUREMENT,
unit_fn=lambda data: (
UnitOfMass.OUNCES
if data.units == AcaiaUnitOfMass.OUNCES
else UnitOfMass.GRAMS
),
value_fn=lambda scale: scale.weight,
),
)
RESTORE_SENSORS: tuple[AcaiaSensorEntityDescription, ...] = (
AcaiaSensorEntityDescription(
key="battery",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda scale: (
scale.device_state.battery_level if scale.device_state else None
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AcaiaConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors."""
coordinator = entry.runtime_data
entities: list[SensorEntity] = [
AcaiaSensor(coordinator, entity_description) for entity_description in SENSORS
]
entities.extend(
AcaiaRestoreSensor(coordinator, entity_description)
for entity_description in RESTORE_SENSORS
)
async_add_entities(entities)
class AcaiaSensor(AcaiaEntity, SensorEntity):
"""Representation of an Acaia sensor."""
entity_description: AcaiaDynamicUnitSensorEntityDescription
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement of this entity."""
if (
self._scale.device_state is not None
and self.entity_description.unit_fn is not None
):
return self.entity_description.unit_fn(self._scale.device_state)
return self.entity_description.native_unit_of_measurement
@property
def native_value(self) -> int | float | None:
"""Return the state of the entity."""
return self.entity_description.value_fn(self._scale)
class AcaiaRestoreSensor(AcaiaEntity, RestoreSensor):
"""Representation of an Acaia sensor with restore capabilities."""
entity_description: AcaiaSensorEntityDescription
_restored_data: SensorExtraStoredData | None = None
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
await super().async_added_to_hass()
self._restored_data = await self.async_get_last_sensor_data()
if self._restored_data is not None:
self._attr_native_value = self._restored_data.native_value
self._attr_native_unit_of_measurement = (
self._restored_data.native_unit_of_measurement
)
if self._scale.device_state is not None:
self._attr_native_value = self.entity_description.value_fn(self._scale)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self._scale.device_state is not None:
self._attr_native_value = self.entity_description.value_fn(self._scale)
self._async_write_ha_state()
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available or self._restored_data is not None

View File

@ -0,0 +1,43 @@
{
"config": {
"flow_title": "{name}",
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"unsupported_device": "This device is not supported."
},
"error": {
"device_not_found": "Device could not be found.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"bluetooth_confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
},
"user": {
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:common::config_flow::data::device%]"
}
}
}
},
"entity": {
"binary_sensor": {
"timer_running": {
"name": "Timer running"
}
},
"button": {
"tare": {
"name": "Tare"
},
"reset_timer": {
"name": "Reset timer"
},
"start_stop": {
"name": "Start/stop timer"
}
}
}
}

View File

@ -8,6 +8,6 @@
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
"quality_scale": "platinum",
"requirements": ["accuweather==3.0.0"],
"requirements": ["accuweather==4.0.0"],
"single_config_entry": true
}

View File

@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==0.9.5"]
"requirements": ["aioairzone==0.9.6"]
}

View File

@ -38,6 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) ->
ip_address=entry.data[CONF_IP_ADDRESS],
port=entry.data.get(CONF_PORT, DEFAULT_PORT),
timeout=8,
enable_debounce=True,
)
coordinator = ApSystemsDataCoordinator(hass, api)
await coordinator.async_config_entry_first_refresh()

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/apsystems",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["apsystems-ez1==2.2.1"]
"requirements": ["apsystems-ez1==2.4.0"]
}

View File

@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any
from aiohttp.client_exceptions import ClientConnectionError
from APsystemsEZ1 import InverterReturnedError
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant
@ -40,7 +41,7 @@ class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity):
"""Update switch status and availability."""
try:
status = await self._api.get_device_power_status()
except (TimeoutError, ClientConnectionError):
except (TimeoutError, ClientConnectionError, InverterReturnedError):
self._attr_available = False
else:
self._attr_available = True

View File

@ -31,6 +31,7 @@ from homeassistant.components.tts import (
)
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import intent
from homeassistant.helpers.collection import (
CHANGE_UPDATED,
CollectionError,
@ -109,6 +110,7 @@ PIPELINE_FIELDS: VolDictType = {
vol.Required("tts_voice"): vol.Any(str, None),
vol.Required("wake_word_entity"): vol.Any(str, None),
vol.Required("wake_word_id"): vol.Any(str, None),
vol.Optional("prefer_local_intents"): bool,
}
STORED_PIPELINE_RUNS = 10
@ -322,6 +324,7 @@ async def async_update_pipeline(
tts_voice: str | None | UndefinedType = UNDEFINED,
wake_word_entity: str | None | UndefinedType = UNDEFINED,
wake_word_id: str | None | UndefinedType = UNDEFINED,
prefer_local_intents: bool | UndefinedType = UNDEFINED,
) -> None:
"""Update a pipeline."""
pipeline_data: PipelineData = hass.data[DOMAIN]
@ -345,6 +348,7 @@ async def async_update_pipeline(
("tts_voice", tts_voice),
("wake_word_entity", wake_word_entity),
("wake_word_id", wake_word_id),
("prefer_local_intents", prefer_local_intents),
)
if val is not UNDEFINED
}
@ -398,6 +402,7 @@ class Pipeline:
tts_voice: str | None
wake_word_entity: str | None
wake_word_id: str | None
prefer_local_intents: bool = False
id: str = field(default_factory=ulid_util.ulid_now)
@ -421,6 +426,7 @@ class Pipeline:
tts_voice=data["tts_voice"],
wake_word_entity=data["wake_word_entity"],
wake_word_id=data["wake_word_id"],
prefer_local_intents=data.get("prefer_local_intents", False),
)
def to_json(self) -> dict[str, Any]:
@ -438,6 +444,7 @@ class Pipeline:
"tts_voice": self.tts_voice,
"wake_word_entity": self.wake_word_entity,
"wake_word_id": self.wake_word_id,
"prefer_local_intents": self.prefer_local_intents,
}
@ -1016,15 +1023,58 @@ class PipelineRun:
)
try:
conversation_result = await conversation.async_converse(
hass=self.hass,
user_input = conversation.ConversationInput(
text=intent_input,
context=self.context,
conversation_id=conversation_id,
device_id=device_id,
context=self.context,
language=self.pipeline.conversation_language,
language=self.pipeline.language,
agent_id=self.intent_agent,
)
# Sentence triggers override conversation agent
if (
trigger_response_text
:= await conversation.async_handle_sentence_triggers(
self.hass, user_input
)
):
# Sentence trigger matched
trigger_response = intent.IntentResponse(
self.pipeline.conversation_language
)
trigger_response.async_set_speech(trigger_response_text)
conversation_result = conversation.ConversationResult(
response=trigger_response,
conversation_id=user_input.conversation_id,
)
# Try local intents first, if preferred.
# Skip this step if the default agent is already used.
elif (
self.pipeline.prefer_local_intents
and (user_input.agent_id != conversation.HOME_ASSISTANT_AGENT)
and (
intent_response := await conversation.async_handle_intents(
self.hass, user_input
)
)
):
# Local intent matched
conversation_result = conversation.ConversationResult(
response=intent_response,
conversation_id=user_input.conversation_id,
)
else:
# Fall back to pipeline conversation agent
conversation_result = await conversation.async_converse(
hass=self.hass,
text=user_input.text,
conversation_id=user_input.conversation_id,
device_id=user_input.device_id,
context=user_input.context,
language=user_input.language,
agent_id=user_input.agent_id,
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during intent recognition")
raise IntentRecognitionError(

View File

@ -22,13 +22,14 @@ class AussieBroadbandConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
_reauth_username: str
def __init__(self) -> None:
"""Initialize the config flow."""
self.data: dict = {}
self.options: dict = {CONF_SERVICES: []}
self.services: list[dict[str, Any]] = []
self.client: AussieBB | None = None
self._reauth_username: str | None = None
async def async_auth(self, user_input: dict[str, str]) -> dict[str, str] | None:
"""Reusable Auth Helper."""
@ -92,7 +93,7 @@ class AussieBroadbandConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] | None = None
if user_input and self._reauth_username:
if user_input:
data = {
CONF_USERNAME: self._reauth_username,
CONF_PASSWORD: user_input[CONF_PASSWORD],

View File

@ -16,7 +16,7 @@
"requirements": [
"bleak==0.22.3",
"bleak-retry-connector==3.6.0",
"bluetooth-adapters==0.20.0",
"bluetooth-adapters==0.20.2",
"bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.20.0",
"dbus-fast==2.24.3",

View File

@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aiostreammagic"],
"requirements": ["aiostreammagic==2.8.4"],
"requirements": ["aiostreammagic==2.8.5"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
}

View File

@ -51,8 +51,13 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
CambridgeAudioSelectEntityDescription(
key="display_brightness",
translation_key="display_brightness",
options=[x.value for x in DisplayBrightness],
options=[
DisplayBrightness.BRIGHT.value,
DisplayBrightness.DIM.value,
DisplayBrightness.OFF.value,
],
entity_category=EntityCategory.CONFIG,
load_fn=lambda client: client.display.brightness != DisplayBrightness.NONE,
value_fn=lambda client: client.display.brightness,
set_value_fn=lambda client, value: client.set_display_brightness(
DisplayBrightness(value)

View File

@ -20,7 +20,7 @@ from aiohttp import hdrs, web
import attr
from propcache import cached_property, under_cached_property
import voluptuous as vol
from webrtc_models import RTCIceCandidate, RTCIceServer
from webrtc_models import RTCIceCandidateInit, RTCIceServer
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
@ -865,7 +865,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return config
async def async_on_webrtc_candidate(
self, session_id: str, candidate: RTCIceCandidate
self, session_id: str, candidate: RTCIceCandidateInit
) -> None:
"""Handle a WebRTC candidate."""
if self._webrtc_provider:
@ -896,7 +896,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
else:
frontend_stream_types.add(StreamType.HLS)
if self._webrtc_provider:
if self._webrtc_provider or self._legacy_webrtc_provider:
frontend_stream_types.add(StreamType.WEB_RTC)
return CameraCapabilities(frontend_stream_types)

View File

@ -64,7 +64,7 @@ class CameraMediaSource(MediaSource):
if not camera:
raise Unresolvable(f"Could not resolve media item: {item.identifier}")
if (stream_type := camera.frontend_stream_type) is None:
if not (stream_types := camera.camera_capabilities.frontend_stream_types):
return PlayMedia(
f"/api/camera_proxy_stream/{camera.entity_id}", camera.content_type
)
@ -76,7 +76,7 @@ class CameraMediaSource(MediaSource):
url = await _async_stream_endpoint_url(self.hass, camera, HLS_PROVIDER)
except HomeAssistantError as err:
# Handle known error
if stream_type != StreamType.HLS:
if StreamType.HLS not in stream_types:
raise Unresolvable(
"Camera does not support MJPEG or HLS streaming."
) from err

View File

@ -6,12 +6,17 @@ from abc import ABC, abstractmethod
import asyncio
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import asdict, dataclass, field
from functools import cache, partial
from functools import cache, partial, wraps
import logging
from typing import TYPE_CHECKING, Any, Protocol
import voluptuous as vol
from webrtc_models import RTCConfiguration, RTCIceCandidate, RTCIceServer
from webrtc_models import (
RTCConfiguration,
RTCIceCandidate,
RTCIceCandidateInit,
RTCIceServer,
)
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
@ -78,7 +83,7 @@ class WebRTCAnswer(WebRTCMessage):
class WebRTCCandidate(WebRTCMessage):
"""WebRTC candidate."""
candidate: RTCIceCandidate
candidate: RTCIceCandidate | RTCIceCandidateInit
def as_dict(self) -> dict[str, Any]:
"""Return a dict representation of the message."""
@ -146,7 +151,7 @@ class CameraWebRTCProvider(ABC):
@abstractmethod
async def async_on_webrtc_candidate(
self, session_id: str, candidate: RTCIceCandidate
self, session_id: str, candidate: RTCIceCandidateInit
) -> None:
"""Handle the WebRTC candidate."""
@ -205,6 +210,51 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None:
)
type WsCommandWithCamera = Callable[
[websocket_api.ActiveConnection, dict[str, Any], Camera],
Awaitable[None],
]
def require_webrtc_support(
error_code: str,
) -> Callable[[WsCommandWithCamera], websocket_api.AsyncWebSocketCommandHandler]:
"""Validate that the camera supports WebRTC."""
def decorate(
func: WsCommandWithCamera,
) -> websocket_api.AsyncWebSocketCommandHandler:
"""Decorate func."""
@wraps(func)
async def validate(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Validate that the camera supports WebRTC."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if StreamType.WEB_RTC not in (
stream_types := camera.camera_capabilities.frontend_stream_types
):
connection.send_error(
msg["id"],
error_code,
(
"Camera does not support WebRTC,"
f" frontend_stream_types={stream_types}"
),
)
return
await func(connection, msg, camera)
return validate
return decorate
@websocket_api.websocket_command(
{
vol.Required("type"): "camera/webrtc/offer",
@ -213,8 +263,9 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None:
}
)
@websocket_api.async_response
@require_webrtc_support("webrtc_offer_failed")
async def ws_webrtc_offer(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None:
"""Handle the signal path for a WebRTC stream.
@ -226,20 +277,7 @@ async def ws_webrtc_offer(
Async friendly.
"""
entity_id = msg["entity_id"]
offer = msg["offer"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_offer_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
session_id = ulid()
connection.subscriptions[msg["id"]] = partial(
camera.close_webrtc_session, session_id
@ -278,23 +316,11 @@ async def ws_webrtc_offer(
}
)
@websocket_api.async_response
@require_webrtc_support("webrtc_get_client_config_failed")
async def ws_get_client_config(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None:
"""Handle get WebRTC client config websocket command."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_get_client_config_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
config = camera.async_get_webrtc_client_configuration().to_frontend_dict()
connection.send_result(
msg["id"],
@ -311,25 +337,13 @@ async def ws_get_client_config(
}
)
@websocket_api.async_response
@require_webrtc_support("webrtc_candidate_failed")
async def ws_candidate(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None:
"""Handle WebRTC candidate websocket command."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_candidate_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
await camera.async_on_webrtc_candidate(
msg["session_id"], RTCIceCandidate(msg["candidate"])
msg["session_id"], RTCIceCandidateInit(msg["candidate"])
)
connection.send_message(websocket_api.result_message(msg["id"]))

View File

@ -1,6 +1,7 @@
"""Handle Cloud assist pipelines."""
import asyncio
from typing import Any
from homeassistant.components.assist_pipeline import (
async_create_default_pipeline,
@ -98,7 +99,7 @@ async def async_migrate_cloud_pipeline_engine(
# is an after dependency of cloud
await async_setup_pipeline_store(hass)
kwargs: dict[str, str] = {pipeline_attribute: engine_id}
kwargs: dict[str, Any] = {pipeline_attribute: engine_id}
pipelines = async_get_pipelines(hass)
for pipeline in pipelines:
if getattr(pipeline, pipeline_attribute) == DOMAIN:

View File

@ -44,7 +44,7 @@ from .const import (
SERVICE_RELOAD,
ConversationEntityFeature,
)
from .default_agent import async_setup_default_agent
from .default_agent import DefaultAgent, async_setup_default_agent
from .entity import ConversationEntity
from .http import async_setup as async_setup_conversation_http
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
@ -207,6 +207,32 @@ async def async_prepare_agent(
await agent.async_prepare(language)
async def async_handle_sentence_triggers(
hass: HomeAssistant, user_input: ConversationInput
) -> str | None:
"""Try to match input against sentence triggers and return response text.
Returns None if no match occurred.
"""
default_agent = async_get_agent(hass)
assert isinstance(default_agent, DefaultAgent)
return await default_agent.async_handle_sentence_triggers(user_input)
async def async_handle_intents(
hass: HomeAssistant, user_input: ConversationInput
) -> intent.IntentResponse | None:
"""Try to match input against registered intents and return response.
Returns None if no match occurred.
"""
default_agent = async_get_agent(hass)
assert isinstance(default_agent, DefaultAgent)
return await default_agent.async_handle_intents(user_input)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Register the process service."""
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)

View File

@ -16,11 +16,11 @@ from hassil.expression import Expression, ListReference, Sequence
from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList
from hassil.recognize import (
MISSING_ENTITY,
MatchEntity,
RecognizeResult,
UnmatchedTextEntity,
recognize_all,
recognize_best,
)
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
from hassil.util import merge_dict
from home_assistant_intents import ErrorKey, get_intents, get_languages
import yaml
@ -213,13 +213,10 @@ class DefaultAgent(ConversationEntity):
async_listen_entity_updates(self.hass, DOMAIN, self._async_clear_slot_list),
]
async def async_recognize(
self, user_input: ConversationInput
) -> RecognizeResult | SentenceTriggerResult | None:
async def async_recognize_intent(
self, user_input: ConversationInput, strict_intents_only: bool = False
) -> RecognizeResult | None:
"""Recognize intent from user input."""
if trigger_result := await self._match_triggers(user_input.text):
return trigger_result
language = user_input.language or self.hass.config.language
lang_intents = await self.async_get_or_load_intents(language)
@ -240,6 +237,7 @@ class DefaultAgent(ConversationEntity):
slot_lists,
intent_context,
language,
strict_intents_only,
)
_LOGGER.debug(
@ -251,56 +249,36 @@ class DefaultAgent(ConversationEntity):
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
"""Process a sentence."""
language = user_input.language or self.hass.config.language
conversation_id = None # Not supported
result = await self.async_recognize(user_input)
# Check if a trigger matched
if isinstance(result, SentenceTriggerResult):
# Gather callback responses in parallel
trigger_callbacks = [
self._trigger_sentences[trigger_id].callback(
result.sentence, trigger_result, user_input.device_id
)
for trigger_id, trigger_result in result.matched_triggers.items()
]
# Use first non-empty result as response.
#
# There may be multiple copies of a trigger running when editing in
# the UI, so it's critical that we filter out empty responses here.
response_text: str | None = None
response_set_by_trigger = False
for trigger_future in asyncio.as_completed(trigger_callbacks):
trigger_response = await trigger_future
if trigger_response is None:
continue
response_text = trigger_response
response_set_by_trigger = True
break
if trigger_result := await self.async_recognize_sentence_trigger(user_input):
# Process callbacks and get response
response_text = await self._handle_trigger_result(
trigger_result, user_input
)
# Convert to conversation result
response = intent.IntentResponse(language=language)
response = intent.IntentResponse(
language=user_input.language or self.hass.config.language
)
response.response_type = intent.IntentResponseType.ACTION_DONE
if response_set_by_trigger:
# Response was explicitly set to empty
response_text = response_text or ""
elif not response_text:
# Use translated acknowledgment for pipeline language
translations = await translation.async_get_translations(
self.hass, language, DOMAIN, [DOMAIN]
)
response_text = translations.get(
f"component.{DOMAIN}.conversation.agent.done", "Done"
)
response.async_set_speech(response_text)
return ConversationResult(response=response)
# Match intents
intent_result = await self.async_recognize_intent(user_input)
return await self._async_process_intent_result(intent_result, user_input)
async def _async_process_intent_result(
self,
result: RecognizeResult | None,
user_input: ConversationInput,
) -> ConversationResult:
"""Process user input with intents."""
language = user_input.language or self.hass.config.language
conversation_id = None # Not supported
# Intent match or failure
lang_intents = await self.async_get_or_load_intents(language)
@ -436,6 +414,7 @@ class DefaultAgent(ConversationEntity):
slot_lists: dict[str, SlotList],
intent_context: dict[str, Any] | None,
language: str,
strict_intents_only: bool,
) -> RecognizeResult | None:
"""Search intents for a match to user input."""
strict_result = self._recognize_strict(
@ -446,6 +425,9 @@ class DefaultAgent(ConversationEntity):
# Successful strict match
return strict_result
if strict_intents_only:
return None
# Try again with all entities (including unexposed)
entity_registry = er.async_get(self.hass)
all_entity_names: list[tuple[str, str, dict[str, Any]]] = []
@ -499,6 +481,7 @@ class DefaultAgent(ConversationEntity):
maybe_result: RecognizeResult | None = None
best_num_matched_entities = 0
best_num_unmatched_entities = 0
best_num_unmatched_ranges = 0
for result in recognize_all(
user_input.text,
lang_intents.intents,
@ -517,10 +500,14 @@ class DefaultAgent(ConversationEntity):
num_matched_entities += 1
num_unmatched_entities = 0
num_unmatched_ranges = 0
for unmatched_entity in result.unmatched_entities_list:
if isinstance(unmatched_entity, UnmatchedTextEntity):
if unmatched_entity.text != MISSING_ENTITY:
num_unmatched_entities += 1
elif isinstance(unmatched_entity, UnmatchedRangeEntity):
num_unmatched_ranges += 1
num_unmatched_entities += 1
else:
num_unmatched_entities += 1
@ -532,15 +519,24 @@ class DefaultAgent(ConversationEntity):
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities < best_num_unmatched_entities)
)
or (
# Prefer unmatched ranges
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges > best_num_unmatched_ranges)
)
or (
# More literal text matched
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and (result.text_chunks_matched > maybe_result.text_chunks_matched)
)
or (
# Prefer match failures with entities
(result.text_chunks_matched == maybe_result.text_chunks_matched)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and (
("name" in result.entities)
or ("name" in result.unmatched_entities)
@ -550,6 +546,7 @@ class DefaultAgent(ConversationEntity):
maybe_result = result
best_num_matched_entities = num_matched_entities
best_num_unmatched_entities = num_unmatched_entities
best_num_unmatched_ranges = num_unmatched_ranges
return maybe_result
@ -562,76 +559,15 @@ class DefaultAgent(ConversationEntity):
language: str,
) -> RecognizeResult | None:
"""Search intents for a strict match to user input."""
custom_found = False
name_found = False
best_results: list[RecognizeResult] = []
best_name_quality: int | None = None
best_text_chunks_matched: int | None = None
for result in recognize_all(
return recognize_best(
user_input.text,
lang_intents.intents,
slot_lists=slot_lists,
intent_context=intent_context,
language=language,
):
# Prioritize user intents
is_custom = (
result.intent_metadata is not None
and result.intent_metadata.get(METADATA_CUSTOM_SENTENCE)
)
if custom_found and not is_custom:
continue
if not custom_found and is_custom:
custom_found = True
# Clear builtin results
name_found = False
best_results = []
best_name_quality = None
best_text_chunks_matched = None
# Prioritize results with a "name" slot
name = result.entities.get("name")
is_name = name and not name.is_wildcard
if name_found and not is_name:
continue
if not name_found and is_name:
name_found = True
# Clear non-name results
best_results = []
best_text_chunks_matched = None
if is_name:
# Prioritize results with a better "name" slot
name_quality = len(cast(MatchEntity, name).value.split())
if (best_name_quality is None) or (name_quality > best_name_quality):
best_name_quality = name_quality
# Clear worse name results
best_results = []
best_text_chunks_matched = None
elif name_quality < best_name_quality:
continue
# Prioritize results with more literal text
# This causes wildcards to match last.
if (best_text_chunks_matched is None) or (
result.text_chunks_matched > best_text_chunks_matched
):
best_results = [result]
best_text_chunks_matched = result.text_chunks_matched
elif result.text_chunks_matched == best_text_chunks_matched:
# Accumulate results with the same number of literal text matched.
# We will resolve the ambiguity below.
best_results.append(result)
if best_results:
# Successful strict match
return best_results[0]
return None
best_metadata_key=METADATA_CUSTOM_SENTENCE,
best_slot_name="name",
)
async def _build_speech(
self,
@ -1102,7 +1038,9 @@ class DefaultAgent(ConversationEntity):
# Force rebuild on next use
self._trigger_intents = None
async def _match_triggers(self, sentence: str) -> SentenceTriggerResult | None:
async def async_recognize_sentence_trigger(
self, user_input: ConversationInput
) -> SentenceTriggerResult | None:
"""Try to match sentence against registered trigger sentences.
Calls the registered callbacks if there's a match and returns a sentence
@ -1120,7 +1058,7 @@ class DefaultAgent(ConversationEntity):
matched_triggers: dict[int, RecognizeResult] = {}
matched_template: str | None = None
for result in recognize_all(sentence, self._trigger_intents):
for result in recognize_all(user_input.text, self._trigger_intents):
if result.intent_sentence is not None:
matched_template = result.intent_sentence.text
@ -1137,12 +1075,88 @@ class DefaultAgent(ConversationEntity):
_LOGGER.debug(
"'%s' matched %s trigger(s): %s",
sentence,
user_input.text,
len(matched_triggers),
list(matched_triggers),
)
return SentenceTriggerResult(sentence, matched_template, matched_triggers)
return SentenceTriggerResult(
user_input.text, matched_template, matched_triggers
)
async def _handle_trigger_result(
self, result: SentenceTriggerResult, user_input: ConversationInput
) -> str:
"""Run sentence trigger callbacks and return response text."""
# Gather callback responses in parallel
trigger_callbacks = [
self._trigger_sentences[trigger_id].callback(
user_input.text, trigger_result, user_input.device_id
)
for trigger_id, trigger_result in result.matched_triggers.items()
]
# Use first non-empty result as response.
#
# There may be multiple copies of a trigger running when editing in
# the UI, so it's critical that we filter out empty responses here.
response_text = ""
response_set_by_trigger = False
for trigger_future in asyncio.as_completed(trigger_callbacks):
trigger_response = await trigger_future
if trigger_response is None:
continue
response_text = trigger_response
response_set_by_trigger = True
break
if response_set_by_trigger:
# Response was explicitly set to empty
response_text = response_text or ""
elif not response_text:
# Use translated acknowledgment for pipeline language
language = user_input.language or self.hass.config.language
translations = await translation.async_get_translations(
self.hass, language, DOMAIN, [DOMAIN]
)
response_text = translations.get(
f"component.{DOMAIN}.conversation.agent.done", "Done"
)
return response_text
async def async_handle_sentence_triggers(
self, user_input: ConversationInput
) -> str | None:
"""Try to input sentence against sentence triggers and return response text.
Returns None if no match occurred.
"""
if trigger_result := await self.async_recognize_sentence_trigger(user_input):
return await self._handle_trigger_result(trigger_result, user_input)
return None
async def async_handle_intents(
self,
user_input: ConversationInput,
) -> intent.IntentResponse | None:
"""Try to match sentence against registered intents and return response.
Only performs strict matching with exposed entities and exact wording.
Returns None if no match occurred.
"""
result = await self.async_recognize_intent(user_input, strict_intents_only=True)
if not isinstance(result, RecognizeResult):
# No error message on failed match
return None
conversation_result = await self._async_process_intent_result(
result, user_input
)
return conversation_result.response
def _make_error_result(
@ -1154,7 +1168,6 @@ def _make_error_result(
"""Create conversation result with error code and text."""
response = intent.IntentResponse(language=language)
response.async_set_error(error_code, response_text)
return ConversationResult(response, conversation_id)

View File

@ -6,12 +6,8 @@ from collections.abc import Iterable
from typing import Any
from aiohttp import web
from hassil.recognize import (
MISSING_ENTITY,
RecognizeResult,
UnmatchedRangeEntity,
UnmatchedTextEntity,
)
from hassil.recognize import MISSING_ENTITY, RecognizeResult
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
import voluptuous as vol
from homeassistant.components import http, websocket_api
@ -28,11 +24,7 @@ from .agent_manager import (
get_agent_manager,
)
from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY
from .default_agent import (
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
SentenceTriggerResult,
)
from .default_agent import METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE, DefaultAgent
from .entity import ConversationEntity
from .models import ConversationInput
@ -171,44 +163,42 @@ async def websocket_hass_agent_debug(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""Return intents that would be matched by the default agent for a list of sentences."""
results = [
await hass.data[DATA_DEFAULT_ENTITY].async_recognize(
ConversationInput(
text=sentence,
context=connection.context(msg),
conversation_id=None,
device_id=msg.get("device_id"),
language=msg.get("language", hass.config.language),
agent_id=None,
)
)
for sentence in msg["sentences"]
]
agent = hass.data.get(DATA_DEFAULT_ENTITY)
assert isinstance(agent, DefaultAgent)
# Return results for each sentence in the same order as the input.
result_dicts: list[dict[str, Any] | None] = []
for result in results:
for sentence in msg["sentences"]:
user_input = ConversationInput(
text=sentence,
context=connection.context(msg),
conversation_id=None,
device_id=msg.get("device_id"),
language=msg.get("language", hass.config.language),
agent_id=None,
)
result_dict: dict[str, Any] | None = None
if isinstance(result, SentenceTriggerResult):
if trigger_result := await agent.async_recognize_sentence_trigger(user_input):
result_dict = {
# Matched a user-defined sentence trigger.
# We can't provide the response here without executing the
# trigger.
"match": True,
"source": "trigger",
"sentence_template": result.sentence_template or "",
"sentence_template": trigger_result.sentence_template or "",
}
elif isinstance(result, RecognizeResult):
successful_match = not result.unmatched_entities
elif intent_result := await agent.async_recognize_intent(user_input):
successful_match = not intent_result.unmatched_entities
result_dict = {
# Name of the matching intent (or the closest)
"intent": {
"name": result.intent.name,
"name": intent_result.intent.name,
},
# Slot values that would be received by the intent
"slots": { # direct access to values
entity_key: entity.text or entity.value
for entity_key, entity in result.entities.items()
for entity_key, entity in intent_result.entities.items()
},
# Extra slot details, such as the originally matched text
"details": {
@ -217,7 +207,7 @@ async def websocket_hass_agent_debug(
"value": entity.value,
"text": entity.text,
}
for entity_key, entity in result.entities.items()
for entity_key, entity in intent_result.entities.items()
},
# Entities/areas/etc. that would be targeted
"targets": {},
@ -226,24 +216,26 @@ async def websocket_hass_agent_debug(
# Text of the sentence template that matched (or was closest)
"sentence_template": "",
# When match is incomplete, this will contain the best slot guesses
"unmatched_slots": _get_unmatched_slots(result),
"unmatched_slots": _get_unmatched_slots(intent_result),
}
if successful_match:
result_dict["targets"] = {
state.entity_id: {"matched": is_matched}
for state, is_matched in _get_debug_targets(hass, result)
for state, is_matched in _get_debug_targets(hass, intent_result)
}
if result.intent_sentence is not None:
result_dict["sentence_template"] = result.intent_sentence.text
if intent_result.intent_sentence is not None:
result_dict["sentence_template"] = intent_result.intent_sentence.text
# Inspect metadata to determine if this matched a custom sentence
if result.intent_metadata and result.intent_metadata.get(
if intent_result.intent_metadata and intent_result.intent_metadata.get(
METADATA_CUSTOM_SENTENCE
):
result_dict["source"] = "custom"
result_dict["file"] = result.intent_metadata.get(METADATA_CUSTOM_FILE)
result_dict["file"] = intent_result.intent_metadata.get(
METADATA_CUSTOM_FILE
)
else:
result_dict["source"] = "builtin"

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.6"]
"requirements": ["hassil==2.0.2", "home-assistant-intents==2024.11.13"]
}

View File

@ -4,7 +4,8 @@ from __future__ import annotations
from typing import Any
from hassil.recognize import PUNCTUATION, RecognizeResult
from hassil.recognize import RecognizeResult
from hassil.util import PUNCTUATION_ALL
import voluptuous as vol
from homeassistant.const import CONF_COMMAND, CONF_PLATFORM
@ -20,7 +21,7 @@ from .const import DATA_DEFAULT_ENTITY, DOMAIN
def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation."""
for sentence in value:
if PUNCTUATION.search(sentence):
if PUNCTUATION_ALL.search(sentence):
raise vol.Invalid("sentence should not contain punctuation")
return value

View File

@ -6,12 +6,12 @@ import logging
from pydexcom import AccountError, Dexcom, GlucoseReading, SessionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_SERVER, DOMAIN, MG_DL, PLATFORMS, SERVER_OUS
from .const import CONF_SERVER, DOMAIN, PLATFORMS, SERVER_OUS
_LOGGER = logging.getLogger(__name__)
@ -32,11 +32,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except SessionError as error:
raise ConfigEntryNotReady from error
if not entry.options:
hass.config_entries.async_update_entry(
entry, options={CONF_UNIT_OF_MEASUREMENT: MG_DL}
)
async def async_update_data():
try:
return await hass.async_add_executor_job(dexcom.get_current_glucose_reading)
@ -55,8 +50,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@ -67,8 +60,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@ -7,16 +7,10 @@ from typing import Any
from pydexcom import AccountError, Dexcom, SessionError
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_PASSWORD, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import CONF_SERVER, DOMAIN, MG_DL, MMOL_L, SERVER_OUS, SERVER_US
from .const import CONF_SERVER, DOMAIN, SERVER_OUS, SERVER_US
DATA_SCHEMA = vol.Schema(
{
@ -62,34 +56,3 @@ class DexcomConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> DexcomOptionsFlowHandler:
"""Get the options flow for this handler."""
return DexcomOptionsFlowHandler()
class DexcomOptionsFlowHandler(OptionsFlow):
"""Handle a option flow for Dexcom."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle options flow."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
data_schema = vol.Schema(
{
vol.Optional(
CONF_UNIT_OF_MEASUREMENT,
default=self.config_entry.options.get(
CONF_UNIT_OF_MEASUREMENT, MG_DL
),
): vol.In({MG_DL, MMOL_L}),
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)

View File

@ -5,9 +5,6 @@ from homeassistant.const import Platform
DOMAIN = "dexcom"
PLATFORMS = [Platform.SENSOR]
MMOL_L = "mmol/L"
MG_DL = "mg/dL"
CONF_SERVER = "server"
SERVER_OUS = "EU"

View File

@ -6,7 +6,7 @@ from pydexcom import GlucoseReading
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME
from homeassistant.const import CONF_USERNAME, UnitOfBloodGlucoseConcentration
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -15,7 +15,7 @@ from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
)
from .const import DOMAIN, MG_DL
from .const import DOMAIN
TRENDS = {
1: "rising_quickly",
@ -36,13 +36,10 @@ async def async_setup_entry(
"""Set up the Dexcom sensors."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
username = config_entry.data[CONF_USERNAME]
unit_of_measurement = config_entry.options[CONF_UNIT_OF_MEASUREMENT]
async_add_entities(
[
DexcomGlucoseTrendSensor(coordinator, username, config_entry.entry_id),
DexcomGlucoseValueSensor(
coordinator, username, config_entry.entry_id, unit_of_measurement
),
DexcomGlucoseValueSensor(coordinator, username, config_entry.entry_id),
],
)
@ -73,6 +70,10 @@ class DexcomSensorEntity(
class DexcomGlucoseValueSensor(DexcomSensorEntity):
"""Representation of a Dexcom glucose value sensor."""
_attr_device_class = SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION
_attr_native_unit_of_measurement = (
UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER
)
_attr_translation_key = "glucose_value"
def __init__(
@ -80,18 +81,15 @@ class DexcomGlucoseValueSensor(DexcomSensorEntity):
coordinator: DataUpdateCoordinator,
username: str,
entry_id: str,
unit_of_measurement: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, username, entry_id, "value")
self._attr_native_unit_of_measurement = unit_of_measurement
self._key = "mg_dl" if unit_of_measurement == MG_DL else "mmol_l"
@property
def native_value(self):
"""Return the state of the sensor."""
if self.coordinator.data:
return getattr(self.coordinator.data, self._key)
return self.coordinator.data.mg_dl
return None

View File

@ -92,6 +92,7 @@ class EmonitorConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Attempt to confirm."""
assert self.discovered_ip is not None
if user_input is not None:
return self.async_create_entry(
title=self.discovered_info["title"],

View File

@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["sense_energy"],
"quality_scale": "internal",
"requirements": ["sense-energy==0.13.3"]
"requirements": ["sense-energy==0.13.4"]
}

View File

@ -21,6 +21,8 @@ from .models import Eq3Config, Eq3ConfigEntryData
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
]

View File

@ -24,6 +24,13 @@ ENTITY_KEY_WINDOW = "window"
ENTITY_KEY_LOCK = "lock"
ENTITY_KEY_BOOST = "boost"
ENTITY_KEY_AWAY = "away"
ENTITY_KEY_COMFORT = "comfort"
ENTITY_KEY_ECO = "eco"
ENTITY_KEY_OFFSET = "offset"
ENTITY_KEY_WINDOW_OPEN_TEMPERATURE = "window_open_temperature"
ENTITY_KEY_WINDOW_OPEN_TIMEOUT = "window_open_timeout"
ENTITY_KEY_VALVE = "valve"
ENTITY_KEY_AWAY_UNTIL = "away_until"
GET_DEVICE_TIMEOUT = 5 # seconds
@ -77,3 +84,5 @@ DEFAULT_SCAN_INTERVAL = 10 # seconds
SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected"
SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected"
EQ3BT_STEP = 0.5

View File

@ -8,11 +8,36 @@
}
}
},
"number": {
"comfort": {
"default": "mdi:sun-thermometer"
},
"eco": {
"default": "mdi:snowflake-thermometer"
},
"offset": {
"default": "mdi:thermometer-plus"
},
"window_open_temperature": {
"default": "mdi:window-open-variant"
},
"window_open_timeout": {
"default": "mdi:timer-refresh"
}
},
"sensor": {
"away_until": {
"default": "mdi:home-export-outline"
},
"valve": {
"default": "mdi:pipe-valve"
}
},
"switch": {
"away": {
"default": "mdi:home-account",
"state": {
"on": "mdi:home-export"
"on": "mdi:home-export-outline"
}
},
"lock": {

View File

@ -23,5 +23,5 @@
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
"quality_scale": "silver",
"requirements": ["eq3btsmart==1.2.1", "bleak-esphome==1.1.0"]
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==1.1.0"]
}

View File

@ -2,7 +2,6 @@
from dataclasses import dataclass
from eq3btsmart.const import DEFAULT_AWAY_HOURS, DEFAULT_AWAY_TEMP
from eq3btsmart.thermostat import Thermostat
from .const import (
@ -23,8 +22,6 @@ class Eq3Config:
target_temp_selector: TargetTemperatureSelector = DEFAULT_TARGET_TEMP_SELECTOR
external_temp_sensor: str = ""
scan_interval: int = DEFAULT_SCAN_INTERVAL
default_away_hours: float = DEFAULT_AWAY_HOURS
default_away_temperature: float = DEFAULT_AWAY_TEMP
@dataclass(slots=True)

View File

@ -0,0 +1,158 @@
"""Platform for eq3 number entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from eq3btsmart import Thermostat
from eq3btsmart.const import (
EQ3BT_MAX_OFFSET,
EQ3BT_MAX_TEMP,
EQ3BT_MIN_OFFSET,
EQ3BT_MIN_TEMP,
)
from eq3btsmart.models import Presets
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import Eq3ConfigEntry
from .const import (
ENTITY_KEY_COMFORT,
ENTITY_KEY_ECO,
ENTITY_KEY_OFFSET,
ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
EQ3BT_STEP,
)
from .entity import Eq3Entity
@dataclass(frozen=True, kw_only=True)
class Eq3NumberEntityDescription(NumberEntityDescription):
"""Entity description for eq3 number entities."""
value_func: Callable[[Presets], float]
value_set_func: Callable[
[Thermostat],
Callable[[float], Awaitable[None]],
]
mode: NumberMode = NumberMode.BOX
entity_category: EntityCategory | None = EntityCategory.CONFIG
NUMBER_ENTITY_DESCRIPTIONS = [
Eq3NumberEntityDescription(
key=ENTITY_KEY_COMFORT,
value_func=lambda presets: presets.comfort_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature,
translation_key=ENTITY_KEY_COMFORT,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_ECO,
value_func=lambda presets: presets.eco_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature,
translation_key=ENTITY_KEY_ECO,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
value_func=lambda presets: presets.window_open_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature,
translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_OFFSET,
value_func=lambda presets: presets.offset_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset,
translation_key=ENTITY_KEY_OFFSET,
native_min_value=EQ3BT_MIN_OFFSET,
native_max_value=EQ3BT_MAX_OFFSET,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration,
value_func=lambda presets: presets.window_open_time.value.total_seconds() / 60,
translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
native_min_value=0,
native_max_value=60,
native_step=5,
native_unit_of_measurement=UnitOfTime.MINUTES,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the entry."""
async_add_entities(
Eq3NumberEntity(entry, entity_description)
for entity_description in NUMBER_ENTITY_DESCRIPTIONS
)
class Eq3NumberEntity(Eq3Entity, NumberEntity):
"""Base class for all eq3 number entities."""
entity_description: Eq3NumberEntityDescription
def __init__(
self, entry: Eq3ConfigEntry, entity_description: Eq3NumberEntityDescription
) -> None:
"""Initialize the entity."""
super().__init__(entry, entity_description.key)
self.entity_description = entity_description
@property
def native_value(self) -> float:
"""Return the state of the entity."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
assert self._thermostat.status.presets is not None
return self.entity_description.value_func(self._thermostat.status.presets)
async def async_set_native_value(self, value: float) -> None:
"""Set the state of the entity."""
await self.entity_description.value_set_func(self._thermostat)(value)
@property
def available(self) -> bool:
"""Return whether the entity is available."""
return (
self._thermostat.status is not None
and self._thermostat.status.presets is not None
and self._attr_available
)

View File

@ -0,0 +1,84 @@
"""Platform for eq3 sensor entities."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING
from eq3btsmart.models import Status
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.components.sensor.const import SensorStateClass
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import Eq3ConfigEntry
from .const import ENTITY_KEY_AWAY_UNTIL, ENTITY_KEY_VALVE
from .entity import Eq3Entity
@dataclass(frozen=True, kw_only=True)
class Eq3SensorEntityDescription(SensorEntityDescription):
"""Entity description for eq3 sensor entities."""
value_func: Callable[[Status], int | datetime | None]
SENSOR_ENTITY_DESCRIPTIONS = [
Eq3SensorEntityDescription(
key=ENTITY_KEY_VALVE,
translation_key=ENTITY_KEY_VALVE,
value_func=lambda status: status.valve,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
Eq3SensorEntityDescription(
key=ENTITY_KEY_AWAY_UNTIL,
translation_key=ENTITY_KEY_AWAY_UNTIL,
value_func=lambda status: (
status.away_until.value if status.away_until else None
),
device_class=SensorDeviceClass.DATE,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the entry."""
async_add_entities(
Eq3SensorEntity(entry, entity_description)
for entity_description in SENSOR_ENTITY_DESCRIPTIONS
)
class Eq3SensorEntity(Eq3Entity, SensorEntity):
"""Base class for eq3 sensor entities."""
entity_description: Eq3SensorEntityDescription
def __init__(
self, entry: Eq3ConfigEntry, entity_description: Eq3SensorEntityDescription
) -> None:
"""Initialize the entity."""
super().__init__(entry, entity_description.key)
self.entity_description = entity_description
@property
def native_value(self) -> int | datetime | None:
"""Return the value reported by the sensor."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
return self.entity_description.value_func(self._thermostat.status)

View File

@ -25,6 +25,31 @@
"name": "Daylight saving time"
}
},
"number": {
"comfort": {
"name": "Comfort temperature"
},
"eco": {
"name": "Eco temperature"
},
"offset": {
"name": "Offset temperature"
},
"window_open_temperature": {
"name": "Window open temperature"
},
"window_open_timeout": {
"name": "Window open timeout"
}
},
"sensor": {
"away_until": {
"name": "Away until"
},
"valve": {
"name": "Valve"
}
},
"switch": {
"lock": {
"name": "Lock"

View File

@ -179,6 +179,9 @@ class FFmpegConvertResponse(web.StreamResponse):
# Remove metadata and cover art
command_args.extend(["-map_metadata", "-1", "-vn"])
# disable progress stats on stderr
command_args.append("-nostats")
# Output to stdout
command_args.append("pipe:")

View File

@ -61,6 +61,8 @@ async def async_setup_entry(
if (dashboard := async_get_dashboard(hass)) is None:
return
entry_data = DomainData.get(hass).get_entry_data(entry)
assert entry_data.device_info is not None
device_name = entry_data.device_info.name
unsubs: list[CALLBACK_TYPE] = []
@callback
@ -72,13 +74,22 @@ async def async_setup_entry(
if not entry_data.available or not dashboard.last_update_success:
return
# Do not add Dashboard Entity if this device is not known to the ESPHome dashboard.
if dashboard.data is None or dashboard.data.get(device_name) is None:
return
for unsub in unsubs:
unsub()
unsubs.clear()
async_add_entities([ESPHomeDashboardUpdateEntity(entry_data, dashboard)])
if entry_data.available and dashboard.last_update_success:
if (
entry_data.available
and dashboard.last_update_success
and dashboard.data is not None
and dashboard.data.get(device_name)
):
_async_setup_update_entity()
return
@ -133,10 +144,8 @@ class ESPHomeDashboardUpdateEntity(
self._attr_supported_features = NO_FEATURES
self._attr_installed_version = device_info.esphome_version
device = coordinator.data.get(device_info.name)
if device is None:
self._attr_latest_version = None
else:
self._attr_latest_version = device["current_version"]
assert device is not None
self._attr_latest_version = device["current_version"]
@callback
def _handle_coordinator_update(self) -> None:

View File

@ -6,30 +6,16 @@ from collections.abc import Callable
from dataclasses import dataclass
import datetime
import logging
import os
from typing import Any, Final, cast
from fitbit import Fitbit
from oauthlib.oauth2.rfc6749.errors import OAuth2Error
import voluptuous as vol
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_TOKEN,
CONF_UNIT_SYSTEM,
PERCENTAGE,
EntityCategory,
UnitOfLength,
@ -38,33 +24,13 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.json import load_json_object
from .api import FitbitApi
from .const import (
ATTR_ACCESS_TOKEN,
ATTR_LAST_SAVED_AT,
ATTR_REFRESH_TOKEN,
ATTRIBUTION,
BATTERY_LEVELS,
CONF_CLOCK_FORMAT,
CONF_MONITORED_RESOURCES,
DEFAULT_CLOCK_FORMAT,
DEFAULT_CONFIG,
DOMAIN,
FITBIT_CONFIG_FILE,
FITBIT_DEFAULT_RESOURCES,
FitbitScope,
FitbitUnitSystem,
)
from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem
from .coordinator import FitbitData, FitbitDeviceCoordinator
from .exceptions import FitbitApiException, FitbitAuthException
from .model import FitbitDevice, config_from_entry_data
@ -75,6 +41,8 @@ _CONFIGURING: dict[str, str] = {}
SCAN_INTERVAL: Final = datetime.timedelta(minutes=30)
FITBIT_TRACKER_SUBSTRING = "/tracker/"
def _default_value_fn(result: dict[str, Any]) -> str:
"""Parse a Fitbit timeseries API responses."""
@ -156,11 +124,34 @@ class FitbitSensorEntityDescription(SensorEntityDescription):
unit_fn: Callable[[FitbitUnitSystem], str | None] = lambda x: None
scope: FitbitScope | None = None
@property
def is_tracker(self) -> bool:
"""Return if the entity is a tracker."""
return FITBIT_TRACKER_SUBSTRING in self.key
def _build_device_info(
config_entry: ConfigEntry, entity_description: FitbitSensorEntityDescription
) -> DeviceInfo:
"""Build device info for sensor entities info across devices."""
unique_id = cast(str, config_entry.unique_id)
if entity_description.is_tracker:
return DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, f"{unique_id}_tracker")},
translation_key="tracker",
translation_placeholders={"display_name": config_entry.title},
)
return DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, unique_id)},
)
FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
FitbitSensorEntityDescription(
key="activities/activityCalories",
name="Activity Calories",
translation_key="activity_calories",
native_unit_of_measurement="cal",
icon="mdi:fire",
scope=FitbitScope.ACTIVITY,
@ -169,7 +160,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/calories",
name="Calories",
translation_key="calories",
native_unit_of_measurement="cal",
icon="mdi:fire",
scope=FitbitScope.ACTIVITY,
@ -177,7 +168,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/caloriesBMR",
name="Calories BMR",
translation_key="calories_bmr",
native_unit_of_measurement="cal",
icon="mdi:fire",
scope=FitbitScope.ACTIVITY,
@ -187,7 +178,6 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/distance",
name="Distance",
icon="mdi:map-marker",
device_class=SensorDeviceClass.DISTANCE,
value_fn=_distance_value_fn,
@ -197,7 +187,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/elevation",
name="Elevation",
translation_key="elevation",
icon="mdi:walk",
device_class=SensorDeviceClass.DISTANCE,
unit_fn=_elevation_unit,
@ -207,7 +197,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/floors",
name="Floors",
translation_key="floors",
native_unit_of_measurement="floors",
icon="mdi:walk",
scope=FitbitScope.ACTIVITY,
@ -216,7 +206,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/heart",
name="Resting Heart Rate",
translation_key="resting_heart_rate",
native_unit_of_measurement="bpm",
icon="mdi:heart-pulse",
value_fn=_int_value_or_none("restingHeartRate"),
@ -225,7 +215,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/minutesFairlyActive",
name="Minutes Fairly Active",
translation_key="minutes_fairly_active",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:walk",
device_class=SensorDeviceClass.DURATION,
@ -235,7 +225,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/minutesLightlyActive",
name="Minutes Lightly Active",
translation_key="minutes_lightly_active",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:walk",
device_class=SensorDeviceClass.DURATION,
@ -245,7 +235,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/minutesSedentary",
name="Minutes Sedentary",
translation_key="minutes_sedentary",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:seat-recline-normal",
device_class=SensorDeviceClass.DURATION,
@ -255,7 +245,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/minutesVeryActive",
name="Minutes Very Active",
translation_key="minutes_very_active",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:run",
device_class=SensorDeviceClass.DURATION,
@ -265,7 +255,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/steps",
name="Steps",
translation_key="steps",
native_unit_of_measurement="steps",
icon="mdi:walk",
scope=FitbitScope.ACTIVITY,
@ -273,7 +263,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/activityCalories",
name="Tracker Activity Calories",
translation_key="activity_calories",
native_unit_of_measurement="cal",
icon="mdi:fire",
scope=FitbitScope.ACTIVITY,
@ -283,7 +273,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/calories",
name="Tracker Calories",
translation_key="calories",
native_unit_of_measurement="cal",
icon="mdi:fire",
scope=FitbitScope.ACTIVITY,
@ -293,7 +283,6 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/distance",
name="Tracker Distance",
icon="mdi:map-marker",
device_class=SensorDeviceClass.DISTANCE,
value_fn=_distance_value_fn,
@ -305,7 +294,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/elevation",
name="Tracker Elevation",
translation_key="elevation",
icon="mdi:walk",
device_class=SensorDeviceClass.DISTANCE,
unit_fn=_elevation_unit,
@ -316,7 +305,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/floors",
name="Tracker Floors",
translation_key="floors",
native_unit_of_measurement="floors",
icon="mdi:walk",
scope=FitbitScope.ACTIVITY,
@ -326,7 +315,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/minutesFairlyActive",
name="Tracker Minutes Fairly Active",
translation_key="minutes_fairly_active",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:walk",
device_class=SensorDeviceClass.DURATION,
@ -337,7 +326,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/minutesLightlyActive",
name="Tracker Minutes Lightly Active",
translation_key="minutes_lightly_active",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:walk",
device_class=SensorDeviceClass.DURATION,
@ -348,7 +337,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/minutesSedentary",
name="Tracker Minutes Sedentary",
translation_key="minutes_sedentary",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:seat-recline-normal",
device_class=SensorDeviceClass.DURATION,
@ -359,7 +348,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/minutesVeryActive",
name="Tracker Minutes Very Active",
translation_key="minutes_very_active",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:run",
device_class=SensorDeviceClass.DURATION,
@ -370,7 +359,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="activities/tracker/steps",
name="Tracker Steps",
translation_key="steps",
native_unit_of_measurement="steps",
icon="mdi:walk",
scope=FitbitScope.ACTIVITY,
@ -380,7 +369,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="body/bmi",
name="BMI",
translation_key="bmi",
native_unit_of_measurement="BMI",
icon="mdi:human",
state_class=SensorStateClass.MEASUREMENT,
@ -391,7 +380,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="body/fat",
name="Body Fat",
translation_key="body_fat",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:human",
state_class=SensorStateClass.MEASUREMENT,
@ -402,7 +391,6 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="body/weight",
name="Weight",
icon="mdi:human",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.WEIGHT,
@ -412,7 +400,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="sleep/awakeningsCount",
name="Awakenings Count",
translation_key="awakenings_count",
native_unit_of_measurement="times awaken",
icon="mdi:sleep",
scope=FitbitScope.SLEEP,
@ -421,7 +409,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="sleep/efficiency",
name="Sleep Efficiency",
translation_key="sleep_efficiency",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:sleep",
state_class=SensorStateClass.MEASUREMENT,
@ -430,7 +418,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="sleep/minutesAfterWakeup",
name="Minutes After Wakeup",
translation_key="minutes_after_wakeup",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:sleep",
device_class=SensorDeviceClass.DURATION,
@ -440,7 +428,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="sleep/minutesAsleep",
name="Sleep Minutes Asleep",
translation_key="sleep_minutes_asleep",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:sleep",
device_class=SensorDeviceClass.DURATION,
@ -450,7 +438,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="sleep/minutesAwake",
name="Sleep Minutes Awake",
translation_key="sleep_minutes_awake",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:sleep",
device_class=SensorDeviceClass.DURATION,
@ -460,7 +448,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="sleep/minutesToFallAsleep",
name="Sleep Minutes to Fall Asleep",
translation_key="sleep_minutes_to_fall_asleep",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:sleep",
device_class=SensorDeviceClass.DURATION,
@ -470,7 +458,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="sleep/timeInBed",
name="Sleep Time in Bed",
translation_key="sleep_time_in_bed",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:hotel",
device_class=SensorDeviceClass.DURATION,
@ -480,7 +468,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="foods/log/caloriesIn",
name="Calories In",
translation_key="calories_in",
native_unit_of_measurement="cal",
icon="mdi:food-apple",
state_class=SensorStateClass.TOTAL_INCREASING,
@ -489,7 +477,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
),
FitbitSensorEntityDescription(
key="foods/log/water",
name="Water",
translation_key="water",
icon="mdi:cup-water",
unit_fn=_water_unit,
state_class=SensorStateClass.TOTAL_INCREASING,
@ -501,14 +489,14 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
# Different description depending on clock format
SLEEP_START_TIME = FitbitSensorEntityDescription(
key="sleep/startTime",
name="Sleep Start Time",
translation_key="sleep_start_time",
icon="mdi:clock",
scope=FitbitScope.SLEEP,
entity_category=EntityCategory.DIAGNOSTIC,
)
SLEEP_START_TIME_12HR = FitbitSensorEntityDescription(
key="sleep/startTime",
name="Sleep Start Time",
translation_key="sleep_start_time",
icon="mdi:clock",
value_fn=_clock_format_12h,
scope=FitbitScope.SLEEP,
@ -533,126 +521,6 @@ FITBIT_RESOURCE_BATTERY_LEVEL = FitbitSensorEntityDescription(
native_unit_of_measurement=PERCENTAGE,
)
FITBIT_RESOURCES_KEYS: Final[list[str]] = [
desc.key
for desc in (*FITBIT_RESOURCES_LIST, FITBIT_RESOURCE_BATTERY, SLEEP_START_TIME)
]
PLATFORM_SCHEMA: Final = SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Optional(
CONF_MONITORED_RESOURCES, default=FITBIT_DEFAULT_RESOURCES
): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_KEYS)]),
vol.Optional(CONF_CLOCK_FORMAT, default=DEFAULT_CLOCK_FORMAT): vol.In(
["12H", "24H"]
),
vol.Optional(CONF_UNIT_SYSTEM, default=FitbitUnitSystem.LEGACY_DEFAULT): vol.In(
[
FitbitUnitSystem.EN_GB,
FitbitUnitSystem.EN_US,
FitbitUnitSystem.METRIC,
FitbitUnitSystem.LEGACY_DEFAULT,
]
),
}
)
# Only import configuration if it was previously created successfully with all
# of the following fields.
FITBIT_CONF_KEYS = [
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
ATTR_ACCESS_TOKEN,
ATTR_REFRESH_TOKEN,
ATTR_LAST_SAVED_AT,
]
def load_config_file(config_path: str) -> dict[str, Any] | None:
"""Load existing valid fitbit.conf from disk for import."""
if os.path.isfile(config_path):
config_file = load_json_object(config_path)
if config_file != DEFAULT_CONFIG and all(
key in config_file for key in FITBIT_CONF_KEYS
):
return config_file
return None
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Fitbit sensor."""
config_path = hass.config.path(FITBIT_CONFIG_FILE)
config_file = await hass.async_add_executor_job(load_config_file, config_path)
_LOGGER.debug("loaded config file: %s", config_file)
if config_file is not None:
_LOGGER.debug("Importing existing fitbit.conf application credentials")
# Refresh the token before importing to ensure it is working and not
# expired on first initialization.
authd_client = Fitbit(
config_file[CONF_CLIENT_ID],
config_file[CONF_CLIENT_SECRET],
access_token=config_file[ATTR_ACCESS_TOKEN],
refresh_token=config_file[ATTR_REFRESH_TOKEN],
expires_at=config_file[ATTR_LAST_SAVED_AT],
refresh_cb=lambda x: None,
)
try:
updated_token = await hass.async_add_executor_job(
authd_client.client.refresh_token
)
except OAuth2Error as err:
_LOGGER.debug("Unable to import fitbit OAuth2 credentials: %s", err)
translation_key = "deprecated_yaml_import_issue_cannot_connect"
else:
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(
config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET]
),
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
"auth_implementation": DOMAIN,
CONF_TOKEN: {
ATTR_ACCESS_TOKEN: updated_token[ATTR_ACCESS_TOKEN],
ATTR_REFRESH_TOKEN: updated_token[ATTR_REFRESH_TOKEN],
"expires_at": updated_token["expires_at"],
"scope": " ".join(updated_token.get("scope", [])),
},
CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT],
CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM],
CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES],
},
)
translation_key = "deprecated_yaml_import"
if (
result.get("type") == FlowResultType.ABORT
and result.get("reason") == "cannot_connect"
):
translation_key = "deprecated_yaml_import_issue_cannot_connect"
else:
translation_key = "deprecated_yaml_no_import"
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml",
breaks_in_ha_version="2024.5.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=translation_key,
)
async def async_setup_entry(
hass: HomeAssistant,
@ -694,6 +562,7 @@ async def async_setup_entry(
description,
units=description.unit_fn(unit_system),
enable_default_override=is_explicit_enable(description),
device_info=_build_device_info(entry, description),
)
for description in resource_list
if is_allowed_resource(description)
@ -728,6 +597,7 @@ class FitbitSensor(SensorEntity):
entity_description: FitbitSensorEntityDescription
_attr_attribution = ATTRIBUTION
_attr_has_entity_name = True
def __init__(
self,
@ -737,6 +607,7 @@ class FitbitSensor(SensorEntity):
description: FitbitSensorEntityDescription,
units: str | None,
enable_default_override: bool,
device_info: DeviceInfo,
) -> None:
"""Initialize the Fitbit sensor."""
self.config_entry = config_entry
@ -744,6 +615,7 @@ class FitbitSensor(SensorEntity):
self.api = api
self._attr_unique_id = f"{user_profile_id}_{description.key}"
self._attr_device_info = device_info
if units is not None:
self._attr_native_unit_of_measurement = units

View File

@ -38,21 +38,82 @@
},
"battery_level": {
"name": "Battery level"
},
"activity_calories": {
"name": "Activity calories"
},
"calories": {
"name": "Calories"
},
"calories_bmr": {
"name": "Calories BMR"
},
"elevation": {
"name": "Elevation"
},
"floors": {
"name": "Floors"
},
"resting_heart_rate": {
"name": "Resting heart rate"
},
"minutes_fairly_active": {
"name": "Minutes fairly active"
},
"minutes_lightly_active": {
"name": "Minutes lightly active"
},
"minutes_sedentary": {
"name": "Minutes sedentary"
},
"minutes_very_active": {
"name": "Minutes very active"
},
"sleep_start_time": {
"name": "Sleep start time"
},
"steps": {
"name": "Steps"
},
"bmi": {
"name": "BMI"
},
"body_fat": {
"name": "Body fat"
},
"awakenings_count": {
"name": "Awakenings count"
},
"sleep_efficiency": {
"name": "Sleep efficiency"
},
"minutes_after_wakeup": {
"name": "Minutes after wakeup"
},
"sleep_minutes_asleep": {
"name": "Sleep minutes asleep"
},
"sleep_minutes_awake": {
"name": "Sleep minutes awake"
},
"sleep_minutes_to_fall_asleep": {
"name": "Sleep minutes to fall asleep"
},
"sleep_time_in_bed": {
"name": "Sleep time in bed"
},
"calories_in": {
"name": "Calories in"
},
"water": {
"name": "Water"
}
}
},
"issues": {
"deprecated_yaml_no_import": {
"title": "Fitbit YAML configuration is being removed",
"description": "Configuring Fitbit using YAML is being removed.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf if it exists and restart Home Assistant and [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually."
},
"deprecated_yaml_import": {
"title": "Fitbit YAML configuration is being removed",
"description": "Configuring Fitbit using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically, including OAuth Application Credentials.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf and restart Home Assistant to fix this issue."
},
"deprecated_yaml_import_issue_cannot_connect": {
"title": "The Fitbit YAML configuration import failed",
"description": "Configuring Fitbit using YAML is being removed but there was a connection error importing your YAML configuration.\n\nRestart Home Assistant to try again or remove the Fitbit YAML configuration from your configuration.yaml file and remove the fitbit.conf and continue to [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually."
"device": {
"tracker": {
"name": "{display_name} tracker"
}
}
}

View File

@ -282,7 +282,7 @@ async def async_test_stream(
return {CONF_STREAM_SOURCE: "timeout"}
await stream.stop()
except StreamWorkerError as err:
return {CONF_STREAM_SOURCE: str(err)}
return {CONF_STREAM_SOURCE: "unknown_with_details", "error_details": str(err)}
except PermissionError:
return {CONF_STREAM_SOURCE: "stream_not_permitted"}
except OSError as err:
@ -339,6 +339,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the start of the config flow."""
errors = {}
description_placeholders = {}
hass = self.hass
if user_input:
# Secondary validation because serialised vol can't seem to handle this complexity:
@ -372,6 +373,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
# temporary preview for user to check the image
self.preview_cam = user_input
return await self.async_step_user_confirm_still()
if "error_details" in errors:
description_placeholders["error"] = errors.pop("error_details")
elif self.user_input:
user_input = self.user_input
else:
@ -379,6 +382,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
data_schema=build_schema(user_input),
description_placeholders=description_placeholders,
errors=errors,
)

View File

@ -3,6 +3,7 @@
"config": {
"error": {
"unknown": "[%key:common::config_flow::error::unknown%]",
"unknown_with_details": "An unknown error occurred: {error}",
"already_exists": "A camera with these URL settings already exists.",
"unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.",
"unable_still_load_auth": "Unable to load valid image from still image URL: The camera may require a user name and password, or they are not correct.",

View File

@ -16,7 +16,7 @@ from go2rtc_client.ws import (
WsError,
)
import voluptuous as vol
from webrtc_models import RTCIceCandidate
from webrtc_models import RTCIceCandidateInit
from homeassistant.components.camera import (
Camera,
@ -264,7 +264,7 @@ class WebRTCProvider(CameraWebRTCProvider):
value: WebRTCMessage
match message:
case WebRTCCandidate():
value = HAWebRTCCandidate(RTCIceCandidate(message.candidate))
value = HAWebRTCCandidate(RTCIceCandidateInit(message.candidate))
case WebRTCAnswer():
value = HAWebRTCAnswer(message.sdp)
case WsError():
@ -277,7 +277,7 @@ class WebRTCProvider(CameraWebRTCProvider):
await ws_client.send(WebRTCOffer(offer_sdp, config.configuration.ice_servers))
async def async_on_webrtc_candidate(
self, session_id: str, candidate: RTCIceCandidate
self, session_id: str, candidate: RTCIceCandidateInit
) -> None:
"""Handle the WebRTC candidate."""

View File

@ -45,7 +45,7 @@
}
},
"application_credentials": {
"description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your Calendar:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **TV and Limited Input devices** for the Application Type."
"description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type."
},
"services": {
"add_event": {

View File

@ -66,10 +66,6 @@ class OAuth2FlowHandler(
self._get_reauth_entry(), data=data
)
if self._async_current_entries():
# Config entry already exists, only one allowed.
return self.async_abort(reason="single_instance_allowed")
return self.async_create_entry(
title=DEFAULT_NAME,
data=data,

View File

@ -8,5 +8,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["gassist-text==0.0.11"]
"requirements": ["gassist-text==0.0.11"],
"single_config_entry": true
}

View File

@ -7,6 +7,7 @@ import logging
from googlemaps import Client
from googlemaps.distance_matrix import distance_matrix
from googlemaps.exceptions import ApiError, Timeout, TransportError
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -172,9 +173,13 @@ class GoogleTravelTimeSensor(SensorEntity):
self._resolved_destination,
)
if self._resolved_destination is not None and self._resolved_origin is not None:
self._matrix = distance_matrix(
self._client,
self._resolved_origin,
self._resolved_destination,
**options_copy,
)
try:
self._matrix = distance_matrix(
self._client,
self._resolved_origin,
self._resolved_destination,
**options_copy,
)
except (ApiError, TransportError, Timeout) as ex:
_LOGGER.error("Error getting travel time: %s", ex)
self._matrix = None

View File

@ -26,6 +26,8 @@ ATTR_CONFIG_ENTRY = "config_entry"
ATTR_SKILL = "skill"
ATTR_TASK = "task"
ATTR_DIRECTION = "direction"
ATTR_TARGET = "target"
ATTR_ITEM = "item"
SERVICE_CAST_SKILL = "cast_skill"
SERVICE_START_QUEST = "start_quest"
SERVICE_ACCEPT_QUEST = "accept_quest"
@ -36,6 +38,9 @@ SERVICE_LEAVE_QUEST = "leave_quest"
SERVICE_SCORE_HABIT = "score_habit"
SERVICE_SCORE_REWARD = "score_reward"
SERVICE_TRANSFORMATION = "transformation"
WARRIOR = "warrior"
ROGUE = "rogue"
HEALER = "healer"

View File

@ -187,6 +187,9 @@
},
"score_reward": {
"service": "mdi:sack"
},
"transformation": {
"service": "mdi:flask-round-bottom"
}
}
}

View File

@ -24,7 +24,7 @@ from homeassistant.helpers.issue_registry import (
)
from homeassistant.helpers.typing import StateType
from .const import DOMAIN, UNIT_TASKS
from .const import ASSETS_URL, DOMAIN, UNIT_TASKS
from .entity import HabiticaBase
from .types import HabiticaConfigEntry
from .util import entity_used_in, get_attribute_points, get_attributes_total
@ -40,6 +40,7 @@ class HabitipySensorEntityDescription(SensorEntityDescription):
attributes_fn: (
Callable[[dict[str, Any], dict[str, Any]], dict[str, Any] | None] | None
) = None
entity_picture: str | None = None
@dataclass(kw_only=True, frozen=True)
@ -144,6 +145,7 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
value_fn=lambda user, _: user.get("balance", 0) * 4,
suggested_display_precision=0,
native_unit_of_measurement="gems",
entity_picture="shop_gem.png",
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.TRINKETS,
@ -293,6 +295,13 @@ class HabitipySensor(HabiticaBase, SensorEntity):
return func(self.coordinator.data.user, self.coordinator.content)
return None
@property
def entity_picture(self) -> str | None:
"""Return the entity picture to use in the frontend, if any."""
if entity_picture := self.entity_description.entity_picture:
return f"{ASSETS_URL}{entity_picture}"
return None
class HabitipyTaskSensor(HabiticaBase, SensorEntity):
"""A Habitica task sensor."""

View File

@ -27,8 +27,10 @@ from .const import (
ATTR_CONFIG_ENTRY,
ATTR_DATA,
ATTR_DIRECTION,
ATTR_ITEM,
ATTR_PATH,
ATTR_SKILL,
ATTR_TARGET,
ATTR_TASK,
DOMAIN,
EVENT_API_CALL_SUCCESS,
@ -42,6 +44,7 @@ from .const import (
SERVICE_SCORE_HABIT,
SERVICE_SCORE_REWARD,
SERVICE_START_QUEST,
SERVICE_TRANSFORMATION,
)
from .types import HabiticaConfigEntry
@ -77,6 +80,14 @@ SERVICE_SCORE_TASK_SCHEMA = vol.Schema(
}
)
SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
vol.Required(ATTR_ITEM): cv.string,
vol.Required(ATTR_TARGET): cv.string,
}
)
def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
"""Return config entry or raise if not found or not loaded."""
@ -294,6 +305,83 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
await coordinator.async_request_refresh()
return response
async def transformation(call: ServiceCall) -> ServiceResponse:
"""User a transformation item on a player character."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data
ITEMID_MAP = {
"snowball": {"itemId": "snowball"},
"spooky_sparkles": {"itemId": "spookySparkles"},
"seafoam": {"itemId": "seafoam"},
"shiny_seed": {"itemId": "shinySeed"},
}
# check if target is self
if call.data[ATTR_TARGET] in (
coordinator.data.user["id"],
coordinator.data.user["profile"]["name"],
coordinator.data.user["auth"]["local"]["username"],
):
target_id = coordinator.data.user["id"]
else:
# check if target is a party member
try:
party = await coordinator.api.groups.party.members.get()
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
if e.status == HTTPStatus.NOT_FOUND:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="party_not_found",
) from e
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
try:
target_id = next(
member["id"]
for member in party
if call.data[ATTR_TARGET].lower()
in (
member["id"],
member["auth"]["local"]["username"].lower(),
member["profile"]["name"].lower(),
)
)
except StopIteration as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="target_not_found",
translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"},
) from e
try:
response: dict[str, Any] = await coordinator.api.user.class_.cast[
ITEMID_MAP[call.data[ATTR_ITEM]]["itemId"]
].post(targetId=target_id)
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
if e.status == HTTPStatus.UNAUTHORIZED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="item_not_found",
translation_placeholders={"item": call.data[ATTR_ITEM]},
) from e
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
else:
return response
hass.services.async_register(
DOMAIN,
SERVICE_API_CALL,
@ -323,3 +411,11 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
schema=SERVICE_SCORE_TASK_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_TRANSFORMATION,
transformation,
schema=SERVICE_TRANSFORMATION_SCHEMA,
supports_response=SupportsResponse.ONLY,
)

View File

@ -72,3 +72,25 @@ score_reward:
fields:
config_entry: *config_entry
task: *task
transformation:
fields:
config_entry:
required: true
selector:
config_entry:
integration: habitica
item:
required: true
selector:
select:
options:
- "snowball"
- "spooky_sparkles"
- "seafoam"
- "shiny_seed"
mode: dropdown
translation_key: "transformation_item_select"
target:
required: true
selector:
text:

View File

@ -321,6 +321,15 @@
},
"quest_not_found": {
"message": "Unable to complete action, quest or group not found"
},
"target_not_found": {
"message": "Unable to find target {target} in your party"
},
"party_not_found": {
"message": "Unable to find target, you are currently not in a party. You can only target yourself"
},
"item_not_found": {
"message": "Unable to use {item}, you don't own this item."
}
},
"issues": {
@ -461,6 +470,24 @@
"description": "The name (or task ID) of the custom reward."
}
}
},
"transformation": {
"name": "Use a transformation item",
"description": "Use a transformation item from your Habitica character's inventory on a member of your party or yourself.",
"fields": {
"config_entry": {
"name": "Select character",
"description": "Choose the Habitica character to use the transformation item."
},
"item": {
"name": "Transformation item",
"description": "Select the transformation item you want to use. Item must be in the characters inventory."
},
"target": {
"name": "Target character",
"description": "The name of the character you want to use the transformation item on. You can also specify the players username or user ID."
}
}
}
},
"selector": {
@ -471,6 +498,14 @@
"backstab": "Rogue: Backstab",
"smash": "Warrior: Brutal smash"
}
},
"transformation_item_select": {
"options": {
"snowball": "Snowball",
"spooky_sparkles": "Spooky sparkles",
"seafoam": "Seafoam",
"shiny_seed": "Shiny seed"
}
}
}
}

View File

@ -0,0 +1,20 @@
"""Diagnostics support for Home Connect Diagnostics."""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
device.appliance.haId: device.appliance.status
for device in hass.data[DOMAIN][config_entry.entry_id].devices
}

View File

@ -7,5 +7,5 @@
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"quality_scale": "silver",
"requirements": ["homematicip==1.1.2"]
"requirements": ["homematicip==1.1.3"]
}

View File

@ -505,15 +505,14 @@ class HomeAssistantHTTP:
self, url_path: str, path: str, cache_headers: bool = True
) -> None:
"""Register a folder or file to serve as a static path."""
frame.report(
frame.report_usage(
"calls hass.http.register_static_path which is deprecated because "
"it does blocking I/O in the event loop, instead "
"call `await hass.http.async_register_static_paths("
f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`; '
"This function will be removed in 2025.7",
exclude_integrations={"http"},
error_if_core=False,
error_if_integration=False,
core_behavior=frame.ReportBehavior.LOG,
)
configs = [StaticPathConfig(url_path, path, cache_headers)]
resources = self._make_static_resources(configs)

View File

@ -130,10 +130,15 @@ class HueSceneEntity(HueSceneEntityBase):
@property
def is_dynamic(self) -> bool:
"""Return if this scene has a dynamic color palette."""
if self.resource.palette.color and len(self.resource.palette.color) > 1:
if (
self.resource.palette
and self.resource.palette.color
and len(self.resource.palette.color) > 1
):
return True
if (
self.resource.palette.color_temperature
self.resource.palette
and self.resource.palette.color_temperature
and len(self.resource.palette.color_temperature) > 1
):
return True

View File

@ -95,7 +95,7 @@ class PowerViewNumber(ShadeEntity, RestoreNumber):
self.entity_description = description
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
def set_native_value(self, value: float) -> None:
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
self._attr_native_value = value
self.entity_description.store_value_fn(self.coordinator, self._shade.id, value)

View File

@ -3,24 +3,42 @@
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
from aioautomower.model import MowerActivities, MowerAttributes
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.components.script import scripts_with_entity
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from . import AutomowerConfigEntry
from .const import DOMAIN
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerBaseEntity
_LOGGER = logging.getLogger(__name__)
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Get list of related automations and scripts."""
used_in = automations_with_entity(hass, entity_id)
used_in += scripts_with_entity(hass, entity_id)
return used_in
@dataclass(frozen=True, kw_only=True)
class AutomowerBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes Automower binary sensor entity."""
@ -43,6 +61,7 @@ MOWER_BINARY_SENSOR_TYPES: tuple[AutomowerBinarySensorEntityDescription, ...] =
key="returning_to_dock",
translation_key="returning_to_dock",
value_fn=lambda data: data.mower.activity == MowerActivities.GOING_HOME,
entity_registry_enabled_default=False,
),
)
@ -81,3 +100,39 @@ class AutomowerBinarySensorEntity(AutomowerBaseEntity, BinarySensorEntity):
def is_on(self) -> bool:
"""Return the state of the binary sensor."""
return self.entity_description.value_fn(self.mower_attributes)
async def async_added_to_hass(self) -> None:
"""Raise issue when entity is registered and was not disabled."""
if TYPE_CHECKING:
assert self.unique_id
if not (
entity_id := er.async_get(self.hass).async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, self.unique_id
)
):
return
if (
self.enabled
and self.entity_description.key == "returning_to_dock"
and entity_used_in(self.hass, entity_id)
):
async_create_issue(
self.hass,
DOMAIN,
f"deprecated_entity_{self.entity_description.key}",
breaks_in_ha_version="2025.6.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_entity",
translation_placeholders={
"entity_name": str(self.name),
"entity": entity_id,
},
)
else:
async_delete_issue(
self.hass,
DOMAIN,
f"deprecated_task_entity_{self.entity_description.key}",
)
await super().async_added_to_hass()

View File

@ -311,6 +311,12 @@
}
}
},
"issues": {
"deprecated_entity": {
"title": "The Husqvarna Automower {entity_name} sensor is deprecated",
"description": "The Husqavarna Automower entity `{entity}` is deprecated and will be removed in a future release.\nYou can use the new returning state of the lawn mower entity instead.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`."
}
},
"services": {
"override_schedule": {
"name": "Override schedule",

View File

@ -32,6 +32,10 @@
}
},
"options": {
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"step": {
"init": {
"title": "Options",

View File

@ -7,8 +7,12 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from .const import DOMAIN, SCAN_INTERVAL
from .coordinator import HydrawiseDataUpdateCoordinator
from .const import DOMAIN
from .coordinator import (
HydrawiseMainDataUpdateCoordinator,
HydrawiseUpdateCoordinators,
HydrawiseWaterUseDataUpdateCoordinator,
)
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@ -29,9 +33,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD])
)
coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator
main_coordinator = HydrawiseMainDataUpdateCoordinator(hass, hydrawise)
await main_coordinator.async_config_entry_first_refresh()
water_use_coordinator = HydrawiseWaterUseDataUpdateCoordinator(
hass, hydrawise, main_coordinator
)
await water_use_coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = (
HydrawiseUpdateCoordinators(
main=main_coordinator,
water_use=water_use_coordinator,
)
)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True

View File

@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from .const import DOMAIN, SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND
from .coordinator import HydrawiseDataUpdateCoordinator
from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
@ -81,18 +81,16 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise binary_sensor platform."""
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
entities: list[HydrawiseBinarySensor] = []
for controller in coordinator.data.controllers.values():
for controller in coordinators.main.data.controllers.values():
entities.extend(
HydrawiseBinarySensor(coordinator, description, controller)
HydrawiseBinarySensor(coordinators.main, description, controller)
for description in CONTROLLER_BINARY_SENSORS
)
entities.extend(
HydrawiseBinarySensor(
coordinator,
coordinators.main,
description,
controller,
sensor_id=sensor.id,
@ -103,7 +101,7 @@ async def async_setup_entry(
)
entities.extend(
HydrawiseZoneBinarySensor(
coordinator, description, controller, zone_id=zone.id
coordinators.main, description, controller, zone_id=zone.id
)
for zone in controller.zones
for description in ZONE_BINARY_SENSORS

View File

@ -10,7 +10,8 @@ DEFAULT_WATERING_TIME = timedelta(minutes=15)
MANUFACTURER = "Hydrawise"
SCAN_INTERVAL = timedelta(seconds=60)
MAIN_SCAN_INTERVAL = timedelta(minutes=5)
WATER_USE_SCAN_INTERVAL = timedelta(minutes=60)
SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update"

View File

@ -2,8 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from dataclasses import dataclass, field
from pydrawise import Hydrawise
from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone
@ -12,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.dt import now
from .const import DOMAIN, LOGGER
from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL
@dataclass
@ -20,22 +19,39 @@ class HydrawiseData:
"""Container for data fetched from the Hydrawise API."""
user: User
controllers: dict[int, Controller]
zones: dict[int, Zone]
sensors: dict[int, Sensor]
daily_water_summary: dict[int, ControllerWaterUseSummary]
controllers: dict[int, Controller] = field(default_factory=dict)
zones: dict[int, Zone] = field(default_factory=dict)
sensors: dict[int, Sensor] = field(default_factory=dict)
daily_water_summary: dict[int, ControllerWaterUseSummary] = field(
default_factory=dict
)
@dataclass
class HydrawiseUpdateCoordinators:
"""Container for all Hydrawise DataUpdateCoordinator instances."""
main: HydrawiseMainDataUpdateCoordinator
water_use: HydrawiseWaterUseDataUpdateCoordinator
class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]):
"""The Hydrawise Data Update Coordinator."""
"""Base class for Hydrawise Data Update Coordinators."""
api: Hydrawise
def __init__(
self, hass: HomeAssistant, api: Hydrawise, scan_interval: timedelta
) -> None:
class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
"""The main Hydrawise Data Update Coordinator.
This fetches the primary state data for Hydrawise controllers and zones
at a relatively frequent interval so that the primary functions of the
integration are updated in a timely manner.
"""
def __init__(self, hass: HomeAssistant, api: Hydrawise) -> None:
"""Initialize HydrawiseDataUpdateCoordinator."""
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval)
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=MAIN_SCAN_INTERVAL)
self.api = api
async def _async_update_data(self) -> HydrawiseData:
@ -43,28 +59,56 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]):
# Don't fetch zones. We'll fetch them for each controller later.
# This is to prevent 502 errors in some cases.
# See: https://github.com/home-assistant/core/issues/120128
user = await self.api.get_user(fetch_zones=False)
controllers = {}
zones = {}
sensors = {}
daily_water_summary: dict[int, ControllerWaterUseSummary] = {}
for controller in user.controllers:
controllers[controller.id] = controller
data = HydrawiseData(user=await self.api.get_user(fetch_zones=False))
for controller in data.user.controllers:
data.controllers[controller.id] = controller
controller.zones = await self.api.get_zones(controller)
for zone in controller.zones:
zones[zone.id] = zone
data.zones[zone.id] = zone
for sensor in controller.sensors:
sensors[sensor.id] = sensor
data.sensors[sensor.id] = sensor
return data
class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
"""Data Update Coordinator for Hydrawise Water Use.
This fetches data that is more expensive for the Hydrawise API to compute
at a less frequent interval as to not overload the Hydrawise servers.
"""
_main_coordinator: HydrawiseMainDataUpdateCoordinator
def __init__(
self,
hass: HomeAssistant,
api: Hydrawise,
main_coordinator: HydrawiseMainDataUpdateCoordinator,
) -> None:
"""Initialize HydrawiseWaterUseDataUpdateCoordinator."""
super().__init__(
hass,
LOGGER,
name=f"{DOMAIN} water use",
update_interval=WATER_USE_SCAN_INTERVAL,
)
self.api = api
self._main_coordinator = main_coordinator
async def _async_update_data(self) -> HydrawiseData:
"""Fetch the latest data from Hydrawise."""
daily_water_summary: dict[int, ControllerWaterUseSummary] = {}
for controller in self._main_coordinator.data.controllers.values():
daily_water_summary[controller.id] = await self.api.get_water_use_summary(
controller,
now().replace(hour=0, minute=0, second=0, microsecond=0),
now(),
)
main_data = self._main_coordinator.data
return HydrawiseData(
user=user,
controllers=controllers,
zones=zones,
sensors=sensors,
user=main_data.user,
controllers=main_data.controllers,
zones=main_data.zones,
sensors=main_data.sensors,
daily_water_summary=daily_water_summary,
)

View File

@ -4,9 +4,11 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import timedelta
from typing import Any
from pydrawise.schema import ControllerWaterUseSummary
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@ -19,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import HydrawiseDataUpdateCoordinator
from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
@ -30,100 +32,58 @@ class HydrawiseSensorEntityDescription(SensorEntityDescription):
value_fn: Callable[[HydrawiseSensor], Any]
def _get_zone_watering_time(sensor: HydrawiseSensor) -> int:
if (current_run := sensor.zone.scheduled_runs.current_run) is not None:
return int(current_run.remaining_time.total_seconds() / 60)
return 0
def _get_water_use(sensor: HydrawiseSensor) -> ControllerWaterUseSummary:
return sensor.coordinator.data.daily_water_summary[sensor.controller.id]
def _get_zone_next_cycle(sensor: HydrawiseSensor) -> datetime | None:
if (next_run := sensor.zone.scheduled_runs.next_run) is not None:
return dt_util.as_utc(next_run.start_time)
return None
def _get_zone_daily_active_water_use(sensor: HydrawiseSensor) -> float:
"""Get active water use for the zone."""
daily_water_summary = sensor.coordinator.data.daily_water_summary[
sensor.controller.id
]
return float(daily_water_summary.active_use_by_zone_id.get(sensor.zone.id, 0.0))
def _get_zone_daily_active_water_time(sensor: HydrawiseSensor) -> float | None:
"""Get active water time for the zone."""
daily_water_summary = sensor.coordinator.data.daily_water_summary[
sensor.controller.id
]
return daily_water_summary.active_time_by_zone_id.get(
sensor.zone.id, timedelta()
).total_seconds()
def _get_controller_daily_active_water_use(sensor: HydrawiseSensor) -> float | None:
"""Get active water use for the controller."""
daily_water_summary = sensor.coordinator.data.daily_water_summary[
sensor.controller.id
]
return daily_water_summary.total_active_use
def _get_controller_daily_inactive_water_use(sensor: HydrawiseSensor) -> float | None:
"""Get inactive water use for the controller."""
daily_water_summary = sensor.coordinator.data.daily_water_summary[
sensor.controller.id
]
return daily_water_summary.total_inactive_use
def _get_controller_daily_active_water_time(sensor: HydrawiseSensor) -> float:
"""Get active water time for the controller."""
daily_water_summary = sensor.coordinator.data.daily_water_summary[
sensor.controller.id
]
return daily_water_summary.total_active_time.total_seconds()
def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | None:
"""Get inactive water use for the controller."""
daily_water_summary = sensor.coordinator.data.daily_water_summary[
sensor.controller.id
]
return daily_water_summary.total_use
CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
HydrawiseSensorEntityDescription(
key="daily_active_water_time",
translation_key="daily_active_water_time",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
value_fn=_get_controller_daily_active_water_time,
value_fn=lambda sensor: _get_water_use(
sensor
).total_active_time.total_seconds(),
),
)
WATER_USE_ZONE_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
HydrawiseSensorEntityDescription(
key="daily_active_water_time",
translation_key="daily_active_water_time",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
value_fn=lambda sensor: (
_get_water_use(sensor)
.active_time_by_zone_id.get(sensor.zone.id, timedelta())
.total_seconds()
),
),
)
FLOW_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
HydrawiseSensorEntityDescription(
key="daily_total_water_use",
translation_key="daily_total_water_use",
device_class=SensorDeviceClass.VOLUME,
suggested_display_precision=1,
value_fn=_get_controller_daily_total_water_use,
value_fn=lambda sensor: _get_water_use(sensor).total_use,
),
HydrawiseSensorEntityDescription(
key="daily_active_water_use",
translation_key="daily_active_water_use",
device_class=SensorDeviceClass.VOLUME,
suggested_display_precision=1,
value_fn=_get_controller_daily_active_water_use,
value_fn=lambda sensor: _get_water_use(sensor).total_active_use,
),
HydrawiseSensorEntityDescription(
key="daily_inactive_water_use",
translation_key="daily_inactive_water_use",
device_class=SensorDeviceClass.VOLUME,
suggested_display_precision=1,
value_fn=_get_controller_daily_inactive_water_use,
value_fn=lambda sensor: _get_water_use(sensor).total_inactive_use,
),
)
@ -133,7 +93,9 @@ FLOW_ZONE_SENSORS: tuple[SensorEntityDescription, ...] = (
translation_key="daily_active_water_use",
device_class=SensorDeviceClass.VOLUME,
suggested_display_precision=1,
value_fn=_get_zone_daily_active_water_use,
value_fn=lambda sensor: float(
_get_water_use(sensor).active_use_by_zone_id.get(sensor.zone.id, 0.0)
),
),
)
@ -142,20 +104,24 @@ ZONE_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
key="next_cycle",
translation_key="next_cycle",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=_get_zone_next_cycle,
value_fn=lambda sensor: (
dt_util.as_utc(sensor.zone.scheduled_runs.next_run.start_time)
if sensor.zone.scheduled_runs.next_run is not None
else None
),
),
HydrawiseSensorEntityDescription(
key="watering_time",
translation_key="watering_time",
native_unit_of_measurement=UnitOfTime.MINUTES,
value_fn=_get_zone_watering_time,
),
HydrawiseSensorEntityDescription(
key="daily_active_water_time",
translation_key="daily_active_water_time",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
value_fn=_get_zone_daily_active_water_time,
value_fn=lambda sensor: (
int(
sensor.zone.scheduled_runs.current_run.remaining_time.total_seconds()
/ 60
)
if sensor.zone.scheduled_runs.current_run is not None
else 0
),
),
)
@ -168,29 +134,37 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise sensor platform."""
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
entities: list[HydrawiseSensor] = []
for controller in coordinator.data.controllers.values():
for controller in coordinators.main.data.controllers.values():
entities.extend(
HydrawiseSensor(coordinator, description, controller)
for description in CONTROLLER_SENSORS
HydrawiseSensor(coordinators.water_use, description, controller)
for description in WATER_USE_CONTROLLER_SENSORS
)
entities.extend(
HydrawiseSensor(coordinator, description, controller, zone_id=zone.id)
HydrawiseSensor(
coordinators.water_use, description, controller, zone_id=zone.id
)
for zone in controller.zones
for description in WATER_USE_ZONE_SENSORS
)
entities.extend(
HydrawiseSensor(coordinators.main, description, controller, zone_id=zone.id)
for zone in controller.zones
for description in ZONE_SENSORS
)
if coordinator.data.daily_water_summary[controller.id].total_use is not None:
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(
HydrawiseSensor(coordinator, description, controller)
HydrawiseSensor(coordinators.water_use, description, controller)
for description in FLOW_CONTROLLER_SENSORS
)
entities.extend(
HydrawiseSensor(
coordinator,
coordinators.water_use,
description,
controller,
zone_id=zone.id,

View File

@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DEFAULT_WATERING_TIME, DOMAIN
from .coordinator import HydrawiseDataUpdateCoordinator
from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
@ -66,12 +66,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise switch platform."""
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
HydrawiseSwitch(coordinator, description, controller, zone_id=zone.id)
for controller in coordinator.data.controllers.values()
HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id)
for controller in coordinators.main.data.controllers.values()
for zone in controller.zones
for description in SWITCH_TYPES
)

View File

@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import HydrawiseDataUpdateCoordinator
from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
VALVE_TYPES: tuple[ValveEntityDescription, ...] = (
@ -34,12 +34,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise valve platform."""
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
HydrawiseValve(coordinator, description, controller, zone_id=zone.id)
for controller in coordinator.data.controllers.values()
HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id)
for controller in coordinators.main.data.controllers.values()
for zone in controller.zones
for description in VALVE_TYPES
)

View File

@ -52,8 +52,8 @@ CONF_KNX_DEFAULT_RATE_LIMIT: Final = 0
DEFAULT_ROUTING_IA: Final = "0.0.240"
CONF_KNX_TELEGRAM_LOG_SIZE: Final = "telegram_log_size"
TELEGRAM_LOG_DEFAULT: Final = 200
TELEGRAM_LOG_MAX: Final = 5000 # ~2 MB or ~5 hours of reasonable bus load
TELEGRAM_LOG_DEFAULT: Final = 1000
TELEGRAM_LOG_MAX: Final = 25000 # ~10 MB or ~25 hours of reasonable bus load
##
# Secure constants

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Any, cast
from propcache import cached_property
from xknx import XKNX
from xknx.devices.light import ColorTemperatureType, Light as XknxLight, XYYColor
@ -389,39 +390,47 @@ class _KnxLight(LightEntity):
)
return None
@property
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
if self._device.supports_xyy_color:
return ColorMode.XY
if self._device.supports_hs_color:
return ColorMode.HS
if self._device.supports_rgbw:
return ColorMode.RGBW
if self._device.supports_color:
return ColorMode.RGB
@cached_property
def supported_color_modes(self) -> set[ColorMode]:
"""Get supported color modes."""
color_mode = set()
if (
self._device.supports_color_temperature
or self._device.supports_tunable_white
):
return ColorMode.COLOR_TEMP
if self._device.supports_brightness:
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
@property
def supported_color_modes(self) -> set[ColorMode]:
"""Flag supported color modes."""
return {self.color_mode}
color_mode.add(ColorMode.COLOR_TEMP)
if self._device.supports_xyy_color:
color_mode.add(ColorMode.XY)
if self._device.supports_rgbw:
color_mode.add(ColorMode.RGBW)
elif self._device.supports_color:
# one of RGB or RGBW so individual color configurations work properly
color_mode.add(ColorMode.RGB)
if self._device.supports_hs_color:
color_mode.add(ColorMode.HS)
if not color_mode:
# brightness or on/off must be the only supported mode
if self._device.supports_brightness:
color_mode.add(ColorMode.BRIGHTNESS)
else:
color_mode.add(ColorMode.ONOFF)
return color_mode
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
brightness = kwargs.get(ATTR_BRIGHTNESS)
color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
rgb = kwargs.get(ATTR_RGB_COLOR)
rgbw = kwargs.get(ATTR_RGBW_COLOR)
hs_color = kwargs.get(ATTR_HS_COLOR)
xy_color = kwargs.get(ATTR_XY_COLOR)
# LightEntity color translation will ensure that only attributes of supported
# color modes are passed to this method - so we can't set unsupported mode here
if color_temp := kwargs.get(ATTR_COLOR_TEMP_KELVIN):
self._attr_color_mode = ColorMode.COLOR_TEMP
if rgb := kwargs.get(ATTR_RGB_COLOR):
self._attr_color_mode = ColorMode.RGB
if rgbw := kwargs.get(ATTR_RGBW_COLOR):
self._attr_color_mode = ColorMode.RGBW
if hs_color := kwargs.get(ATTR_HS_COLOR):
self._attr_color_mode = ColorMode.HS
if xy_color := kwargs.get(ATTR_XY_COLOR):
self._attr_color_mode = ColorMode.XY
if (
not self.is_on
@ -500,17 +509,17 @@ class _KnxLight(LightEntity):
await self._device.set_brightness(brightness)
return
# brightness without color in kwargs; set via color
if self.color_mode == ColorMode.XY:
if self._attr_color_mode == ColorMode.XY:
await self._device.set_xyy_color(XYYColor(brightness=brightness))
return
# default to white if color not known for RGB(W)
if self.color_mode == ColorMode.RGBW:
if self._attr_color_mode == ColorMode.RGBW:
_rgbw = self.rgbw_color
if not _rgbw or not any(_rgbw):
_rgbw = (0, 0, 0, 255)
await set_color(_rgbw[:3], _rgbw[3], brightness)
return
if self.color_mode == ColorMode.RGB:
if self._attr_color_mode == ColorMode.RGB:
_rgb = self.rgb_color
if not _rgb or not any(_rgb):
_rgb = (255, 255, 255)
@ -533,6 +542,7 @@ class KnxYamlLight(_KnxLight, KnxYamlEntity):
knx_module=knx_module,
device=_create_yaml_light(knx_module.xknx, config),
)
self._attr_color_mode = next(iter(self.supported_color_modes))
self._attr_max_color_temp_kelvin: int = config[LightSchema.CONF_MAX_KELVIN]
self._attr_min_color_temp_kelvin: int = config[LightSchema.CONF_MIN_KELVIN]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
@ -566,5 +576,6 @@ class KnxUiLight(_KnxLight, KnxUiEntity):
self._device = _create_ui_light(
knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME]
)
self._attr_color_mode = next(iter(self.supported_color_modes))
self._attr_max_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MAX]
self._attr_min_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MIN]

View File

@ -13,7 +13,7 @@
"requirements": [
"xknx==3.3.0",
"xknxproject==3.8.1",
"knx-frontend==2024.9.10.221729"
"knx-frontend==2024.11.16.205004"
],
"single_config_entry": true
}

View File

@ -75,6 +75,7 @@ class Telegrams:
)
)
self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size)
self.last_ga_telegrams: dict[str, TelegramDict] = {}
async def load_history(self) -> None:
"""Load history from store."""
@ -88,6 +89,9 @@ class Telegrams:
if isinstance(telegram["payload"], list):
telegram["payload"] = tuple(telegram["payload"]) # type: ignore[unreachable]
self.recent_telegrams.extend(telegrams)
self.last_ga_telegrams = {
t["destination"]: t for t in telegrams if t["payload"] is not None
}
async def save_history(self) -> None:
"""Save history to store."""
@ -98,6 +102,9 @@ class Telegrams:
"""Handle incoming and outgoing telegrams from xknx."""
telegram_dict = self.telegram_to_dict(telegram)
self.recent_telegrams.append(telegram_dict)
if telegram_dict["payload"] is not None:
# exclude GroupValueRead telegrams
self.last_ga_telegrams[telegram_dict["destination"]] = telegram_dict
async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM, telegram, telegram_dict)
def telegram_to_dict(self, telegram: Telegram) -> TelegramDict:

View File

@ -47,6 +47,7 @@ async def register_panel(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_project_file_process)
websocket_api.async_register_command(hass, ws_project_file_remove)
websocket_api.async_register_command(hass, ws_group_monitor_info)
websocket_api.async_register_command(hass, ws_group_telegrams)
websocket_api.async_register_command(hass, ws_subscribe_telegram)
websocket_api.async_register_command(hass, ws_get_knx_project)
websocket_api.async_register_command(hass, ws_validate_entity)
@ -287,6 +288,27 @@ def ws_group_monitor_info(
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "knx/group_telegrams",
}
)
@provide_knx
@callback
def ws_group_telegrams(
hass: HomeAssistant,
knx: KNXModule,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Handle get group telegrams command."""
connection.send_result(
msg["id"],
knx.telegrams.last_ga_telegrams,
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{

View File

@ -145,6 +145,7 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle user-confirmation of discovered node."""
if user_input is None:
assert self._name is not None
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"name": self._name},

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/kostal_plenticore",
"iot_class": "local_polling",
"loggers": ["kostal"],
"requirements": ["pykoplenti==1.2.2"]
"requirements": ["pykoplenti==1.3.0"]
}

View File

@ -20,7 +20,8 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from .const import (
ADD_ENTITIES_CALLBACKS,
@ -41,15 +42,26 @@ from .helpers import (
register_lcn_address_devices,
register_lcn_host_device,
)
from .services import SERVICES
from .services import register_services
from .websocket import register_panel_and_ws_api
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the LCN component."""
hass.data.setdefault(DOMAIN, {})
await register_services(hass)
await register_panel_and_ws_api(hass)
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up a connection to PCHK host from a config entry."""
hass.data.setdefault(DOMAIN, {})
if config_entry.entry_id in hass.data[DOMAIN]:
return False
@ -109,15 +121,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
)
lcn_connection.register_for_inputs(input_received)
# register service calls
for service_name, service in SERVICES:
if not hass.services.has_service(DOMAIN, service_name):
hass.services.async_register(
DOMAIN, service_name, service(hass).async_call_service, service.schema
)
await register_panel_and_ws_api(hass)
return True
@ -168,11 +171,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
host = hass.data[DOMAIN].pop(config_entry.entry_id)
await host[CONNECTION].async_close()
# unregister service calls
if unload_ok and not hass.data[DOMAIN]: # check if this is the last entry to unload
for service_name, _ in SERVICES:
hass.services.async_remove(DOMAIN, service_name)
return unload_ok

View File

@ -429,3 +429,11 @@ SERVICES = (
(LcnService.DYN_TEXT, DynText),
(LcnService.PCK, Pck),
)
async def register_services(hass: HomeAssistant) -> None:
"""Register services for LCN."""
for service_name, service in SERVICES:
hass.services.async_register(
DOMAIN, service_name, service(hass).async_call_service, service.schema
)

View File

@ -25,7 +25,8 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_host": "[%key:common::config_flow::error::invalid_host%]"
}
},
"device_automation": {

View File

@ -13,45 +13,68 @@ from .const import DATA_SESSION, DOMAIN
MANUFACTURER_ARTSOUND: Final[str] = "ArtSound"
MANUFACTURER_ARYLIC: Final[str] = "Arylic"
MANUFACTURER_IEAST: Final[str] = "iEAST"
MANUFACTURER_WIIM: Final[str] = "WiiM"
MANUFACTURER_GGMM: Final[str] = "GGMM"
MANUFACTURER_MEDION: Final[str] = "Medion"
MANUFACTURER_GENERIC: Final[str] = "Generic"
MODELS_ARTSOUND_SMART_ZONE4: Final[str] = "Smart Zone 4 AMP"
MODELS_ARTSOUND_SMART_HYDE: Final[str] = "Smart Hyde"
MODELS_ARYLIC_S50: Final[str] = "S50+"
MODELS_ARYLIC_S50_PRO: Final[str] = "S50 Pro"
MODELS_ARYLIC_A30: Final[str] = "A30"
MODELS_ARYLIC_A50: Final[str] = "A50"
MODELS_ARYLIC_A50S: Final[str] = "A50+"
MODELS_ARYLIC_UP2STREAM_AMP: Final[str] = "Up2Stream Amp 2.0"
MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3"
MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4"
MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1"
MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3"
MODELS_ARYLIC_UP2STREAM_PLATE_AMP: Final[str] = "Up2Stream Plate Amp"
MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5"
MODELS_WIIM_AMP: Final[str] = "WiiM Amp"
MODELS_WIIM_MINI: Final[str] = "WiiM Mini"
MODELS_GGMM_GGMM_E2: Final[str] = "GGMM E2"
MODELS_MEDION_MD_43970: Final[str] = "Life P66970 (MD 43970)"
MODELS_GENERIC: Final[str] = "Generic"
PROJECTID_LOOKUP: Final[dict[str, tuple[str, str]]] = {
"SMART_ZONE4_AMP": (MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_ZONE4),
"SMART_HYDE": (MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_HYDE),
"ARYLIC_S50": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50),
"RP0016_S50PRO_S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50_PRO),
"RP0011_WB60_S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A30),
"X-50": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50),
"ARYLIC_A50S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50S),
"RP0011_WB60": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP),
"UP2STREAM_AMP_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3),
"UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4),
"UP2STREAM_PRO_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3),
"ARYLIC_V20": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PLATE_AMP),
"UP2STREAM_MINI_V3": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"RP0014_A50C_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"ARYLIC_S50A": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"RP0010_D5_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"RP0001": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"RP0013_WA31S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"RP0010_D5": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"RP0013_WA31S_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"RP0014_A50D_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"ARYLIC_A50TE": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"ARYLIC_A50N": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"iEAST-02": (MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5),
"WiiM_Amp_4layer": (MANUFACTURER_WIIM, MODELS_WIIM_AMP),
"Muzo_Mini": (MANUFACTURER_WIIM, MODELS_WIIM_MINI),
"GGMM_E2A": (MANUFACTURER_GGMM, MODELS_GGMM_GGMM_E2),
"A16": (MANUFACTURER_MEDION, MODELS_MEDION_MD_43970),
}
def get_info_from_project(project: str) -> tuple[str, str]:
"""Get manufacturer and model info based on given project."""
match project:
case "SMART_ZONE4_AMP":
return MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_ZONE4
case "SMART_HYDE":
return MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_HYDE
case "ARYLIC_S50":
return MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50
case "RP0016_S50PRO_S":
return MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50_PRO
case "RP0011_WB60_S":
return MANUFACTURER_ARYLIC, MODELS_ARYLIC_A30
case "ARYLIC_A50S":
return MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50S
case "UP2STREAM_AMP_V3":
return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3
case "UP2STREAM_AMP_V4":
return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4
case "UP2STREAM_PRO_V3":
return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3
case "iEAST-02":
return MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5
case _:
return MANUFACTURER_GENERIC, MODELS_GENERIC
return PROJECTID_LOOKUP.get(project, (MANUFACTURER_GENERIC, MODELS_GENERIC))
async def async_get_client_session(hass: HomeAssistant) -> ClientSession:

View File

@ -97,7 +97,10 @@ class LookinFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"name": self._name, "host": self._host},
description_placeholders={
"name": self._name or "LOOKin",
"host": self._host,
},
)
return self.async_create_entry(

View File

@ -28,12 +28,12 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"set_up_new_device": "A new device was detected. Please set it up as a new entity instead of reconfiguring."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable.",
"set_up_new_device": "A new device was detected. Please set it up as a new entity instead of reconfiguring."
"no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable."
}
},
"entity": {

View File

@ -60,14 +60,14 @@ HEATER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key=CURRENT_POWER,
key="current_power",
translation_key="current_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=CONTROL_SIGNAL,
key="control_signal",
translation_key="control_signal",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
@ -143,6 +143,16 @@ LOCAL_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
),
)
SOCKET_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=HUMIDITY,
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
*HEATER_SENSOR_TYPES,
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback

View File

@ -24,7 +24,7 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN):
host: str | None = None
mac: str | None = None
name: str | None = None
name: str
async def async_step_user(
self, user_input: dict[str, Any] | None = None

View File

@ -48,11 +48,12 @@ CONFIG_SCHEMA = vol.Schema({vol.Required(CONF_MAC_CODE): str})
class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Motionblinds Bluetooth."""
_display_name: str
def __init__(self) -> None:
"""Initialize a ConfigFlow."""
self._discovery_info: BluetoothServiceInfoBleak | BLEDevice | None = None
self._mac_code: str | None = None
self._display_name: str | None = None
self._blind_type: MotionBlindType | None = None
async def async_step_bluetooth(
@ -67,8 +68,8 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
self._discovery_info = discovery_info
self._mac_code = get_mac_from_local_name(discovery_info.name)
self._display_name = display_name = DISPLAY_NAME.format(mac_code=self._mac_code)
self.context["title_placeholders"] = {"name": display_name}
self._display_name = DISPLAY_NAME.format(mac_code=self._mac_code)
self.context["title_placeholders"] = {"name": self._display_name}
return await self.async_step_confirm()
@ -113,7 +114,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
assert self._discovery_info is not None
return self.async_create_entry(
title=str(self._display_name),
title=self._display_name,
data={
CONF_ADDRESS: self._discovery_info.address,
CONF_LOCAL_NAME: self._discovery_info.name,

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