mirror of
https://github.com/home-assistant/core.git
synced 2025-07-28 23:57:06 +00:00
Merge branch 'dev' into mill
This commit is contained in:
commit
43eb8277df
2
.github/workflows/builder.yml
vendored
2
.github/workflows/builder.yml
vendored
@ -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"
|
||||
|
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
@ -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
|
||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.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"
|
||||
|
@ -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]
|
||||
|
@ -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
|
||||
|
10
build.yaml
10
build.yaml
@ -1,10 +1,10 @@
|
||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base: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
|
||||
|
@ -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),
|
||||
)
|
||||
|
@ -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,
|
||||
|
31
homeassistant/components/acaia/__init__.py
Normal file
31
homeassistant/components/acaia/__init__.py
Normal 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)
|
58
homeassistant/components/acaia/binary_sensor.py
Normal file
58
homeassistant/components/acaia/binary_sensor.py
Normal 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)
|
61
homeassistant/components/acaia/button.py
Normal file
61
homeassistant/components/acaia/button.py
Normal 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)
|
149
homeassistant/components/acaia/config_flow.py
Normal file
149
homeassistant/components/acaia/config_flow.py
Normal 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,
|
||||
)
|
4
homeassistant/components/acaia/const.py
Normal file
4
homeassistant/components/acaia/const.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Constants for component."""
|
||||
|
||||
DOMAIN = "acaia"
|
||||
CONF_IS_NEW_STYLE_SCALE = "is_new_style_scale"
|
86
homeassistant/components/acaia/coordinator.py
Normal file
86
homeassistant/components/acaia/coordinator.py
Normal 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",
|
||||
)
|
||||
)
|
40
homeassistant/components/acaia/entity.py
Normal file
40
homeassistant/components/acaia/entity.py
Normal 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
|
24
homeassistant/components/acaia/icons.json
Normal file
24
homeassistant/components/acaia/icons.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
29
homeassistant/components/acaia/manifest.json
Normal file
29
homeassistant/components/acaia/manifest.json
Normal 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"]
|
||||
}
|
135
homeassistant/components/acaia/sensor.py
Normal file
135
homeassistant/components/acaia/sensor.py
Normal 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
|
43
homeassistant/components/acaia/strings.json
Normal file
43
homeassistant/components/acaia/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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],
|
||||
|
@ -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",
|
||||
|
@ -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."]
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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"]))
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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"],
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -21,6 +21,8 @@ from .models import Eq3Config, Eq3ConfigEntryData
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
@ -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
|
||||
|
@ -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": {
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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)
|
||||
|
158
homeassistant/components/eq3btsmart/number.py
Normal file
158
homeassistant/components/eq3btsmart/number.py
Normal 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
|
||||
)
|
84
homeassistant/components/eq3btsmart/sensor.py
Normal file
84
homeassistant/components/eq3btsmart/sensor.py
Normal 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)
|
@ -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"
|
||||
|
@ -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:")
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
@ -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.",
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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": {
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -187,6 +187,9 @@
|
||||
},
|
||||
"score_reward": {
|
||||
"service": "mdi:sack"
|
||||
},
|
||||
"transformation": {
|
||||
"service": "mdi:flask-round-bottom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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."""
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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:
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
20
homeassistant/components/home_connect/diagnostics.py
Normal file
20
homeassistant/components/home_connect/diagnostics.py
Normal 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
|
||||
}
|
@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homematicip"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["homematicip==1.1.2"]
|
||||
"requirements": ["homematicip==1.1.3"]
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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(
|
||||
{
|
||||
|
@ -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},
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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": {
|
||||
|
@ -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:
|
||||
|
@ -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(
|
||||
|
@ -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": {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user