mirror of
https://github.com/home-assistant/core.git
synced 2025-12-18 22:08:14 +00:00
Compare commits
60 Commits
schedule/a
...
knx-ui-sen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f15f5b4ec | ||
|
|
5628a13e7d | ||
|
|
dce0ff0973 | ||
|
|
09c9a51dcb | ||
|
|
d661aa129a | ||
|
|
27570233f9 | ||
|
|
2632b6682b | ||
|
|
69a3dd8246 | ||
|
|
8a2dde73dd | ||
|
|
81d0c4638d | ||
|
|
3c865c6f41 | ||
|
|
3b32c4bcbf | ||
|
|
fcdc1cfed9 | ||
|
|
0fd782c4ab | ||
|
|
bbcaf69973 | ||
|
|
f2b713acac | ||
|
|
6c944d6b15 | ||
|
|
4dd3abb16a | ||
|
|
d2672b9ddf | ||
|
|
ff30492919 | ||
|
|
b5ccdf8165 | ||
|
|
b3c745cfa7 | ||
|
|
67aeafa797 | ||
|
|
3d71b6de44 | ||
|
|
5349045932 | ||
|
|
4960871c84 | ||
|
|
af3861cd6b | ||
|
|
f9a070e9b3 | ||
|
|
fd503b2e33 | ||
|
|
e5a73fcf57 | ||
|
|
6991e01489 | ||
|
|
c8636ee6f3 | ||
|
|
52229dc5a8 | ||
|
|
f013455843 | ||
|
|
cae5bca546 | ||
|
|
49299b06c6 | ||
|
|
8e39027ad5 | ||
|
|
2a1ce2df61 | ||
|
|
7a6d929150 | ||
|
|
6f4a112dbb | ||
|
|
2197b910fb | ||
|
|
7e2a9cd7f9 | ||
|
|
e7ed7a8ed2 | ||
|
|
9ba2d0defe | ||
|
|
231300919c | ||
|
|
664c50586f | ||
|
|
43b9ecfc2b | ||
|
|
f1237ed52a | ||
|
|
ecf8f55cc4 | ||
|
|
ff36693057 | ||
|
|
005785997c | ||
|
|
9917b82b66 | ||
|
|
9c927406ac | ||
|
|
972d95602a | ||
|
|
5e0549a18f | ||
|
|
bcbb159fb2 | ||
|
|
0123ca656a | ||
|
|
1f699c729c | ||
|
|
50c3fcfeba | ||
|
|
2af1e098cc |
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
@@ -51,6 +51,9 @@ rules:
|
||||
- **Missing imports** - We use static analysis tooling to catch that
|
||||
- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions)
|
||||
|
||||
**Git commit practices during review:**
|
||||
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
|
||||
|
||||
## Python Requirements
|
||||
|
||||
- **Compatibility**: Python 3.13+
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
|
||||
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
|
||||
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
2
Dockerfile
generated
2
Dockerfile
generated
@@ -24,7 +24,7 @@ ENV \
|
||||
COPY rootfs /
|
||||
|
||||
# Add go2rtc binary
|
||||
COPY --from=ghcr.io/alexxit/go2rtc@sha256:baef0aa19d759fcfd31607b34ce8eaf039d496282bba57731e6ae326896d7640 /usr/local/bin/go2rtc /bin/go2rtc
|
||||
COPY --from=ghcr.io/alexxit/go2rtc@sha256:f394f6329f5389a4c9a7fc54b09fdec9621bbb78bf7a672b973440bbdfb02241 /usr/local/bin/go2rtc /bin/go2rtc
|
||||
|
||||
RUN \
|
||||
# Verify go2rtc can be executed
|
||||
|
||||
@@ -133,7 +133,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"media_player",
|
||||
"schedule",
|
||||
"switch",
|
||||
"text",
|
||||
"update",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"domain": "blackbird",
|
||||
"name": "Monoprice Blackbird Matrix Switch",
|
||||
"codeowners": [],
|
||||
"disabled": "This integration is disabled because it references pyserial-asyncio, which does blocking I/O in the asyncio loop and is not maintained.",
|
||||
"documentation": "https://www.home-assistant.io/integrations/blackbird",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyblackbird"],
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==43.0.0",
|
||||
"aioesphomeapi==43.3.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
||||
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError
|
||||
from pyfritzhome.devicetypes import FritzhomeTemplate
|
||||
from pyfritzhome.devicetypes import FritzhomeTemplate, FritzhomeTrigger
|
||||
from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -27,6 +27,7 @@ class FritzboxCoordinatorData:
|
||||
|
||||
devices: dict[str, FritzhomeDevice]
|
||||
templates: dict[str, FritzhomeTemplate]
|
||||
triggers: dict[str, FritzhomeTrigger]
|
||||
supported_color_properties: dict[str, tuple[dict, list]]
|
||||
|
||||
|
||||
@@ -37,6 +38,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
configuration_url: str
|
||||
fritz: Fritzhome
|
||||
has_templates: bool
|
||||
has_triggers: bool
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: FritzboxConfigEntry) -> None:
|
||||
"""Initialize the Fritzbox Smarthome device coordinator."""
|
||||
@@ -50,8 +52,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
|
||||
self.new_devices: set[str] = set()
|
||||
self.new_templates: set[str] = set()
|
||||
self.new_triggers: set[str] = set()
|
||||
|
||||
self.data = FritzboxCoordinatorData({}, {}, {})
|
||||
self.data = FritzboxCoordinatorData({}, {}, {}, {})
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
@@ -74,6 +77,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
)
|
||||
LOGGER.debug("enable smarthome templates: %s", self.has_templates)
|
||||
|
||||
self.has_triggers = await self.hass.async_add_executor_job(
|
||||
self.fritz.has_triggers
|
||||
)
|
||||
LOGGER.debug("enable smarthome triggers: %s", self.has_triggers)
|
||||
|
||||
self.configuration_url = self.fritz.get_prefixed_host()
|
||||
|
||||
await self.async_config_entry_first_refresh()
|
||||
@@ -92,7 +100,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
|
||||
available_main_ains = [
|
||||
ain
|
||||
for ain, dev in data.devices.items() | data.templates.items()
|
||||
for ain, dev in (data.devices | data.templates | data.triggers).items()
|
||||
if dev.device_and_unit_id[1] is None
|
||||
]
|
||||
device_reg = dr.async_get(self.hass)
|
||||
@@ -112,6 +120,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
self.fritz.update_devices(ignore_removed=False)
|
||||
if self.has_templates:
|
||||
self.fritz.update_templates(ignore_removed=False)
|
||||
if self.has_triggers:
|
||||
self.fritz.update_triggers(ignore_removed=False)
|
||||
|
||||
except RequestConnectionError as ex:
|
||||
raise UpdateFailed from ex
|
||||
except HTTPError:
|
||||
@@ -123,6 +134,8 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
self.fritz.update_devices(ignore_removed=False)
|
||||
if self.has_templates:
|
||||
self.fritz.update_templates(ignore_removed=False)
|
||||
if self.has_triggers:
|
||||
self.fritz.update_triggers(ignore_removed=False)
|
||||
|
||||
devices = self.fritz.get_devices()
|
||||
device_data = {}
|
||||
@@ -156,12 +169,20 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
for template in templates:
|
||||
template_data[template.ain] = template
|
||||
|
||||
trigger_data = {}
|
||||
if self.has_triggers:
|
||||
triggers = self.fritz.get_triggers()
|
||||
for trigger in triggers:
|
||||
trigger_data[trigger.ain] = trigger
|
||||
|
||||
self.new_devices = device_data.keys() - self.data.devices.keys()
|
||||
self.new_templates = template_data.keys() - self.data.templates.keys()
|
||||
self.new_triggers = trigger_data.keys() - self.data.triggers.keys()
|
||||
|
||||
return FritzboxCoordinatorData(
|
||||
devices=device_data,
|
||||
templates=template_data,
|
||||
triggers=trigger_data,
|
||||
supported_color_properties=supported_color_properties,
|
||||
)
|
||||
|
||||
@@ -193,6 +214,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
if (
|
||||
self.data.devices.keys() - new_data.devices.keys()
|
||||
or self.data.templates.keys() - new_data.templates.keys()
|
||||
or self.data.triggers.keys() - new_data.triggers.keys()
|
||||
):
|
||||
self.cleanup_removed_devices(new_data)
|
||||
|
||||
|
||||
@@ -4,14 +4,17 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyfritzhome.devicetypes import FritzhomeTrigger
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FritzboxConfigEntry
|
||||
from .entity import FritzBoxDeviceEntity
|
||||
from .entity import FritzBoxDeviceEntity, FritzBoxEntity
|
||||
|
||||
# Coordinator handles data updates, so we can allow unlimited parallel updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -26,21 +29,27 @@ async def async_setup_entry(
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
@callback
|
||||
def _add_entities(devices: set[str] | None = None) -> None:
|
||||
"""Add devices."""
|
||||
def _add_entities(
|
||||
devices: set[str] | None = None, triggers: set[str] | None = None
|
||||
) -> None:
|
||||
"""Add devices and triggers."""
|
||||
if devices is None:
|
||||
devices = coordinator.new_devices
|
||||
if not devices:
|
||||
if triggers is None:
|
||||
triggers = coordinator.new_triggers
|
||||
if not devices and not triggers:
|
||||
return
|
||||
async_add_entities(
|
||||
entities = [
|
||||
FritzboxSwitch(coordinator, ain)
|
||||
for ain in devices
|
||||
if coordinator.data.devices[ain].has_switch
|
||||
)
|
||||
] + [FritzboxTrigger(coordinator, ain) for ain in triggers]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_add_entities))
|
||||
|
||||
_add_entities(set(coordinator.data.devices))
|
||||
_add_entities(set(coordinator.data.devices), set(coordinator.data.triggers))
|
||||
|
||||
|
||||
class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
|
||||
@@ -70,3 +79,42 @@ class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="manual_switching_disabled",
|
||||
)
|
||||
|
||||
|
||||
class FritzboxTrigger(FritzBoxEntity, SwitchEntity):
|
||||
"""The switch class for FRITZ!SmartHome triggers."""
|
||||
|
||||
@property
|
||||
def data(self) -> FritzhomeTrigger:
|
||||
"""Return the trigger data entity."""
|
||||
return self.coordinator.data.triggers[self.ain]
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device specific attributes."""
|
||||
return DeviceInfo(
|
||||
name=self.data.name,
|
||||
identifiers={(DOMAIN, self.ain)},
|
||||
configuration_url=self.coordinator.configuration_url,
|
||||
manufacturer="FRITZ!",
|
||||
model="SmartHome Routine",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the trigger is active."""
|
||||
return self.data.active # type: ignore [no-any-return]
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Activate the trigger."""
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.fritz.set_trigger_active, self.ain
|
||||
)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Deactivate the trigger."""
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.fritz.set_trigger_inactive, self.ain
|
||||
)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@@ -2,15 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.stream import (
|
||||
CONF_RTSP_TRANSPORT,
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.const import CONF_AUTHENTICATION, CONF_VERIFY_SSL, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import CONF_FRAMERATE, CONF_LIMIT_REFETCH_TO_URL_CHANGE, SECTION_ADVANCED
|
||||
|
||||
DOMAIN = "generic"
|
||||
PLATFORMS = [Platform.CAMERA]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
@@ -47,3 +55,38 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate entry."""
|
||||
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 2:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 1:
|
||||
# Migrate to advanced section
|
||||
new_options = {**entry.options}
|
||||
advanced = new_options[SECTION_ADVANCED] = {
|
||||
CONF_FRAMERATE: new_options.pop(CONF_FRAMERATE),
|
||||
CONF_VERIFY_SSL: new_options.pop(CONF_VERIFY_SSL),
|
||||
}
|
||||
|
||||
# migrate optional fields
|
||||
for key in (
|
||||
CONF_RTSP_TRANSPORT,
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
|
||||
CONF_AUTHENTICATION,
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
|
||||
):
|
||||
if key in new_options:
|
||||
advanced[key] = new_options.pop(key)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, options=new_options, version=2)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -41,6 +41,7 @@ from .const import (
|
||||
CONF_STILL_IMAGE_URL,
|
||||
CONF_STREAM_SOURCE,
|
||||
GET_IMAGE_TIMEOUT,
|
||||
SECTION_ADVANCED,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -62,9 +63,11 @@ def generate_auth(device_info: Mapping[str, Any]) -> httpx.Auth | None:
|
||||
"""Generate httpx.Auth object from credentials."""
|
||||
username: str | None = device_info.get(CONF_USERNAME)
|
||||
password: str | None = device_info.get(CONF_PASSWORD)
|
||||
authentication = device_info.get(CONF_AUTHENTICATION)
|
||||
if username and password:
|
||||
if authentication == HTTP_DIGEST_AUTHENTICATION:
|
||||
if (
|
||||
device_info[SECTION_ADVANCED].get(CONF_AUTHENTICATION)
|
||||
== HTTP_DIGEST_AUTHENTICATION
|
||||
):
|
||||
return httpx.DigestAuth(username=username, password=password)
|
||||
return httpx.BasicAuth(username=username, password=password)
|
||||
return None
|
||||
@@ -99,14 +102,16 @@ class GenericCamera(Camera):
|
||||
if self._stream_source:
|
||||
self._stream_source = Template(self._stream_source, hass)
|
||||
self._attr_supported_features = CameraEntityFeature.STREAM
|
||||
self._limit_refetch = device_info.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False)
|
||||
self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE]
|
||||
self._limit_refetch = device_info[SECTION_ADVANCED].get(
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE, False
|
||||
)
|
||||
self._attr_frame_interval = 1 / device_info[SECTION_ADVANCED][CONF_FRAMERATE]
|
||||
self.content_type = device_info[CONF_CONTENT_TYPE]
|
||||
self.verify_ssl = device_info[CONF_VERIFY_SSL]
|
||||
if device_info.get(CONF_RTSP_TRANSPORT):
|
||||
self.stream_options[CONF_RTSP_TRANSPORT] = device_info[CONF_RTSP_TRANSPORT]
|
||||
self.verify_ssl = device_info[SECTION_ADVANCED][CONF_VERIFY_SSL]
|
||||
if rtsp_transport := device_info[SECTION_ADVANCED].get(CONF_RTSP_TRANSPORT):
|
||||
self.stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
|
||||
self._auth = generate_auth(device_info)
|
||||
if device_info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
|
||||
if device_info[SECTION_ADVANCED].get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
|
||||
self.stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True
|
||||
|
||||
self._last_url = None
|
||||
|
||||
@@ -50,10 +50,17 @@ from homeassistant.const import (
|
||||
HTTP_DIGEST_AUTHENTICATION,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
from homeassistant.helpers import config_validation as cv, template as template_helper
|
||||
from homeassistant.helpers.entity_platform import PlatformData
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .camera import GenericCamera, generate_auth
|
||||
@@ -67,17 +74,20 @@ from .const import (
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
GET_IMAGE_TIMEOUT,
|
||||
SECTION_ADVANCED,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DATA = {
|
||||
CONF_NAME: DEFAULT_NAME,
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
||||
CONF_FRAMERATE: 2,
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_RTSP_TRANSPORT: "tcp",
|
||||
SECTION_ADVANCED: {
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
||||
CONF_FRAMERATE: 2,
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_RTSP_TRANSPORT: "tcp",
|
||||
},
|
||||
}
|
||||
|
||||
SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"}
|
||||
@@ -94,58 +104,47 @@ class InvalidStreamException(HomeAssistantError):
|
||||
|
||||
|
||||
def build_schema(
|
||||
user_input: Mapping[str, Any],
|
||||
is_options_flow: bool = False,
|
||||
show_advanced_options: bool = False,
|
||||
) -> vol.Schema:
|
||||
"""Create schema for camera config setup."""
|
||||
rtsp_options = [
|
||||
SelectOptionDict(
|
||||
value=value,
|
||||
label=name,
|
||||
)
|
||||
for value, name in RTSP_TRANSPORTS.items()
|
||||
]
|
||||
|
||||
advanced_section = {
|
||||
vol.Required(CONF_FRAMERATE): vol.All(
|
||||
vol.Range(min=0, min_included=False), cv.positive_float
|
||||
),
|
||||
vol.Required(CONF_VERIFY_SSL): bool,
|
||||
vol.Optional(CONF_RTSP_TRANSPORT): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=rtsp_options,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_AUTHENTICATION): vol.In(
|
||||
[HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]
|
||||
),
|
||||
}
|
||||
spec = {
|
||||
vol.Optional(
|
||||
CONF_STILL_IMAGE_URL,
|
||||
description={"suggested_value": user_input.get(CONF_STILL_IMAGE_URL, "")},
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_STREAM_SOURCE,
|
||||
description={"suggested_value": user_input.get(CONF_STREAM_SOURCE, "")},
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_RTSP_TRANSPORT,
|
||||
description={"suggested_value": user_input.get(CONF_RTSP_TRANSPORT)},
|
||||
): vol.In(RTSP_TRANSPORTS),
|
||||
vol.Optional(
|
||||
CONF_AUTHENTICATION,
|
||||
description={"suggested_value": user_input.get(CONF_AUTHENTICATION)},
|
||||
): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
description={"suggested_value": user_input.get(CONF_USERNAME, "")},
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
description={"suggested_value": user_input.get(CONF_PASSWORD, "")},
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_FRAMERATE,
|
||||
description={"suggested_value": user_input.get(CONF_FRAMERATE, 2)},
|
||||
): vol.All(vol.Range(min=0, min_included=False), cv.positive_float),
|
||||
vol.Required(
|
||||
CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL, True)
|
||||
): bool,
|
||||
vol.Optional(CONF_STREAM_SOURCE): str,
|
||||
vol.Optional(CONF_STILL_IMAGE_URL): str,
|
||||
vol.Optional(CONF_USERNAME): str,
|
||||
vol.Optional(CONF_PASSWORD): str,
|
||||
vol.Required(SECTION_ADVANCED): section(
|
||||
vol.Schema(advanced_section), {"collapsed": True}
|
||||
),
|
||||
}
|
||||
if is_options_flow:
|
||||
spec[
|
||||
vol.Required(
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
|
||||
default=user_input.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False),
|
||||
)
|
||||
] = bool
|
||||
advanced_section[vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE)] = bool
|
||||
if show_advanced_options:
|
||||
spec[
|
||||
vol.Required(
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
|
||||
default=user_input.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False),
|
||||
)
|
||||
] = bool
|
||||
advanced_section[vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS)] = bool
|
||||
|
||||
return vol.Schema(spec)
|
||||
|
||||
|
||||
@@ -187,7 +186,7 @@ async def async_test_still(
|
||||
return {CONF_STILL_IMAGE_URL: "malformed_url"}, None
|
||||
if not yarl_url.is_absolute():
|
||||
return {CONF_STILL_IMAGE_URL: "relative_url"}, None
|
||||
verify_ssl = info[CONF_VERIFY_SSL]
|
||||
verify_ssl = info[SECTION_ADVANCED][CONF_VERIFY_SSL]
|
||||
auth = generate_auth(info)
|
||||
try:
|
||||
async_client = get_async_client(hass, verify_ssl=verify_ssl)
|
||||
@@ -268,9 +267,9 @@ async def async_test_and_preview_stream(
|
||||
_LOGGER.warning("Problem rendering template %s: %s", stream_source, err)
|
||||
raise InvalidStreamException("template_error") from err
|
||||
stream_options: dict[str, str | bool | float] = {}
|
||||
if rtsp_transport := info.get(CONF_RTSP_TRANSPORT):
|
||||
if rtsp_transport := info[SECTION_ADVANCED].get(CONF_RTSP_TRANSPORT):
|
||||
stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
|
||||
if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
|
||||
if info[SECTION_ADVANCED].get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
|
||||
stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True
|
||||
|
||||
try:
|
||||
@@ -326,7 +325,7 @@ def register_still_preview(hass: HomeAssistant) -> None:
|
||||
class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for generic IP camera."""
|
||||
|
||||
VERSION = 1
|
||||
VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize Generic ConfigFlow."""
|
||||
@@ -381,7 +380,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
user_input = DEFAULT_DATA.copy()
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=build_schema(user_input),
|
||||
data_schema=self.add_suggested_values_to_schema(build_schema(), user_input),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -449,13 +448,19 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
||||
self.preview_stream = None
|
||||
if not errors:
|
||||
data = {
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
|
||||
),
|
||||
**user_input,
|
||||
CONF_CONTENT_TYPE: still_format
|
||||
or self.config_entry.options.get(CONF_CONTENT_TYPE),
|
||||
}
|
||||
if (
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS
|
||||
not in user_input[SECTION_ADVANCED]
|
||||
):
|
||||
data[SECTION_ADVANCED][CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = (
|
||||
self.config_entry.options[SECTION_ADVANCED].get(
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
|
||||
)
|
||||
)
|
||||
self.user_input = data
|
||||
# temporary preview for user to check the image
|
||||
self.preview_image_settings = data
|
||||
@@ -464,10 +469,12 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
||||
user_input = self.user_input
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=build_schema(
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
build_schema(
|
||||
True,
|
||||
self.show_advanced_options,
|
||||
),
|
||||
user_input or self.config_entry.options,
|
||||
True,
|
||||
self.show_advanced_options,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -9,3 +9,4 @@ CONF_STILL_IMAGE_URL = "still_image_url"
|
||||
CONF_STREAM_SOURCE = "stream_source"
|
||||
CONF_FRAMERATE = "framerate"
|
||||
GET_IMAGE_TIMEOUT = 10
|
||||
SECTION_ADVANCED = "advanced"
|
||||
|
||||
@@ -26,17 +26,24 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"authentication": "Authentication",
|
||||
"framerate": "Frame rate (Hz)",
|
||||
"limit_refetch_to_url_change": "Limit refetch to URL change",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"rtsp_transport": "RTSP transport protocol",
|
||||
"still_image_url": "Still image URL (e.g. http://...)",
|
||||
"stream_source": "Stream source URL (e.g. rtsp://...)",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Enter the settings to connect to the camera."
|
||||
"sections": {
|
||||
"advanced": {
|
||||
"data": {
|
||||
"authentication": "Authentication",
|
||||
"framerate": "Frame rate (Hz)",
|
||||
"limit_refetch_to_url_change": "Limit refetch to URL change",
|
||||
"rtsp_transport": "RTSP transport protocol",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"description": "Advanced settings are only needed for special cases. Leave them unchanged unless you know what you are doing.",
|
||||
"name": "Advanced settings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"user_confirm": {
|
||||
"data": {
|
||||
@@ -70,19 +77,27 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"authentication": "[%key:component::generic::config::step::user::data::authentication%]",
|
||||
"framerate": "[%key:component::generic::config::step::user::data::framerate%]",
|
||||
"limit_refetch_to_url_change": "[%key:component::generic::config::step::user::data::limit_refetch_to_url_change%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"rtsp_transport": "[%key:component::generic::config::step::user::data::rtsp_transport%]",
|
||||
"still_image_url": "[%key:component::generic::config::step::user::data::still_image_url%]",
|
||||
"stream_source": "[%key:component::generic::config::step::user::data::stream_source%]",
|
||||
"use_wallclock_as_timestamps": "Use wallclock as timestamps",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
|
||||
"sections": {
|
||||
"advanced": {
|
||||
"data": {
|
||||
"authentication": "[%key:component::generic::config::step::user::sections::advanced::data::authentication%]",
|
||||
"framerate": "[%key:component::generic::config::step::user::sections::advanced::data::framerate%]",
|
||||
"limit_refetch_to_url_change": "[%key:component::generic::config::step::user::sections::advanced::data::limit_refetch_to_url_change%]",
|
||||
"rtsp_transport": "[%key:component::generic::config::step::user::sections::advanced::data::rtsp_transport%]",
|
||||
"use_wallclock_as_timestamps": "Use wallclock as timestamps",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
|
||||
},
|
||||
"description": "[%key:component::generic::config::step::user::sections::advanced::description%]",
|
||||
"name": "[%key:component::generic::config::step::user::sections::advanced::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"user_confirm": {
|
||||
|
||||
@@ -8,4 +8,4 @@ HA_MANAGED_API_PORT = 11984
|
||||
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
||||
# When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA)
|
||||
# in script/hassfest/docker.py.
|
||||
RECOMMENDED_VERSION = "1.9.12"
|
||||
RECOMMENDED_VERSION = "1.9.13"
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["homewizard_energy"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-homewizard-energy==9.3.0"],
|
||||
"requirements": ["python-homewizard-energy==10.0.0"],
|
||||
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homewizard_energy import HomeWizardEnergy
|
||||
from homewizard_energy.models import Batteries, CombinedModels as DeviceResponseEntry
|
||||
from homewizard_energy.models import Batteries
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
@@ -21,69 +16,59 @@ from .helpers import homewizard_exception_handler
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeWizardSelectEntityDescription(SelectEntityDescription):
|
||||
"""Class describing HomeWizard select entities."""
|
||||
|
||||
available_fn: Callable[[DeviceResponseEntry], bool]
|
||||
create_fn: Callable[[DeviceResponseEntry], bool]
|
||||
current_fn: Callable[[DeviceResponseEntry], str | None]
|
||||
set_fn: Callable[[HomeWizardEnergy, str], Awaitable[Any]]
|
||||
|
||||
|
||||
DESCRIPTIONS = [
|
||||
HomeWizardSelectEntityDescription(
|
||||
key="battery_group_mode",
|
||||
translation_key="battery_group_mode",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
options=[Batteries.Mode.ZERO, Batteries.Mode.STANDBY, Batteries.Mode.TO_FULL],
|
||||
available_fn=lambda x: x.batteries is not None,
|
||||
create_fn=lambda x: x.batteries is not None,
|
||||
current_fn=lambda x: x.batteries.mode if x.batteries else None,
|
||||
set_fn=lambda api, mode: api.batteries(mode=Batteries.Mode(mode)),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeWizardConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up HomeWizard select based on a config entry."""
|
||||
async_add_entities(
|
||||
HomeWizardSelectEntity(
|
||||
coordinator=entry.runtime_data,
|
||||
description=description,
|
||||
if entry.runtime_data.data.device.supports_batteries():
|
||||
async_add_entities(
|
||||
[
|
||||
HomeWizardBatteryModeSelectEntity(
|
||||
coordinator=entry.runtime_data,
|
||||
)
|
||||
]
|
||||
)
|
||||
for description in DESCRIPTIONS
|
||||
if description.create_fn(entry.runtime_data.data)
|
||||
)
|
||||
|
||||
|
||||
class HomeWizardSelectEntity(HomeWizardEntity, SelectEntity):
|
||||
class HomeWizardBatteryModeSelectEntity(HomeWizardEntity, SelectEntity):
|
||||
"""Defines a HomeWizard select entity."""
|
||||
|
||||
entity_description: HomeWizardSelectEntityDescription
|
||||
entity_description: SelectEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HWEnergyDeviceUpdateCoordinator,
|
||||
description: HomeWizardSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
description = SelectEntityDescription(
|
||||
key="battery_group_mode",
|
||||
translation_key="battery_group_mode",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
options=[
|
||||
str(mode)
|
||||
for mode in (coordinator.data.device.supported_battery_modes() or [])
|
||||
],
|
||||
)
|
||||
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the selected entity option to represent the entity state."""
|
||||
return self.entity_description.current_fn(self.coordinator.data)
|
||||
return (
|
||||
self.coordinator.data.batteries.mode
|
||||
if self.coordinator.data.batteries and self.coordinator.data.batteries.mode
|
||||
else None
|
||||
)
|
||||
|
||||
@homewizard_exception_handler
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
await self.entity_description.set_fn(self.coordinator.api, option)
|
||||
await self.coordinator.api.batteries(Batteries.Mode(option))
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -65,7 +65,9 @@
|
||||
"state": {
|
||||
"standby": "Standby",
|
||||
"to_full": "Manual charge mode",
|
||||
"zero": "Zero mode"
|
||||
"zero": "Zero mode",
|
||||
"zero_charge_only": "Zero mode (charge only)",
|
||||
"zero_discharge_only": "Zero mode (discharge only)"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@dgomes"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/kmtronic",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pykmtronic"],
|
||||
"requirements": ["pykmtronic==0.3.0"]
|
||||
|
||||
@@ -163,8 +163,10 @@ SUPPORTED_PLATFORMS_UI: Final = {
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.DATE,
|
||||
Platform.FAN,
|
||||
Platform.DATETIME,
|
||||
Platform.LIGHT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.TIME,
|
||||
}
|
||||
@@ -217,3 +219,9 @@ class ClimateConf:
|
||||
FAN_MAX_STEP: Final = "fan_max_step"
|
||||
FAN_SPEED_MODE: Final = "fan_speed_mode"
|
||||
FAN_ZERO_MODE: Final = "fan_zero_mode"
|
||||
|
||||
|
||||
class FanConf:
|
||||
"""Common config keys for fan."""
|
||||
|
||||
MAX_STEP: Final = "max_step"
|
||||
|
||||
146
homeassistant/components/knx/dpt.py
Normal file
146
homeassistant/components/knx/dpt.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""KNX DPT serializer."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from functools import cache
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from xknx.dpt import DPTBase, DPTComplex, DPTEnum, DPTNumeric
|
||||
from xknx.dpt.dpt_16 import DPTString
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
|
||||
HaDptClass = Literal["numeric", "enum", "complex", "string"]
|
||||
|
||||
|
||||
class DPTInfo(TypedDict):
|
||||
"""DPT information."""
|
||||
|
||||
dpt_class: HaDptClass
|
||||
main: int
|
||||
sub: int | None
|
||||
name: str | None
|
||||
unit: str | None
|
||||
sensor_device_class: SensorDeviceClass | None
|
||||
sensor_state_class: SensorStateClass | None
|
||||
|
||||
|
||||
@cache
|
||||
def get_supported_dpts() -> Mapping[str, DPTInfo]:
|
||||
"""Return a mapping of supported DPTs with HA specific attributes."""
|
||||
dpts = {}
|
||||
for dpt_class in DPTBase.dpt_class_tree():
|
||||
dpt_number_str = dpt_class.dpt_number_str()
|
||||
ha_dpt_class = _ha_dpt_class(dpt_class)
|
||||
dpts[dpt_number_str] = DPTInfo(
|
||||
dpt_class=ha_dpt_class,
|
||||
main=dpt_class.dpt_main_number, # type: ignore[typeddict-item] # checked in xknx unit tests
|
||||
sub=dpt_class.dpt_sub_number,
|
||||
name=dpt_class.value_type,
|
||||
unit=dpt_class.unit,
|
||||
sensor_device_class=_sensor_device_classes.get(dpt_number_str),
|
||||
sensor_state_class=_get_sensor_state_class(ha_dpt_class, dpt_number_str),
|
||||
)
|
||||
return dpts
|
||||
|
||||
|
||||
def _ha_dpt_class(dpt_cls: type[DPTBase]) -> HaDptClass:
|
||||
"""Return the DPT class identifier string."""
|
||||
if issubclass(dpt_cls, DPTNumeric):
|
||||
return "numeric"
|
||||
if issubclass(dpt_cls, DPTEnum):
|
||||
return "enum"
|
||||
if issubclass(dpt_cls, DPTComplex):
|
||||
return "complex"
|
||||
if issubclass(dpt_cls, DPTString):
|
||||
return "string"
|
||||
raise ValueError("Unsupported DPT class")
|
||||
|
||||
|
||||
_sensor_device_classes: Mapping[str, SensorDeviceClass] = {
|
||||
"7.011": SensorDeviceClass.DISTANCE,
|
||||
"7.012": SensorDeviceClass.CURRENT,
|
||||
"7.013": SensorDeviceClass.ILLUMINANCE,
|
||||
"8.012": SensorDeviceClass.DISTANCE,
|
||||
"9.001": SensorDeviceClass.TEMPERATURE,
|
||||
"9.002": SensorDeviceClass.TEMPERATURE_DELTA,
|
||||
"9.004": SensorDeviceClass.ILLUMINANCE,
|
||||
"9.005": SensorDeviceClass.WIND_SPEED,
|
||||
"9.006": SensorDeviceClass.PRESSURE,
|
||||
"9.007": SensorDeviceClass.HUMIDITY,
|
||||
"9.020": SensorDeviceClass.VOLTAGE,
|
||||
"9.021": SensorDeviceClass.CURRENT,
|
||||
"9.024": SensorDeviceClass.POWER,
|
||||
"9.025": SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
"9.027": SensorDeviceClass.TEMPERATURE,
|
||||
"9.028": SensorDeviceClass.WIND_SPEED,
|
||||
"9.029": SensorDeviceClass.ABSOLUTE_HUMIDITY,
|
||||
"12.1200": SensorDeviceClass.VOLUME,
|
||||
"12.1201": SensorDeviceClass.VOLUME,
|
||||
"13.002": SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
"13.010": SensorDeviceClass.ENERGY,
|
||||
"13.012": SensorDeviceClass.REACTIVE_ENERGY,
|
||||
"13.013": SensorDeviceClass.ENERGY,
|
||||
"13.015": SensorDeviceClass.REACTIVE_ENERGY,
|
||||
"13.016": SensorDeviceClass.ENERGY,
|
||||
"13.1200": SensorDeviceClass.VOLUME,
|
||||
"13.1201": SensorDeviceClass.VOLUME,
|
||||
"14.010": SensorDeviceClass.AREA,
|
||||
"14.019": SensorDeviceClass.CURRENT,
|
||||
"14.027": SensorDeviceClass.VOLTAGE,
|
||||
"14.028": SensorDeviceClass.VOLTAGE,
|
||||
"14.030": SensorDeviceClass.VOLTAGE,
|
||||
"14.031": SensorDeviceClass.ENERGY,
|
||||
"14.033": SensorDeviceClass.FREQUENCY,
|
||||
"14.037": SensorDeviceClass.ENERGY_STORAGE,
|
||||
"14.039": SensorDeviceClass.DISTANCE,
|
||||
"14.051": SensorDeviceClass.WEIGHT,
|
||||
"14.056": SensorDeviceClass.POWER,
|
||||
"14.057": SensorDeviceClass.POWER_FACTOR,
|
||||
"14.058": SensorDeviceClass.PRESSURE,
|
||||
"14.065": SensorDeviceClass.SPEED,
|
||||
"14.068": SensorDeviceClass.TEMPERATURE,
|
||||
"14.069": SensorDeviceClass.TEMPERATURE,
|
||||
"14.070": SensorDeviceClass.TEMPERATURE_DELTA,
|
||||
"14.076": SensorDeviceClass.VOLUME,
|
||||
"14.077": SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
"14.080": SensorDeviceClass.APPARENT_POWER,
|
||||
"14.1200": SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
"14.1201": SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
"29.010": SensorDeviceClass.ENERGY,
|
||||
"29.012": SensorDeviceClass.REACTIVE_ENERGY,
|
||||
}
|
||||
|
||||
_sensor_state_class_overrides: Mapping[str, SensorStateClass | None] = {
|
||||
"5.003": SensorStateClass.MEASUREMENT_ANGLE, # DPTAngle
|
||||
"5.006": None, # DPTTariff
|
||||
"7.010": None, # DPTPropDataType
|
||||
"8.011": SensorStateClass.MEASUREMENT_ANGLE, # DPTRotationAngle
|
||||
"9.026": SensorStateClass.TOTAL_INCREASING, # DPTRainAmount
|
||||
"12.1200": SensorStateClass.TOTAL, # DPTVolumeLiquidLitre
|
||||
"12.1201": SensorStateClass.TOTAL, # DPTVolumeM3
|
||||
"13.010": SensorStateClass.TOTAL, # DPTActiveEnergy
|
||||
"13.011": SensorStateClass.TOTAL, # DPTApparantEnergy
|
||||
"13.012": SensorStateClass.TOTAL, # DPTReactiveEnergy
|
||||
"14.007": SensorStateClass.MEASUREMENT_ANGLE, # DPTAngleDeg
|
||||
"14.037": SensorStateClass.TOTAL, # DPTHeatQuantity
|
||||
"14.051": SensorStateClass.TOTAL, # DPTMass
|
||||
"14.055": SensorStateClass.MEASUREMENT_ANGLE, # DPTPhaseAngleDeg
|
||||
"14.031": SensorStateClass.TOTAL_INCREASING, # DPTEnergy
|
||||
"17.001": None, # DPTSceneNumber
|
||||
"29.010": SensorStateClass.TOTAL, # DPTActiveEnergy8Byte
|
||||
"29.011": SensorStateClass.TOTAL, # DPTApparantEnergy8Byte
|
||||
"29.012": SensorStateClass.TOTAL, # DPTReactiveEnergy8Byte
|
||||
}
|
||||
|
||||
|
||||
def _get_sensor_state_class(
|
||||
ha_dpt_class: HaDptClass, dpt_number_str: str
|
||||
) -> SensorStateClass | None:
|
||||
"""Return the SensorStateClass for a given DPT."""
|
||||
if ha_dpt_class != "numeric":
|
||||
return None
|
||||
|
||||
return _sensor_state_class_overrides.get(
|
||||
dpt_number_str,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
)
|
||||
@@ -5,13 +5,17 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import Any, Final
|
||||
|
||||
from propcache.api import cached_property
|
||||
from xknx.devices import Fan as XknxFan
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.percentage import (
|
||||
percentage_to_ranged_value,
|
||||
@@ -19,10 +23,18 @@ from homeassistant.util.percentage import (
|
||||
)
|
||||
from homeassistant.util.scaling import int_states_in_range
|
||||
|
||||
from .const import KNX_ADDRESS, KNX_MODULE_KEY
|
||||
from .entity import KnxYamlEntity
|
||||
from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, FanConf
|
||||
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
|
||||
from .knx_module import KNXModule
|
||||
from .schema import FanSchema
|
||||
from .storage.const import (
|
||||
CONF_ENTITY,
|
||||
CONF_GA_OSCILLATION,
|
||||
CONF_GA_SPEED,
|
||||
CONF_GA_STEP,
|
||||
CONF_SPEED,
|
||||
)
|
||||
from .storage.util import ConfigExtractor
|
||||
|
||||
DEFAULT_PERCENTAGE: Final = 50
|
||||
|
||||
@@ -34,40 +46,36 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up fan(s) for KNX platform."""
|
||||
knx_module = hass.data[KNX_MODULE_KEY]
|
||||
config: list[ConfigType] = knx_module.config_yaml[Platform.FAN]
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.FAN,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiFan,
|
||||
),
|
||||
)
|
||||
|
||||
async_add_entities(KNXFan(knx_module, entity_config) for entity_config in config)
|
||||
entities: list[_KnxFan] = []
|
||||
if yaml_platform_config := knx_module.config_yaml.get(Platform.FAN):
|
||||
entities.extend(
|
||||
KnxYamlFan(knx_module, entity_config)
|
||||
for entity_config in yaml_platform_config
|
||||
)
|
||||
if ui_config := knx_module.config_store.data["entities"].get(Platform.FAN):
|
||||
entities.extend(
|
||||
KnxUiFan(knx_module, unique_id, config)
|
||||
for unique_id, config in ui_config.items()
|
||||
)
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class KNXFan(KnxYamlEntity, FanEntity):
|
||||
class _KnxFan(FanEntity):
|
||||
"""Representation of a KNX fan."""
|
||||
|
||||
_device: XknxFan
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of KNX fan."""
|
||||
max_step = config.get(FanSchema.CONF_MAX_STEP)
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxFan(
|
||||
xknx=knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
group_address_speed=config.get(KNX_ADDRESS),
|
||||
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
|
||||
group_address_oscillation=config.get(
|
||||
FanSchema.CONF_OSCILLATION_ADDRESS
|
||||
),
|
||||
group_address_oscillation_state=config.get(
|
||||
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
|
||||
),
|
||||
max_step=max_step,
|
||||
),
|
||||
)
|
||||
# FanSpeedMode.STEP if max_step is set
|
||||
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
|
||||
self._attr_unique_id = str(self._device.speed.group_address)
|
||||
_step_range: tuple[int, int] | None
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
@@ -77,7 +85,7 @@ class KNXFan(KnxYamlEntity, FanEntity):
|
||||
else:
|
||||
await self._device.set_speed(percentage)
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def supported_features(self) -> FanEntityFeature:
|
||||
"""Flag supported features."""
|
||||
flags = (
|
||||
@@ -103,7 +111,7 @@ class KNXFan(KnxYamlEntity, FanEntity):
|
||||
)
|
||||
return self._device.current_speed
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def speed_count(self) -> int:
|
||||
"""Return the number of speeds the fan supports."""
|
||||
if self._step_range is None:
|
||||
@@ -134,3 +142,76 @@ class KNXFan(KnxYamlEntity, FanEntity):
|
||||
def oscillating(self) -> bool | None:
|
||||
"""Return whether or not the fan is currently oscillating."""
|
||||
return self._device.current_oscillation
|
||||
|
||||
|
||||
class KnxYamlFan(_KnxFan, KnxYamlEntity):
|
||||
"""Representation of a KNX fan configured from YAML."""
|
||||
|
||||
_device: XknxFan
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of KNX fan."""
|
||||
max_step = config.get(FanConf.MAX_STEP)
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxFan(
|
||||
xknx=knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
group_address_speed=config.get(KNX_ADDRESS),
|
||||
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
|
||||
group_address_oscillation=config.get(
|
||||
FanSchema.CONF_OSCILLATION_ADDRESS
|
||||
),
|
||||
group_address_oscillation_state=config.get(
|
||||
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
|
||||
),
|
||||
max_step=max_step,
|
||||
),
|
||||
)
|
||||
# FanSpeedMode.STEP if max_step is set
|
||||
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
|
||||
self._attr_unique_id = str(self._device.speed.group_address)
|
||||
|
||||
|
||||
class KnxUiFan(_KnxFan, KnxUiEntity):
|
||||
"""Representation of a KNX fan configured from UI."""
|
||||
|
||||
_device: XknxFan
|
||||
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize of KNX fan."""
|
||||
knx_conf = ConfigExtractor(config[DOMAIN])
|
||||
# max_step is required for step mode, thus can be used to differentiate modes
|
||||
max_step: int | None = knx_conf.get(CONF_SPEED, FanConf.MAX_STEP)
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
unique_id=unique_id,
|
||||
entity_config=config[CONF_ENTITY],
|
||||
)
|
||||
if max_step:
|
||||
# step control
|
||||
speed_write = knx_conf.get_write(CONF_SPEED, CONF_GA_STEP)
|
||||
speed_state = knx_conf.get_state_and_passive(CONF_SPEED, CONF_GA_STEP)
|
||||
else:
|
||||
# percentage control
|
||||
speed_write = knx_conf.get_write(CONF_SPEED, CONF_GA_SPEED)
|
||||
speed_state = knx_conf.get_state_and_passive(CONF_SPEED, CONF_GA_SPEED)
|
||||
|
||||
self._device = XknxFan(
|
||||
xknx=knx_module.xknx,
|
||||
name=config[CONF_ENTITY][CONF_NAME],
|
||||
group_address_speed=speed_write,
|
||||
group_address_speed_state=speed_state,
|
||||
group_address_oscillation=knx_conf.get_write(CONF_GA_OSCILLATION),
|
||||
group_address_oscillation_state=knx_conf.get_state_and_passive(
|
||||
CONF_GA_OSCILLATION
|
||||
),
|
||||
max_step=max_step,
|
||||
sync_state=knx_conf.get(CONF_SYNC_STATE),
|
||||
)
|
||||
# FanSpeedMode.STEP if max_step is set
|
||||
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
|
||||
|
||||
@@ -59,6 +59,7 @@ from .const import (
|
||||
ClimateConf,
|
||||
ColorTempModes,
|
||||
CoverConf,
|
||||
FanConf,
|
||||
FanZeroMode,
|
||||
)
|
||||
from .validation import (
|
||||
@@ -575,7 +576,6 @@ class FanSchema(KNXPlatformSchema):
|
||||
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
|
||||
CONF_OSCILLATION_ADDRESS = "oscillation_address"
|
||||
CONF_OSCILLATION_STATE_ADDRESS = "oscillation_state_address"
|
||||
CONF_MAX_STEP = "max_step"
|
||||
|
||||
DEFAULT_NAME = "KNX Fan"
|
||||
|
||||
@@ -586,7 +586,7 @@ class FanSchema(KNXPlatformSchema):
|
||||
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_MAX_STEP): cv.byte,
|
||||
vol.Optional(FanConf.MAX_STEP): cv.byte,
|
||||
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,8 +6,8 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.core.connection_state import XknxConnectionState, XknxConnectionType
|
||||
from xknx.devices import Device as XknxDevice, Sensor as XknxSensor
|
||||
|
||||
@@ -25,20 +25,32 @@ from homeassistant.const import (
|
||||
CONF_ENTITY_CATEGORY,
|
||||
CONF_NAME,
|
||||
CONF_TYPE,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
EntityCategory,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, StateType
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
|
||||
from .const import ATTR_SOURCE, KNX_MODULE_KEY
|
||||
from .entity import KnxYamlEntity
|
||||
from .const import ATTR_SOURCE, CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY
|
||||
from .dpt import get_supported_dpts
|
||||
from .entity import (
|
||||
KnxUiEntity,
|
||||
KnxUiEntityPlatformController,
|
||||
KnxYamlEntity,
|
||||
_KnxEntityBase,
|
||||
)
|
||||
from .knx_module import KNXModule
|
||||
from .schema import SensorSchema
|
||||
from .storage.const import CONF_ALWAYS_CALLBACK, CONF_ENTITY, CONF_GA_SENSOR
|
||||
from .storage.util import ConfigExtractor
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
|
||||
@@ -122,58 +134,41 @@ async def async_setup_entry(
|
||||
config_entry: config_entries.ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensor(s) for KNX platform."""
|
||||
"""Set up entities for KNX platform."""
|
||||
knx_module = hass.data[KNX_MODULE_KEY]
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.SENSOR,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiSensor,
|
||||
),
|
||||
)
|
||||
|
||||
entities: list[SensorEntity] = []
|
||||
entities.extend(
|
||||
KNXSystemSensor(knx_module, description)
|
||||
for description in SYSTEM_ENTITY_DESCRIPTIONS
|
||||
)
|
||||
config: list[ConfigType] | None = knx_module.config_yaml.get(Platform.SENSOR)
|
||||
if config:
|
||||
if yaml_platform_config := knx_module.config_yaml.get(Platform.SENSOR):
|
||||
entities.extend(
|
||||
KNXSensor(knx_module, entity_config) for entity_config in config
|
||||
KnxYamlSensor(knx_module, entity_config)
|
||||
for entity_config in yaml_platform_config
|
||||
)
|
||||
if ui_config := knx_module.config_store.data["entities"].get(Platform.SENSOR):
|
||||
entities.extend(
|
||||
KnxUiSensor(knx_module, unique_id, config)
|
||||
for unique_id, config in ui_config.items()
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor:
|
||||
"""Return a KNX sensor to be used within XKNX."""
|
||||
return XknxSensor(
|
||||
xknx,
|
||||
name=config[CONF_NAME],
|
||||
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
|
||||
sync_state=config[SensorSchema.CONF_SYNC_STATE],
|
||||
always_callback=True,
|
||||
value_type=config[CONF_TYPE],
|
||||
)
|
||||
|
||||
|
||||
class KNXSensor(KnxYamlEntity, RestoreSensor):
|
||||
class _KnxSensor(RestoreSensor, _KnxEntityBase):
|
||||
"""Representation of a KNX sensor."""
|
||||
|
||||
_device: XknxSensor
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of a KNX sensor."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_sensor(knx_module.xknx, config),
|
||||
)
|
||||
if device_class := config.get(CONF_DEVICE_CLASS):
|
||||
self._attr_device_class = device_class
|
||||
else:
|
||||
self._attr_device_class = try_parse_enum(
|
||||
SensorDeviceClass, self._device.ha_device_class()
|
||||
)
|
||||
|
||||
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.sensor_value.group_address_state)
|
||||
self._attr_native_unit_of_measurement = self._device.unit_of_measurement()
|
||||
self._attr_state_class = config.get(CONF_STATE_CLASS)
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last state."""
|
||||
if (
|
||||
@@ -198,6 +193,89 @@ class KNXSensor(KnxYamlEntity, RestoreSensor):
|
||||
super().after_update_callback(device)
|
||||
|
||||
|
||||
class KnxYamlSensor(_KnxSensor, KnxYamlEntity):
|
||||
"""Representation of a KNX sensor configured from YAML."""
|
||||
|
||||
_device: XknxSensor
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of a KNX sensor."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxSensor(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
|
||||
sync_state=config[CONF_SYNC_STATE],
|
||||
always_callback=True,
|
||||
value_type=config[CONF_TYPE],
|
||||
),
|
||||
)
|
||||
if device_class := config.get(CONF_DEVICE_CLASS):
|
||||
self._attr_device_class = device_class
|
||||
else:
|
||||
self._attr_device_class = try_parse_enum(
|
||||
SensorDeviceClass, self._device.ha_device_class()
|
||||
)
|
||||
|
||||
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.sensor_value.group_address_state)
|
||||
self._attr_native_unit_of_measurement = self._device.unit_of_measurement()
|
||||
self._attr_state_class = config.get(CONF_STATE_CLASS)
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
|
||||
class KnxUiSensor(_KnxSensor, KnxUiEntity):
|
||||
"""Representation of a KNX sensor configured from the UI."""
|
||||
|
||||
_device: XknxSensor
|
||||
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize KNX sensor."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
unique_id=unique_id,
|
||||
entity_config=config[CONF_ENTITY],
|
||||
)
|
||||
knx_conf = ConfigExtractor(config[DOMAIN])
|
||||
dpt_string = knx_conf.get_dpt(CONF_GA_SENSOR)
|
||||
assert dpt_string is not None # required for sensor
|
||||
dpt_info = get_supported_dpts()[dpt_string]
|
||||
|
||||
self._device = XknxSensor(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_ENTITY][CONF_NAME],
|
||||
group_address_state=knx_conf.get_state_and_passive(CONF_GA_SENSOR),
|
||||
sync_state=knx_conf.get(CONF_SYNC_STATE),
|
||||
always_callback=True,
|
||||
value_type=dpt_string,
|
||||
)
|
||||
|
||||
if device_class_override := knx_conf.get(CONF_DEVICE_CLASS):
|
||||
self._attr_device_class = try_parse_enum(
|
||||
SensorDeviceClass, device_class_override
|
||||
)
|
||||
else:
|
||||
self._attr_device_class = dpt_info["sensor_device_class"]
|
||||
|
||||
if state_class_override := knx_conf.get(CONF_STATE_CLASS):
|
||||
self._attr_state_class = try_parse_enum(
|
||||
SensorStateClass, state_class_override
|
||||
)
|
||||
else:
|
||||
self._attr_state_class = dpt_info["sensor_state_class"]
|
||||
|
||||
self._attr_native_unit_of_measurement = (
|
||||
knx_conf.get(CONF_UNIT_OF_MEASUREMENT) or dpt_info["unit"]
|
||||
)
|
||||
|
||||
self._attr_force_update = knx_conf.get(CONF_ALWAYS_CALLBACK, default=False)
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
|
||||
class KNXSystemSensor(SensorEntity):
|
||||
"""Representation of a KNX system sensor."""
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ CONF_GA_DATE: Final = "ga_date"
|
||||
CONF_GA_DATETIME: Final = "ga_datetime"
|
||||
CONF_GA_TIME: Final = "ga_time"
|
||||
|
||||
CONF_GA_STEP: Final = "ga_step"
|
||||
|
||||
# Climate
|
||||
CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current"
|
||||
CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current"
|
||||
@@ -42,11 +44,15 @@ CONF_GA_FAN_SWING_HORIZONTAL: Final = "ga_fan_swing_horizontal"
|
||||
# Cover
|
||||
CONF_GA_UP_DOWN: Final = "ga_up_down"
|
||||
CONF_GA_STOP: Final = "ga_stop"
|
||||
CONF_GA_STEP: Final = "ga_step"
|
||||
CONF_GA_POSITION_SET: Final = "ga_position_set"
|
||||
CONF_GA_POSITION_STATE: Final = "ga_position_state"
|
||||
CONF_GA_ANGLE: Final = "ga_angle"
|
||||
|
||||
# Fan
|
||||
CONF_SPEED: Final = "speed"
|
||||
CONF_GA_SPEED: Final = "ga_speed"
|
||||
CONF_GA_OSCILLATION: Final = "ga_oscillation"
|
||||
|
||||
# Light
|
||||
CONF_COLOR_TEMP_MIN: Final = "color_temp_min"
|
||||
CONF_COLOR_TEMP_MAX: Final = "color_temp_max"
|
||||
@@ -65,3 +71,6 @@ CONF_GA_WHITE_BRIGHTNESS: Final = "ga_white_brightness"
|
||||
CONF_GA_WHITE_SWITCH: Final = "ga_white_switch"
|
||||
CONF_GA_HUE: Final = "ga_hue"
|
||||
CONF_GA_SATURATION: Final = "ga_saturation"
|
||||
|
||||
# Sensor
|
||||
CONF_ALWAYS_CALLBACK: Final = "always_callback"
|
||||
|
||||
@@ -5,11 +5,21 @@ from enum import StrEnum, unique
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import HVACMode
|
||||
from homeassistant.components.sensor import (
|
||||
CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS,
|
||||
DEVICE_CLASS_STATE_CLASSES,
|
||||
DEVICE_CLASS_UNITS,
|
||||
STATE_CLASS_UNITS,
|
||||
SensorDeviceClass,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_ENTITY_CATEGORY,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_NAME,
|
||||
CONF_PLATFORM,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, selector
|
||||
@@ -28,14 +38,18 @@ from ..const import (
|
||||
ClimateConf,
|
||||
ColorTempModes,
|
||||
CoverConf,
|
||||
FanConf,
|
||||
FanZeroMode,
|
||||
)
|
||||
from ..dpt import get_supported_dpts
|
||||
from .const import (
|
||||
CONF_ALWAYS_CALLBACK,
|
||||
CONF_COLOR,
|
||||
CONF_COLOR_TEMP_MAX,
|
||||
CONF_COLOR_TEMP_MIN,
|
||||
CONF_DATA,
|
||||
CONF_DEVICE_INFO,
|
||||
CONF_DPT,
|
||||
CONF_ENTITY,
|
||||
CONF_GA_ACTIVE,
|
||||
CONF_GA_ANGLE,
|
||||
@@ -62,6 +76,7 @@ from .const import (
|
||||
CONF_GA_OP_MODE_PROTECTION,
|
||||
CONF_GA_OP_MODE_STANDBY,
|
||||
CONF_GA_OPERATION_MODE,
|
||||
CONF_GA_OSCILLATION,
|
||||
CONF_GA_POSITION_SET,
|
||||
CONF_GA_POSITION_STATE,
|
||||
CONF_GA_RED_BRIGHTNESS,
|
||||
@@ -69,6 +84,7 @@ from .const import (
|
||||
CONF_GA_SATURATION,
|
||||
CONF_GA_SENSOR,
|
||||
CONF_GA_SETPOINT_SHIFT,
|
||||
CONF_GA_SPEED,
|
||||
CONF_GA_STEP,
|
||||
CONF_GA_STOP,
|
||||
CONF_GA_SWITCH,
|
||||
@@ -80,6 +96,7 @@ from .const import (
|
||||
CONF_GA_WHITE_BRIGHTNESS,
|
||||
CONF_GA_WHITE_SWITCH,
|
||||
CONF_IGNORE_AUTO_MODE,
|
||||
CONF_SPEED,
|
||||
CONF_TARGET_TEMPERATURE,
|
||||
)
|
||||
from .knx_selector import (
|
||||
@@ -220,6 +237,42 @@ DATETIME_KNX_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
FAN_KNX_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SPEED): GroupSelect(
|
||||
GroupSelectOption(
|
||||
translation_key="percentage_mode",
|
||||
schema={
|
||||
vol.Required(CONF_GA_SPEED): GASelector(
|
||||
write_required=True, valid_dpt="5.001"
|
||||
),
|
||||
},
|
||||
),
|
||||
GroupSelectOption(
|
||||
translation_key="step_mode",
|
||||
schema={
|
||||
vol.Required(CONF_GA_STEP): GASelector(
|
||||
write_required=True, valid_dpt="5.010"
|
||||
),
|
||||
vol.Required(FanConf.MAX_STEP, default=3): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1,
|
||||
max=100,
|
||||
step=1,
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
)
|
||||
),
|
||||
},
|
||||
),
|
||||
collapsible=False,
|
||||
),
|
||||
vol.Optional(CONF_GA_OSCILLATION): GASelector(
|
||||
write_required=True, valid_dpt="1"
|
||||
),
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@unique
|
||||
class LightColorMode(StrEnum):
|
||||
@@ -507,13 +560,123 @@ CLIMATE_KNX_SCHEMA = vol.Schema(
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _validate_sensor_attributes(config: dict) -> dict:
|
||||
"""Validate that state_class is compatible with device_class and unit_of_measurement."""
|
||||
dpt = config[CONF_GA_SENSOR][CONF_DPT]
|
||||
dpt_metadata = get_supported_dpts()[dpt]
|
||||
state_class = config.get(
|
||||
CONF_SENSOR_STATE_CLASS,
|
||||
dpt_metadata["sensor_state_class"],
|
||||
)
|
||||
device_class = config.get(
|
||||
CONF_DEVICE_CLASS,
|
||||
dpt_metadata["sensor_device_class"],
|
||||
)
|
||||
unit_of_measurement = config.get(
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
dpt_metadata["unit"],
|
||||
)
|
||||
if (
|
||||
state_class
|
||||
and device_class
|
||||
and (state_classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None
|
||||
and state_class not in state_classes
|
||||
):
|
||||
raise vol.Invalid(
|
||||
f"State class '{state_class}' is not valid for device class '{device_class}'. "
|
||||
f"Valid options are: {', '.join(sorted(map(str, state_classes), key=str.casefold))}",
|
||||
path=[CONF_SENSOR_STATE_CLASS],
|
||||
)
|
||||
if (
|
||||
device_class
|
||||
and (d_c_units := DEVICE_CLASS_UNITS.get(device_class)) is not None
|
||||
and unit_of_measurement not in d_c_units
|
||||
):
|
||||
raise vol.Invalid(
|
||||
f"Unit of measurement '{unit_of_measurement}' is not valid for device class '{device_class}'. "
|
||||
f"Valid options are: {', '.join(sorted(map(str, d_c_units), key=str.casefold))}",
|
||||
path=(
|
||||
[CONF_DEVICE_CLASS]
|
||||
if CONF_DEVICE_CLASS in config
|
||||
else [CONF_UNIT_OF_MEASUREMENT]
|
||||
),
|
||||
)
|
||||
if (
|
||||
state_class
|
||||
and (s_c_units := STATE_CLASS_UNITS.get(state_class)) is not None
|
||||
and unit_of_measurement not in s_c_units
|
||||
):
|
||||
raise vol.Invalid(
|
||||
f"Unit of measurement '{unit_of_measurement}' is not valid for state class '{state_class}'. "
|
||||
f"Valid options are: {', '.join(sorted(map(str, s_c_units), key=str.casefold))}",
|
||||
path=(
|
||||
[CONF_SENSOR_STATE_CLASS]
|
||||
if CONF_SENSOR_STATE_CLASS in config
|
||||
else [CONF_UNIT_OF_MEASUREMENT]
|
||||
),
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
SENSOR_KNX_SCHEMA = AllSerializeFirst(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_GA_SENSOR): GASelector(
|
||||
write=False, state_required=True, dpt=["numeric", "string"]
|
||||
),
|
||||
"section_advanced_options": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=sorted(
|
||||
{
|
||||
str(unit)
|
||||
for units in DEVICE_CLASS_UNITS.values()
|
||||
for unit in units
|
||||
if unit is not None
|
||||
}
|
||||
),
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
translation_key="component.knx.selector.sensor_unit_of_measurement",
|
||||
custom_value=True,
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=[
|
||||
cls.value
|
||||
for cls in SensorDeviceClass
|
||||
if cls != SensorDeviceClass.ENUM
|
||||
],
|
||||
translation_key="component.knx.selector.sensor_device_class",
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_SENSOR_STATE_CLASS): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=list(SensorStateClass),
|
||||
translation_key="component.knx.selector.sensor_state_class",
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_ALWAYS_CALLBACK): selector.BooleanSelector(),
|
||||
vol.Required(CONF_SYNC_STATE, default=True): SyncStateSelector(
|
||||
allow_false=True
|
||||
),
|
||||
},
|
||||
),
|
||||
_validate_sensor_attributes,
|
||||
)
|
||||
|
||||
KNX_SCHEMA_FOR_PLATFORM = {
|
||||
Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA,
|
||||
Platform.CLIMATE: CLIMATE_KNX_SCHEMA,
|
||||
Platform.COVER: COVER_KNX_SCHEMA,
|
||||
Platform.DATE: DATE_KNX_SCHEMA,
|
||||
Platform.DATETIME: DATETIME_KNX_SCHEMA,
|
||||
Platform.FAN: FAN_KNX_SCHEMA,
|
||||
Platform.LIGHT: LIGHT_KNX_SCHEMA,
|
||||
Platform.SENSOR: SENSOR_KNX_SCHEMA,
|
||||
Platform.SWITCH: SWITCH_KNX_SCHEMA,
|
||||
Platform.TIME: TIME_KNX_SCHEMA,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from ..dpt import HaDptClass, get_supported_dpts
|
||||
from ..validation import ga_validator, maybe_ga_validator, sync_state_validator
|
||||
from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE
|
||||
from .util import dpt_string_to_dict
|
||||
@@ -162,7 +163,7 @@ class GASelector(KNXSelectorBase):
|
||||
passive: bool = True,
|
||||
write_required: bool = False,
|
||||
state_required: bool = False,
|
||||
dpt: type[Enum] | None = None,
|
||||
dpt: type[Enum] | list[HaDptClass] | None = None,
|
||||
valid_dpt: str | Iterable[str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the group address selector."""
|
||||
@@ -186,14 +187,17 @@ class GASelector(KNXSelectorBase):
|
||||
"passive": self.passive,
|
||||
}
|
||||
if self.dpt is not None:
|
||||
options["dptSelect"] = [
|
||||
{
|
||||
"value": item.value,
|
||||
"translation_key": item.value.replace(".", "_"),
|
||||
"dpt": dpt_string_to_dict(item.value), # used for filtering GAs
|
||||
}
|
||||
for item in self.dpt
|
||||
]
|
||||
if isinstance(self.dpt, list):
|
||||
options["dptClasses"] = self.dpt
|
||||
else:
|
||||
options["dptSelect"] = [
|
||||
{
|
||||
"value": item.value,
|
||||
"translation_key": item.value.replace(".", "_"),
|
||||
"dpt": dpt_string_to_dict(item.value), # used for filtering GAs
|
||||
}
|
||||
for item in self.dpt
|
||||
]
|
||||
if self.valid_dpt is not None:
|
||||
options["validDPTs"] = [dpt_string_to_dict(dpt) for dpt in self.valid_dpt]
|
||||
|
||||
@@ -254,7 +258,12 @@ class GASelector(KNXSelectorBase):
|
||||
def _add_dpt(self, schema: dict[vol.Marker, Any]) -> None:
|
||||
"""Add DPT validator to the schema."""
|
||||
if self.dpt is not None:
|
||||
schema[vol.Required(CONF_DPT)] = vol.In({item.value for item in self.dpt})
|
||||
if isinstance(self.dpt, list):
|
||||
schema[vol.Required(CONF_DPT)] = vol.In(get_supported_dpts())
|
||||
else:
|
||||
schema[vol.Required(CONF_DPT)] = vol.In(
|
||||
{item.value for item in self.dpt}
|
||||
)
|
||||
else:
|
||||
schema[vol.Remove(CONF_DPT)] = object
|
||||
|
||||
|
||||
@@ -154,6 +154,183 @@
|
||||
}
|
||||
},
|
||||
"config_panel": {
|
||||
"dpt": {
|
||||
"options": {
|
||||
"5": "Generic 1-byte unsigned integer",
|
||||
"5_001": "Percent (0 … 100)",
|
||||
"5_003": "Angle",
|
||||
"5_004": "Percent (0 … 255)",
|
||||
"5_005": "Decimal factor",
|
||||
"5_006": "Tariff",
|
||||
"5_010": "Counter (0 … 255)",
|
||||
"6": "Generic 1-byte signed integer",
|
||||
"6_001": "Percent (-128 … 127)",
|
||||
"6_010": "Counter (-128 … 127)",
|
||||
"7": "Generic 2-byte unsigned integer",
|
||||
"7_001": "Counter (0 … 65535)",
|
||||
"7_002": "Time period",
|
||||
"7_003": "Time period (10 ms)",
|
||||
"7_004": "Time period (100 ms)",
|
||||
"7_005": "[%key:component::knx::config_panel::dpt::options::7_002%]",
|
||||
"7_006": "[%key:component::knx::config_panel::dpt::options::7_002%]",
|
||||
"7_007": "[%key:component::knx::config_panel::dpt::options::7_002%]",
|
||||
"7_010": "Interface Object Property",
|
||||
"7_011": "Length",
|
||||
"7_012": "Electrical current",
|
||||
"7_013": "Brightness",
|
||||
"7_600": "Color temperature",
|
||||
"8": "Generic 2-byte signed integer",
|
||||
"8_001": "Counter (-32 768 … 32 767)",
|
||||
"8_002": "Delta time",
|
||||
"8_003": "Delta time (10 ms)",
|
||||
"8_004": "Delta time (100 ms)",
|
||||
"8_005": "[%key:component::knx::config_panel::dpt::options::8_002%]",
|
||||
"8_006": "[%key:component::knx::config_panel::dpt::options::8_002%]",
|
||||
"8_007": "[%key:component::knx::config_panel::dpt::options::8_002%]",
|
||||
"8_010": "Percent (-327,68 … 327,67)",
|
||||
"8_011": "Rotation angle",
|
||||
"8_012": "Length (Altitude)",
|
||||
"9": "Generic 2-byte floating point",
|
||||
"9_001": "Temperature",
|
||||
"9_002": "Temperature difference",
|
||||
"9_003": "Temperature change",
|
||||
"9_004": "Illuminance",
|
||||
"9_005": "Wind speed",
|
||||
"9_006": "Pressure (2-byte)",
|
||||
"9_007": "Humidity",
|
||||
"9_008": "Air quality",
|
||||
"9_009": "Air flow",
|
||||
"9_010": "Time",
|
||||
"9_011": "[%key:component::knx::config_panel::dpt::options::9_010%]",
|
||||
"9_020": "Voltage",
|
||||
"9_021": "Current",
|
||||
"9_022": "Power density",
|
||||
"9_023": "Temperature sensitivity",
|
||||
"9_024": "Power (2-byte)",
|
||||
"9_025": "Volume flow",
|
||||
"9_026": "Rain amount",
|
||||
"9_027": "[%key:component::knx::config_panel::dpt::options::9_001%]",
|
||||
"9_028": "[%key:component::knx::config_panel::dpt::options::9_005%]",
|
||||
"9_029": "Absolute humidity",
|
||||
"9_030": "Concentration",
|
||||
"9_60000": "Enthalpy",
|
||||
"12": "Generic 4-byte unsigned integer",
|
||||
"12_001": "Counter (0 … 4 294 967 295)",
|
||||
"12_100": "Time period (4-byte)",
|
||||
"12_101": "[%key:component::knx::config_panel::dpt::options::12_100%]",
|
||||
"12_102": "[%key:component::knx::config_panel::dpt::options::12_100%]",
|
||||
"12_1200": "Liquid volume",
|
||||
"12_1201": "Volume",
|
||||
"13": "Generic 4-byte signed integer",
|
||||
"13_001": "Counter (-2 147 483 648 … 2 147 483 647)",
|
||||
"13_002": "Flow rate",
|
||||
"13_010": "Active energy",
|
||||
"13_011": "Apparent energy",
|
||||
"13_012": "Reactive energy",
|
||||
"13_013": "[%key:component::knx::config_panel::dpt::options::13_010%]",
|
||||
"13_014": "[%key:component::knx::config_panel::dpt::options::13_011%]",
|
||||
"13_015": "[%key:component::knx::config_panel::dpt::options::13_012%]",
|
||||
"13_016": "[%key:component::knx::config_panel::dpt::options::13_010%]",
|
||||
"13_100": "Operating hours",
|
||||
"13_1200": "Delta liquid volume",
|
||||
"13_1201": "Delta volume",
|
||||
"14": "Generic 4-byte floating point",
|
||||
"14_000": "Acceleration",
|
||||
"14_001": "Angular acceleration",
|
||||
"14_002": "Activation energy",
|
||||
"14_003": "Activity (radioactive)",
|
||||
"14_004": "Amount of substance",
|
||||
"14_005": "Amplitude",
|
||||
"14_006": "Angle",
|
||||
"14_007": "[%key:component::knx::config_panel::dpt::options::14_006%]",
|
||||
"14_008": "Angular momentum",
|
||||
"14_009": "Angular velocity",
|
||||
"14_010": "Area",
|
||||
"14_011": "Capacitance",
|
||||
"14_012": "Charge density (surface)",
|
||||
"14_013": "Charge density (volume)",
|
||||
"14_014": "Compressibility",
|
||||
"14_015": "Conductance",
|
||||
"14_016": "Electrical conductivity",
|
||||
"14_017": "Density",
|
||||
"14_018": "Electric charge",
|
||||
"14_019": "Electric current",
|
||||
"14_020": "Electric current density",
|
||||
"14_021": "Electric dipole moment",
|
||||
"14_022": "Electric displacement",
|
||||
"14_023": "Electric field strength",
|
||||
"14_024": "Electric flux",
|
||||
"14_025": "Electric flux density",
|
||||
"14_026": "Electric polarization",
|
||||
"14_027": "Electric potential",
|
||||
"14_028": "Potential difference",
|
||||
"14_029": "Electromagnetic moment",
|
||||
"14_030": "Electromotive force",
|
||||
"14_031": "Energy",
|
||||
"14_032": "Force",
|
||||
"14_033": "Frequency",
|
||||
"14_034": "Angular frequency",
|
||||
"14_035": "Heat capacity",
|
||||
"14_036": "Heat flow rate",
|
||||
"14_037": "Heat quantity",
|
||||
"14_038": "Impedance",
|
||||
"14_039": "Length",
|
||||
"14_040": "Light quantity",
|
||||
"14_041": "Luminance",
|
||||
"14_042": "Luminous flux",
|
||||
"14_043": "Luminous intensity",
|
||||
"14_044": "Magnetic field strength",
|
||||
"14_045": "Magnetic flux",
|
||||
"14_046": "Magnetic flux density",
|
||||
"14_047": "Magnetic moment",
|
||||
"14_048": "Magnetic polarization",
|
||||
"14_049": "Magnetization",
|
||||
"14_050": "Magnetomotive force",
|
||||
"14_051": "Mass",
|
||||
"14_052": "Mass flux",
|
||||
"14_053": "Momentum",
|
||||
"14_054": "Phase angle",
|
||||
"14_055": "[%key:component::knx::config_panel::dpt::options::14_054%]",
|
||||
"14_056": "Power (4-byte)",
|
||||
"14_057": "Power factor",
|
||||
"14_058": "Pressure (4-byte)",
|
||||
"14_059": "Reactance",
|
||||
"14_060": "Resistance",
|
||||
"14_061": "Resistivity",
|
||||
"14_062": "Self inductance",
|
||||
"14_063": "Solid angle",
|
||||
"14_064": "Sound intensity",
|
||||
"14_065": "Speed",
|
||||
"14_066": "Stress",
|
||||
"14_067": "Surface tension",
|
||||
"14_068": "Common temperature",
|
||||
"14_069": "Absolute temperature",
|
||||
"14_070": "[%key:component::knx::config_panel::dpt::options::9_002%]",
|
||||
"14_071": "Thermal capacity",
|
||||
"14_072": "Thermal conductivity",
|
||||
"14_073": "Thermoelectric power",
|
||||
"14_074": "[%key:component::knx::config_panel::dpt::options::9_010%]",
|
||||
"14_075": "Torque",
|
||||
"14_076": "[%key:component::knx::config_panel::dpt::options::12_1201%]",
|
||||
"14_077": "Volume flux",
|
||||
"14_078": "Weight",
|
||||
"14_079": "Work",
|
||||
"14_080": "Apparent power",
|
||||
"14_1200": "Meter flow",
|
||||
"14_1201": "[%key:component::knx::config_panel::dpt::options::14_1200%]",
|
||||
"16_000": "String (ASCII)",
|
||||
"16_001": "String (Latin-1)",
|
||||
"17_001": "Scene number",
|
||||
"29": "Generic 8-byte signed integer",
|
||||
"29_010": "Active energy (8-byte)",
|
||||
"29_011": "Apparent energy (8-byte)",
|
||||
"29_012": "Reactive energy (8-byte)"
|
||||
},
|
||||
"selector": {
|
||||
"label": "Select a datapoint type",
|
||||
"no_selection": "No DPT selected"
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
"create": {
|
||||
"_": {
|
||||
@@ -460,6 +637,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"description": "The KNX fan platform is used as an interface to fan actuators.",
|
||||
"knx": {
|
||||
"ga_oscillation": {
|
||||
"description": "Toggle oscillation of the fan.",
|
||||
"label": "Oscillation"
|
||||
},
|
||||
"speed": {
|
||||
"description": "Control the speed of the fan.",
|
||||
"ga_speed": {
|
||||
"description": "Group address to control the current speed of the fan as a percentage value.",
|
||||
"label": "Speed"
|
||||
},
|
||||
"ga_step": {
|
||||
"description": "Group address to control the current speed step.",
|
||||
"label": "Step"
|
||||
},
|
||||
"max_step": {
|
||||
"description": "Number of discrete fan speed steps (Off excluded).",
|
||||
"label": "Fan steps"
|
||||
},
|
||||
"options": {
|
||||
"percentage_mode": {
|
||||
"description": "Set the fan speed as a percentage value (0-100%).",
|
||||
"label": "Percentage"
|
||||
},
|
||||
"step_mode": {
|
||||
"description": "Set the fan speed in discrete steps.",
|
||||
"label": "Steps"
|
||||
}
|
||||
},
|
||||
"title": "Fan speed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": "Create new entity",
|
||||
"light": {
|
||||
"description": "The KNX light platform is used as an interface to dimming actuators, LED controllers, DALI gateways and similar.",
|
||||
@@ -558,6 +770,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"description": "Read-only entity for numeric or string datapoints. Temperature, percent etc.",
|
||||
"knx": {
|
||||
"always_callback": {
|
||||
"description": "Write each update to the state machine, even if the data is the same.",
|
||||
"label": "Force update"
|
||||
},
|
||||
"device_class": {
|
||||
"description": "Override the DPTs default device class.",
|
||||
"label": "Device class"
|
||||
},
|
||||
"ga_sensor": {
|
||||
"description": "Group address representing state.",
|
||||
"label": "State"
|
||||
},
|
||||
"section_advanced_options": {
|
||||
"description": "Override default DPT-based sensor attributes.",
|
||||
"title": "Overrides"
|
||||
},
|
||||
"state_class": {
|
||||
"description": "Override the DPTs default state class.",
|
||||
"label": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]"
|
||||
},
|
||||
"unit_of_measurement": {
|
||||
"description": "Override the DPTs default unit of measurement.",
|
||||
"label": "Unit of measurement"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"description": "The KNX switch platform is used as an interface to switching actuators.",
|
||||
"knx": {
|
||||
@@ -692,6 +933,79 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"sensor_device_class": {
|
||||
"options": {
|
||||
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
|
||||
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
|
||||
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
|
||||
"area": "[%key:component::sensor::entity_component::area::name%]",
|
||||
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
|
||||
"battery": "[%key:component::sensor::entity_component::battery::name%]",
|
||||
"blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
|
||||
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
|
||||
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
|
||||
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
|
||||
"current": "[%key:component::sensor::entity_component::current::name%]",
|
||||
"data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
|
||||
"data_size": "[%key:component::sensor::entity_component::data_size::name%]",
|
||||
"date": "[%key:component::sensor::entity_component::date::name%]",
|
||||
"distance": "[%key:component::sensor::entity_component::distance::name%]",
|
||||
"duration": "[%key:component::sensor::entity_component::duration::name%]",
|
||||
"energy": "[%key:component::sensor::entity_component::energy::name%]",
|
||||
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
|
||||
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
|
||||
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
|
||||
"gas": "[%key:component::sensor::entity_component::gas::name%]",
|
||||
"humidity": "[%key:component::sensor::entity_component::humidity::name%]",
|
||||
"illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
|
||||
"irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
|
||||
"moisture": "[%key:component::sensor::entity_component::moisture::name%]",
|
||||
"monetary": "[%key:component::sensor::entity_component::monetary::name%]",
|
||||
"nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
|
||||
"nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
|
||||
"nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
|
||||
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||
"ph": "[%key:component::sensor::entity_component::ph::name%]",
|
||||
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
|
||||
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||
"pm4": "[%key:component::sensor::entity_component::pm4::name%]",
|
||||
"power": "[%key:component::sensor::entity_component::power::name%]",
|
||||
"power_factor": "[%key:component::sensor::entity_component::power_factor::name%]",
|
||||
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
|
||||
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
|
||||
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
|
||||
"reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]",
|
||||
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
|
||||
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
|
||||
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
|
||||
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
||||
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
|
||||
"volume": "[%key:component::sensor::entity_component::volume::name%]",
|
||||
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
|
||||
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
|
||||
"water": "[%key:component::sensor::entity_component::water::name%]",
|
||||
"weight": "[%key:component::sensor::entity_component::weight::name%]",
|
||||
"wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
|
||||
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
|
||||
}
|
||||
},
|
||||
"sensor_state_class": {
|
||||
"options": {
|
||||
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
|
||||
"measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]",
|
||||
"total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]",
|
||||
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"event_register": {
|
||||
"description": "Adds or removes group addresses to knx_event filter for triggering `knx_event`s. Only addresses added with this action can be removed.",
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.helpers.typing import UNDEFINED
|
||||
from homeassistant.util.ulid import ulid_now
|
||||
|
||||
from .const import DOMAIN, KNX_MODULE_KEY, SUPPORTED_PLATFORMS_UI
|
||||
from .dpt import get_supported_dpts
|
||||
from .storage.config_store import ConfigStoreException
|
||||
from .storage.const import CONF_DATA
|
||||
from .storage.entity_store_schema import (
|
||||
@@ -186,6 +187,7 @@ def ws_get_base_data(
|
||||
msg["id"],
|
||||
{
|
||||
"connection_info": connection_info,
|
||||
"dpt_metadata": get_supported_dpts(),
|
||||
"project_info": _project_info,
|
||||
"supported_platforms": sorted(SUPPORTED_PLATFORMS_UI),
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"codeowners": ["@OnFreund"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/kodi",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["jsonrpc_async", "jsonrpc_base", "jsonrpc_websocket", "pykodi"],
|
||||
"requirements": ["pykodi==0.2.7"],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@stegm"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/kostal_plenticore",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["kostal"],
|
||||
"requirements": ["pykoplenti==1.3.0"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@eifinger"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/kraken",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["krakenex", "pykrakenapi"],
|
||||
"requirements": ["krakenex==2.2.2", "pykrakenapi==0.1.8"]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/kulersky",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bleak", "pykulersky"],
|
||||
"requirements": ["pykulersky==0.5.8"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@IceBotYT"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lacrosse_view",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["lacrosse_view"],
|
||||
"requirements": ["lacrosse-view==1.1.1"]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ultraheat-api==0.5.7"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@joostlek"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lastfm",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pylast"],
|
||||
"requirements": ["pylast==5.1.0"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@xLarry"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/laundrify",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["laundrify-aio==1.2.2"]
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ from .const import CONF_DOMAIN_DATA
|
||||
from .entity import LcnEntity
|
||||
from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
PARALLEL_UPDATES = 2
|
||||
SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
|
||||
def add_lcn_entities(
|
||||
|
||||
@@ -36,7 +36,7 @@ from .const import (
|
||||
from .entity import LcnEntity
|
||||
from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 2
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ from .const import (
|
||||
from .entity import LcnEntity
|
||||
from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 2
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
BRIGHTNESS_SCALE = (1, 100)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
PARALLEL_UPDATES = 2
|
||||
SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
|
||||
def add_lcn_entities(
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["http", "websocket_api"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/lcn",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pypck"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pypck==0.9.7", "lcn-frontend==0.2.7"]
|
||||
"requirements": ["pypck==0.9.8", "lcn-frontend==0.2.7"]
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ from .const import (
|
||||
from .entity import LcnEntity
|
||||
from .helpers import LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 2
|
||||
|
||||
|
||||
def add_lcn_entities(
|
||||
|
||||
@@ -40,7 +40,7 @@ from .const import (
|
||||
from .entity import LcnEntity
|
||||
from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 2
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ from .const import CONF_DOMAIN_DATA, CONF_OUTPUT, OUTPUT_PORTS, RELAY_PORTS, SET
|
||||
from .entity import LcnEntity
|
||||
from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
PARALLEL_UPDATES = 2
|
||||
SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
|
||||
def add_lcn_switch_entities(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/leaone",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["leaone-ble==0.3.0"]
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/led_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["bluetooth-data-tools==1.28.4", "led-ble==1.1.7"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lg_soundbar",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["temescal"],
|
||||
"requirements": ["temescal==0.5"]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -241,6 +242,7 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
|
||||
# If device is off, turn on first.
|
||||
if not self.data.is_on:
|
||||
await self.async_turn_on()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_set_hvac_mode: %s",
|
||||
@@ -324,10 +326,11 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
|
||||
# If device is off, turn on first.
|
||||
if not self.data.is_on:
|
||||
await self.async_turn_on()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if hvac_mode and hvac_mode != self.hvac_mode:
|
||||
await self.async_set_hvac_mode(HVACMode(hvac_mode))
|
||||
|
||||
await asyncio.sleep(2)
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_set_temperature: %s",
|
||||
self.coordinator.device_name,
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
"name": "LG ThinQ",
|
||||
"codeowners": ["@LG-ThinQ-Integration"],
|
||||
"config_flow": true,
|
||||
"dhcp": [{ "macaddress": "34E6E6*" }],
|
||||
"dhcp": [
|
||||
{
|
||||
"macaddress": "34E6E6*"
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/lg_thinq",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["thinqconnect"],
|
||||
"requirements": ["thinqconnect==1.0.9"]
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"LIFX Z"
|
||||
]
|
||||
},
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiolifx", "aiolifx_effects", "bitstring"],
|
||||
"requirements": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@StefanIacobLivisi", "@planbnet"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/livisi",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["livisi==0.0.25"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/loqed",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["loqedAPI==2.1.10"],
|
||||
"zeroconf": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@majuss", "@suaveolent"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lupusec",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["lupupy"],
|
||||
"requirements": ["lupupy==0.3.2"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@cdheiser", "@wilburCForce"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lutron",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pylutron"],
|
||||
"requirements": ["pylutron==0.2.18"],
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/lyric",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiolyric"],
|
||||
"requirements": ["aiolyric==2.0.2"]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/mailgun",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pymailgunner"],
|
||||
"requirements": ["pymailgunner==1.4"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@Sotolotl", "@emontnemery"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/meater",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["meater-python==0.0.8"]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/medcom_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["medcom-ble==0.1.1"]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/melnor",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["melnor-bluetooth==0.0.25"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "meteo_france",
|
||||
"name": "Météo-France",
|
||||
"name": "M\u00e9t\u00e9o-France",
|
||||
"codeowners": ["@hacf-fr", "@oncleben31", "@Quentame"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/meteo_france",
|
||||
|
||||
@@ -88,6 +88,7 @@ UNSUPPORTED_EXTENDED_CACHE_RETENTION_MODELS: list[str] = [
|
||||
"o4",
|
||||
"gpt-3.5",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4o",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
]
|
||||
|
||||
@@ -37,10 +37,12 @@ from .const import (
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import (
|
||||
RoborockB01Q7UpdateCoordinator,
|
||||
RoborockConfigEntry,
|
||||
RoborockCoordinators,
|
||||
RoborockDataUpdateCoordinator,
|
||||
RoborockDataUpdateCoordinatorA01,
|
||||
RoborockDataUpdateCoordinatorB01,
|
||||
RoborockWashingMachineUpdateCoordinator,
|
||||
RoborockWetDryVacUpdateCoordinator,
|
||||
)
|
||||
@@ -131,13 +133,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
|
||||
for coord in coordinators
|
||||
if isinstance(coord, RoborockDataUpdateCoordinatorA01)
|
||||
]
|
||||
if len(v1_coords) + len(a01_coords) == 0:
|
||||
b01_coords = [
|
||||
coord
|
||||
for coord in coordinators
|
||||
if isinstance(coord, RoborockDataUpdateCoordinatorB01)
|
||||
]
|
||||
if len(v1_coords) + len(a01_coords) + len(b01_coords) == 0:
|
||||
raise ConfigEntryNotReady(
|
||||
"No devices were able to successfully setup",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_coordinators",
|
||||
)
|
||||
entry.runtime_data = RoborockCoordinators(v1_coords, a01_coords)
|
||||
entry.runtime_data = RoborockCoordinators(v1_coords, a01_coords, b01_coords)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -208,12 +215,17 @@ def build_setup_functions(
|
||||
Coroutine[
|
||||
Any,
|
||||
Any,
|
||||
RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None,
|
||||
RoborockDataUpdateCoordinator
|
||||
| RoborockDataUpdateCoordinatorA01
|
||||
| RoborockDataUpdateCoordinatorB01
|
||||
| None,
|
||||
]
|
||||
]:
|
||||
"""Create a list of setup functions that can later be called asynchronously."""
|
||||
coordinators: list[
|
||||
RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01
|
||||
RoborockDataUpdateCoordinator
|
||||
| RoborockDataUpdateCoordinatorA01
|
||||
| RoborockDataUpdateCoordinatorB01
|
||||
] = []
|
||||
for device in devices:
|
||||
_LOGGER.debug("Creating device %s: %s", device.name, device)
|
||||
@@ -229,6 +241,12 @@ def build_setup_functions(
|
||||
coordinators.append(
|
||||
RoborockWashingMachineUpdateCoordinator(hass, entry, device, device.zeo)
|
||||
)
|
||||
elif device.b01_q7_properties is not None:
|
||||
coordinators.append(
|
||||
RoborockB01Q7UpdateCoordinator(
|
||||
hass, entry, device, device.b01_q7_properties
|
||||
)
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Not adding device %s because its protocol version %s or category %s is not supported",
|
||||
@@ -241,8 +259,15 @@ def build_setup_functions(
|
||||
|
||||
|
||||
async def setup_coordinator(
|
||||
coordinator: RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01,
|
||||
) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None:
|
||||
coordinator: RoborockDataUpdateCoordinator
|
||||
| RoborockDataUpdateCoordinatorA01
|
||||
| RoborockDataUpdateCoordinatorB01,
|
||||
) -> (
|
||||
RoborockDataUpdateCoordinator
|
||||
| RoborockDataUpdateCoordinatorA01
|
||||
| RoborockDataUpdateCoordinatorB01
|
||||
| None
|
||||
):
|
||||
"""Set up a single coordinator."""
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -8,12 +8,18 @@ import logging
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from propcache.api import cached_property
|
||||
from roborock import B01Props
|
||||
from roborock.data import HomeDataScene
|
||||
from roborock.devices.device import RoborockDevice
|
||||
from roborock.devices.traits.a01 import DyadApi, ZeoApi
|
||||
from roborock.devices.traits.b01 import Q7PropertiesApi
|
||||
from roborock.devices.traits.v1 import PropertiesApi
|
||||
from roborock.exceptions import RoborockDeviceBusy, RoborockException
|
||||
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
|
||||
from roborock.roborock_message import (
|
||||
RoborockB01Props,
|
||||
RoborockDyadDataProtocol,
|
||||
RoborockZeoProtocol,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_CONNECTIONS
|
||||
@@ -58,12 +64,17 @@ class RoborockCoordinators:
|
||||
|
||||
v1: list[RoborockDataUpdateCoordinator]
|
||||
a01: list[RoborockDataUpdateCoordinatorA01]
|
||||
b01: list[RoborockDataUpdateCoordinatorB01]
|
||||
|
||||
def values(
|
||||
self,
|
||||
) -> list[RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01]:
|
||||
) -> list[
|
||||
RoborockDataUpdateCoordinator
|
||||
| RoborockDataUpdateCoordinatorA01
|
||||
| RoborockDataUpdateCoordinatorB01
|
||||
]:
|
||||
"""Return all coordinators."""
|
||||
return self.v1 + self.a01
|
||||
return self.v1 + self.a01 + self.b01
|
||||
|
||||
|
||||
type RoborockConfigEntry = ConfigEntry[RoborockCoordinators]
|
||||
@@ -469,3 +480,91 @@ class RoborockWetDryVacUpdateCoordinator(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_data_fail",
|
||||
) from ex
|
||||
|
||||
|
||||
class RoborockDataUpdateCoordinatorB01(DataUpdateCoordinator[B01Props]):
|
||||
"""Class to manage fetching data from the API for B01 devices."""
|
||||
|
||||
config_entry: RoborockConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: RoborockConfigEntry,
|
||||
device: RoborockDevice,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=A01_UPDATE_INTERVAL,
|
||||
)
|
||||
self._device = device
|
||||
self.device_info = DeviceInfo(
|
||||
name=device.name,
|
||||
identifiers={(DOMAIN, device.duid)},
|
||||
manufacturer="Roborock",
|
||||
model=device.product.model,
|
||||
sw_version=device.device_info.fv,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def duid(self) -> str:
|
||||
"""Get the unique id of the device as specified by Roborock."""
|
||||
return self._device.duid
|
||||
|
||||
@cached_property
|
||||
def duid_slug(self) -> str:
|
||||
"""Get the slug of the duid."""
|
||||
return slugify(self.duid)
|
||||
|
||||
@property
|
||||
def device(self) -> RoborockDevice:
|
||||
"""Get the RoborockDevice."""
|
||||
return self._device
|
||||
|
||||
|
||||
class RoborockB01Q7UpdateCoordinator(RoborockDataUpdateCoordinatorB01):
|
||||
"""Coordinator for B01 Q7 devices."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: RoborockConfigEntry,
|
||||
device: RoborockDevice,
|
||||
api: Q7PropertiesApi,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(hass, config_entry, device)
|
||||
self.api = api
|
||||
self.request_protocols: list[RoborockB01Props] = [
|
||||
RoborockB01Props.STATUS,
|
||||
RoborockB01Props.MAIN_BRUSH,
|
||||
RoborockB01Props.SIDE_BRUSH,
|
||||
RoborockB01Props.DUST_BAG_USED,
|
||||
RoborockB01Props.MOP_LIFE,
|
||||
RoborockB01Props.MAIN_SENSOR,
|
||||
RoborockB01Props.CLEANING_TIME,
|
||||
RoborockB01Props.REAL_CLEAN_TIME,
|
||||
RoborockB01Props.HYPA,
|
||||
]
|
||||
|
||||
async def _async_update_data(
|
||||
self,
|
||||
) -> B01Props:
|
||||
try:
|
||||
data = await self.api.query_values(self.request_protocols)
|
||||
except RoborockException as ex:
|
||||
_LOGGER.debug("Failed to update Q7 data: %s", ex)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_data_fail",
|
||||
) from ex
|
||||
if data is None:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_data_fail",
|
||||
)
|
||||
return data
|
||||
|
||||
@@ -13,7 +13,11 @@ from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01
|
||||
from .coordinator import (
|
||||
RoborockDataUpdateCoordinator,
|
||||
RoborockDataUpdateCoordinatorA01,
|
||||
RoborockDataUpdateCoordinatorB01,
|
||||
)
|
||||
|
||||
|
||||
class RoborockEntity(Entity):
|
||||
@@ -124,3 +128,23 @@ class RoborockCoordinatedEntityA01(
|
||||
)
|
||||
CoordinatorEntity.__init__(self, coordinator=coordinator)
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
|
||||
class RoborockCoordinatedEntityB01(
|
||||
RoborockEntity, CoordinatorEntity[RoborockDataUpdateCoordinatorB01]
|
||||
):
|
||||
"""Representation of coordinated Roborock Entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unique_id: str,
|
||||
coordinator: RoborockDataUpdateCoordinatorB01,
|
||||
) -> None:
|
||||
"""Initialize the coordinated Roborock Device."""
|
||||
RoborockEntity.__init__(
|
||||
self,
|
||||
unique_id=unique_id,
|
||||
device_info=coordinator.device_info,
|
||||
)
|
||||
CoordinatorEntity.__init__(self, coordinator=coordinator)
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
@@ -8,12 +8,14 @@ import datetime
|
||||
import logging
|
||||
|
||||
from roborock.data import (
|
||||
B01Props,
|
||||
DyadError,
|
||||
RoborockDockErrorCode,
|
||||
RoborockDockTypeCode,
|
||||
RoborockDyadStateCode,
|
||||
RoborockErrorCode,
|
||||
RoborockStateCode,
|
||||
WorkStatusMapping,
|
||||
ZeoError,
|
||||
ZeoState,
|
||||
)
|
||||
@@ -34,9 +36,11 @@ from .coordinator import (
|
||||
RoborockConfigEntry,
|
||||
RoborockDataUpdateCoordinator,
|
||||
RoborockDataUpdateCoordinatorA01,
|
||||
RoborockDataUpdateCoordinatorB01,
|
||||
)
|
||||
from .entity import (
|
||||
RoborockCoordinatedEntityA01,
|
||||
RoborockCoordinatedEntityB01,
|
||||
RoborockCoordinatedEntityV1,
|
||||
RoborockEntity,
|
||||
)
|
||||
@@ -64,6 +68,13 @@ class RoborockSensorDescriptionA01(SensorEntityDescription):
|
||||
data_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class RoborockSensorDescriptionB01(SensorEntityDescription):
|
||||
"""A class that describes Roborock B01 sensors."""
|
||||
|
||||
value_fn: Callable[[B01Props], StateType]
|
||||
|
||||
|
||||
def _dock_error_value_fn(state: DeviceState) -> str | None:
|
||||
if (
|
||||
status := state.status.dock_error_status
|
||||
@@ -326,6 +337,71 @@ A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
|
||||
),
|
||||
]
|
||||
|
||||
Q7_B01_SENSOR_DESCRIPTIONS = [
|
||||
RoborockSensorDescriptionB01(
|
||||
key="q7_status",
|
||||
value_fn=lambda data: data.status_name,
|
||||
translation_key="q7_status",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=WorkStatusMapping.keys(),
|
||||
),
|
||||
RoborockSensorDescriptionB01(
|
||||
key="main_brush_time_left",
|
||||
value_fn=lambda data: data.main_brush_time_left,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="main_brush_time_left",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
RoborockSensorDescriptionB01(
|
||||
key="side_brush_time_left",
|
||||
value_fn=lambda data: data.side_brush_time_left,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="side_brush_time_left",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
RoborockSensorDescriptionB01(
|
||||
key="filter_time_left",
|
||||
value_fn=lambda data: data.filter_time_left,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="filter_time_left",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
RoborockSensorDescriptionB01(
|
||||
key="sensor_time_left",
|
||||
value_fn=lambda data: data.sensor_dirty_time_left,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="sensor_time_left",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
RoborockSensorDescriptionB01(
|
||||
key="mop_life_time_left",
|
||||
value_fn=lambda data: data.mop_life_time_left,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="mop_life_time_left",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
RoborockSensorDescriptionB01(
|
||||
key="total_cleaning_time",
|
||||
value_fn=lambda data: data.real_clean_time,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="total_cleaning_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -354,6 +430,12 @@ async def async_setup_entry(
|
||||
for description in A01_SENSOR_DESCRIPTIONS
|
||||
if description.data_protocol in coordinator.request_protocols
|
||||
)
|
||||
entities.extend(
|
||||
RoborockSensorEntityB01(coordinator, description)
|
||||
for coordinator in coordinators.b01
|
||||
for description in Q7_B01_SENSOR_DESCRIPTIONS
|
||||
if description.value_fn(coordinator.data) is not None
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -440,3 +522,23 @@ class RoborockSensorEntityA01(RoborockCoordinatedEntityA01, SensorEntity):
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value reported by the sensor."""
|
||||
return self.coordinator.data[self.entity_description.data_protocol]
|
||||
|
||||
|
||||
class RoborockSensorEntityB01(RoborockCoordinatedEntityB01, SensorEntity):
|
||||
"""Representation of a B01 Roborock sensor."""
|
||||
|
||||
entity_description: RoborockSensorDescriptionB01
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RoborockDataUpdateCoordinatorB01,
|
||||
description: RoborockSensorDescriptionB01,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.entity_description = description
|
||||
super().__init__(f"{description.key}_{coordinator.duid_slug}", coordinator)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value reported by the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
|
||||
@@ -213,6 +213,25 @@
|
||||
"mop_drying_remaining_time": {
|
||||
"name": "Mop drying remaining time"
|
||||
},
|
||||
"mop_life_time_left": {
|
||||
"name": "Mop life time left"
|
||||
},
|
||||
"q7_status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"docking": "[%key:component::roborock::entity::sensor::status::state::docking%]",
|
||||
"mop_airdrying": "Mop air drying",
|
||||
"mop_cleaning": "Mop cleaning",
|
||||
"moping": "Mopping",
|
||||
"paused": "[%key:common::state::paused%]",
|
||||
"sleeping": "Sleeping",
|
||||
"sweep_moping": "Sweep mopping",
|
||||
"sweep_moping_2": "Sweep mopping",
|
||||
"updating": "[%key:component::roborock::entity::sensor::status::state::updating%]",
|
||||
"waiting_for_orders": "Waiting for orders"
|
||||
}
|
||||
},
|
||||
"sensor_time_left": {
|
||||
"name": "Sensor time left"
|
||||
},
|
||||
|
||||
@@ -6,13 +6,5 @@
|
||||
"reload": {
|
||||
"service": "mdi:reload"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"turned_off": {
|
||||
"trigger": "mdi:calendar-blank"
|
||||
},
|
||||
"turned_on": {
|
||||
"trigger": "mdi:calendar-clock"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted schedules to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "[%key:component::schedule::title%]",
|
||||
@@ -24,15 +20,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_schedule": {
|
||||
"description": "Retrieves the configured time ranges of one or multiple schedules.",
|
||||
@@ -43,27 +30,5 @@
|
||||
"name": "[%key:common::action::reload%]"
|
||||
}
|
||||
},
|
||||
"title": "Schedule",
|
||||
"triggers": {
|
||||
"turned_off": {
|
||||
"description": "Triggers after one or more schedules turn off.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::schedule::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::schedule::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Schedule turned off"
|
||||
},
|
||||
"turned_on": {
|
||||
"description": "Triggers after one or more schedules turn on.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::schedule::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::schedule::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Schedule turned on"
|
||||
}
|
||||
}
|
||||
"title": "Schedule"
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Provides triggers for schedulers."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for schedulers."""
|
||||
return TRIGGERS
|
||||
@@ -1,18 +0,0 @@
|
||||
.trigger_common: &trigger_common
|
||||
target:
|
||||
entity:
|
||||
domain: schedule
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
translation_key: trigger_behavior
|
||||
|
||||
turned_off: *trigger_common
|
||||
turned_on: *trigger_common
|
||||
@@ -537,6 +537,12 @@
|
||||
"voltmeter_value": {
|
||||
"name": "Voltmeter value"
|
||||
},
|
||||
"voltmeter_value_with_channel_name": {
|
||||
"name": "Voltmeter value {channel_name}"
|
||||
},
|
||||
"voltmeter_with_channel_name": {
|
||||
"name": "Voltmeter {channel_name}"
|
||||
},
|
||||
"water_consumption": {
|
||||
"name": "Water consumption"
|
||||
},
|
||||
|
||||
@@ -68,7 +68,7 @@ class _SwingModeWrapper(DeviceWrapper):
|
||||
on_off: DPCodeBooleanWrapper | None = None
|
||||
horizontal: DPCodeBooleanWrapper | None = None
|
||||
vertical: DPCodeBooleanWrapper | None = None
|
||||
modes: list[str]
|
||||
options: list[str]
|
||||
|
||||
@classmethod
|
||||
def find_dpcode(cls, device: CustomerDevice) -> Self | None:
|
||||
@@ -83,18 +83,18 @@ class _SwingModeWrapper(DeviceWrapper):
|
||||
device, DPCode.SWITCH_VERTICAL, prefer_function=True
|
||||
)
|
||||
if on_off or horizontal or vertical:
|
||||
modes = [SWING_OFF]
|
||||
options = [SWING_OFF]
|
||||
if on_off:
|
||||
modes.append(SWING_ON)
|
||||
options.append(SWING_ON)
|
||||
if horizontal:
|
||||
modes.append(SWING_HORIZONTAL)
|
||||
options.append(SWING_HORIZONTAL)
|
||||
if vertical:
|
||||
modes.append(SWING_VERTICAL)
|
||||
options.append(SWING_VERTICAL)
|
||||
return cls(
|
||||
on_off=on_off,
|
||||
horizontal=horizontal,
|
||||
vertical=vertical,
|
||||
modes=modes,
|
||||
options=options,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -403,7 +403,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
# Determine swing modes
|
||||
if swing_wrapper:
|
||||
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
|
||||
self._attr_swing_modes = swing_wrapper.modes
|
||||
self._attr_swing_modes = swing_wrapper.options
|
||||
|
||||
if switch_wrapper:
|
||||
self._attr_supported_features |= (
|
||||
|
||||
@@ -31,10 +31,12 @@ from .models import (
|
||||
class _DPCodeEventWrapper(DPCodeTypeInformationWrapper):
|
||||
"""Base class for Tuya event wrappers."""
|
||||
|
||||
@property
|
||||
def event_types(self) -> list[str]:
|
||||
"""Return the event types for the DP code."""
|
||||
return ["triggered"]
|
||||
options: list[str]
|
||||
|
||||
def __init__(self, dpcode: str, type_information: Any) -> None:
|
||||
"""Init _DPCodeEventWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self.options = ["triggered"]
|
||||
|
||||
def get_event_type(
|
||||
self, device: CustomerDevice, updated_status_properties: list[str] | None
|
||||
@@ -55,11 +57,6 @@ class _DPCodeEventWrapper(DPCodeTypeInformationWrapper):
|
||||
class _EventEnumWrapper(DPCodeEnumWrapper, _DPCodeEventWrapper):
|
||||
"""Wrapper for event enum DP codes."""
|
||||
|
||||
@property
|
||||
def event_types(self) -> list[str]:
|
||||
"""Return the event types for the enum."""
|
||||
return self.options
|
||||
|
||||
def get_event_type(
|
||||
self, device: CustomerDevice, updated_status_properties: list[str] | None
|
||||
) -> str | None:
|
||||
@@ -232,7 +229,7 @@ class TuyaEventEntity(TuyaEntity, EventEntity):
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._dpcode_wrapper = dpcode_wrapper
|
||||
self._attr_event_types = dpcode_wrapper.event_types
|
||||
self._attr_event_types = dpcode_wrapper.options
|
||||
|
||||
async def _handle_state_update(
|
||||
self,
|
||||
|
||||
@@ -128,9 +128,9 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
self._switch_wrapper = switch_wrapper
|
||||
|
||||
self._attr_fan_speed_list = []
|
||||
self._attr_supported_features = (
|
||||
VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE
|
||||
)
|
||||
self._attr_supported_features = VacuumEntityFeature.SEND_COMMAND
|
||||
if status_wrapper or pause_wrapper:
|
||||
self._attr_supported_features |= VacuumEntityFeature.STATE
|
||||
if pause_wrapper:
|
||||
self._attr_supported_features |= VacuumEntityFeature.PAUSE
|
||||
|
||||
|
||||
@@ -453,6 +453,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_audio_cmonx",
|
||||
translation_key="co_alarm_detected",
|
||||
device_class=BinarySensorDeviceClass.CO,
|
||||
ufp_required_field="can_detect_co",
|
||||
ufp_enabled="is_co_detection_on",
|
||||
ufp_event_obj="last_cmonx_detect_event",
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from uiprotect.data import ModelType, ProtectAdoptableDeviceModel
|
||||
|
||||
@@ -45,9 +45,6 @@ class ProtectButtonEntityDescription(
|
||||
ufp_press: str | None = None
|
||||
|
||||
|
||||
DEVICE_CLASS_CHIME_BUTTON: Final = "unifiprotect__chime_button"
|
||||
|
||||
|
||||
ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = (
|
||||
ProtectButtonEntityDescription(
|
||||
key="reboot",
|
||||
@@ -84,7 +81,6 @@ CHIME_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = (
|
||||
ProtectButtonEntityDescription(
|
||||
key="play",
|
||||
translation_key="play_chime",
|
||||
device_class=DEVICE_CLASS_CHIME_BUTTON,
|
||||
ufp_press="play",
|
||||
),
|
||||
ProtectButtonEntityDescription(
|
||||
|
||||
@@ -83,7 +83,7 @@ def _async_device_entities(
|
||||
_LOGGER.debug(
|
||||
"Adding %s entity %s for %s",
|
||||
klass.__name__,
|
||||
description.name,
|
||||
description.key,
|
||||
device.display_name,
|
||||
)
|
||||
continue
|
||||
@@ -111,7 +111,7 @@ def _async_device_entities(
|
||||
_LOGGER.debug(
|
||||
"Adding %s entity %s for %s",
|
||||
klass.__name__,
|
||||
description.name,
|
||||
description.key,
|
||||
device.display_name,
|
||||
)
|
||||
|
||||
@@ -252,16 +252,11 @@ class BaseProtectEntity(Entity):
|
||||
|
||||
if changed:
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
device_name = device.name or ""
|
||||
if hasattr(self, "entity_description") and self.entity_description.name:
|
||||
device_name += f" {self.entity_description.name}"
|
||||
|
||||
_LOGGER.debug(
|
||||
"Updating state [%s (%s)] %s -> %s",
|
||||
device_name,
|
||||
device.mac,
|
||||
"Updating state [%s] %s -> %s",
|
||||
self.entity_id,
|
||||
previous_attrs,
|
||||
tuple((getattr(self, attr)) for attr in self._state_attrs),
|
||||
tuple(getter() for getter in self._state_getters),
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import logging
|
||||
from typing import Any, Final
|
||||
from typing import Any
|
||||
|
||||
from uiprotect.api import ProtectApiClient
|
||||
from uiprotect.data import (
|
||||
@@ -102,8 +102,6 @@ DEVICE_RECORDING_MODES = [
|
||||
{"id": mode.value, "name": mode.value.title()} for mode in list(RecordingMode)
|
||||
]
|
||||
|
||||
DEVICE_CLASS_LCD_MESSAGE: Final = "unifiprotect__lcd_message"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ProtectSelectEntityDescription(
|
||||
@@ -217,7 +215,6 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = (
|
||||
key="doorbell_text",
|
||||
translation_key="doorbell_text",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=DEVICE_CLASS_LCD_MESSAGE,
|
||||
ufp_required_field="feature_flags.has_lcd_screen",
|
||||
ufp_value_fn=_get_doorbell_current,
|
||||
ufp_options_fn=_get_doorbell_options,
|
||||
@@ -377,9 +374,7 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity):
|
||||
entity_description.entity_category is not None
|
||||
and entity_description.ufp_options_fn is not None
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Updating dynamic select options for %s", entity_description.name
|
||||
)
|
||||
_LOGGER.debug("Updating dynamic select options for %s", self.entity_id)
|
||||
self._async_set_options(self.data, entity_description)
|
||||
if (unifi_value := entity_description.get_ufp_value(device)) is None:
|
||||
unifi_value = TYPE_EMPTY_VALUE
|
||||
|
||||
@@ -29,8 +29,6 @@ set_chime_paired_doorbells:
|
||||
selector:
|
||||
device:
|
||||
integration: unifiprotect
|
||||
entity:
|
||||
device_class: unifiprotect__chime_button
|
||||
doorbells:
|
||||
example: "binary_sensor.front_doorbell_doorbell"
|
||||
required: false
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
|
||||
from .const import DOMAIN, LOGGER, PLATFORMS
|
||||
@@ -25,14 +26,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
pyvlx = PyVLX(host=host, password=password)
|
||||
|
||||
LOGGER.debug("Velux interface started")
|
||||
LOGGER.debug("Setting up Velux gateway %s", host)
|
||||
try:
|
||||
LOGGER.debug("Retrieving scenes from %s", host)
|
||||
await pyvlx.load_scenes()
|
||||
LOGGER.debug("Retrieving nodes from %s", host)
|
||||
await pyvlx.load_nodes()
|
||||
except PyVLXException as ex:
|
||||
LOGGER.exception("Can't connect to velux interface: %s", ex)
|
||||
return False
|
||||
except (OSError, PyVLXException) as ex:
|
||||
# Defer setup and retry later as the bridge is not ready/available
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to Velux gateway at {host}. "
|
||||
"If connection continues to fail, try power-cycling the gateway device."
|
||||
) from ex
|
||||
|
||||
LOGGER.debug("Velux connection to %s successful", host)
|
||||
entry.runtime_data = pyvlx
|
||||
|
||||
connections = None
|
||||
|
||||
@@ -20,9 +20,7 @@ rules:
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: todo
|
||||
comment: needs rework, failure to setup currently only returns false
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.components.webhook import (
|
||||
)
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import AUTO_SHUT_OFF_EVENT_NAME, DOMAIN
|
||||
@@ -50,7 +51,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) ->
|
||||
)
|
||||
|
||||
watergate_client = WatergateLocalApiClient(
|
||||
sonic_address if sonic_address.startswith("http") else f"http://{sonic_address}"
|
||||
base_url=(
|
||||
sonic_address
|
||||
if sonic_address.startswith("http")
|
||||
else f"http://{sonic_address}"
|
||||
),
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
coordinator = WatergateDataCoordinator(hass, entry, watergate_client)
|
||||
|
||||
@@ -11,6 +11,7 @@ from watergate_local_api.watergate_api import (
|
||||
from homeassistant.components.webhook import async_generate_id as webhook_generate_id
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -34,7 +35,8 @@ class WatergateConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
watergate_client = WatergateLocalApiClient(
|
||||
self.prepare_ip_address(user_input[CONF_IP_ADDRESS])
|
||||
base_url=self.prepare_ip_address(user_input[CONF_IP_ADDRESS]),
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
try:
|
||||
state = await watergate_client.async_get_device_state()
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"dependencies": ["http", "webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/watergate",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["watergate-local-api==2025.1.0"]
|
||||
}
|
||||
|
||||
@@ -27,9 +27,12 @@ rules:
|
||||
|
||||
# Silver
|
||||
config-entry-unloading: done
|
||||
log-when-unavailable: todo
|
||||
log-when-unavailable: done
|
||||
entity-unavailable: done
|
||||
action-exceptions: done
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
@@ -37,5 +40,36 @@ rules:
|
||||
parallel-updates: done
|
||||
test-coverage: done
|
||||
integration-owner: done
|
||||
docs-installation-parameters: todo
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any configuration parameters.
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
|
||||
@@ -3344,7 +3344,7 @@
|
||||
},
|
||||
"kmtronic": {
|
||||
"name": "KMtronic",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
@@ -3363,7 +3363,7 @@
|
||||
},
|
||||
"kodi": {
|
||||
"name": "Kodi",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
@@ -3386,13 +3386,13 @@
|
||||
},
|
||||
"kostal_plenticore": {
|
||||
"name": "Kostal Plenticore Solar Inverter",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"kraken": {
|
||||
"name": "Kraken",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
@@ -3403,7 +3403,7 @@
|
||||
},
|
||||
"kulersky": {
|
||||
"name": "Kuler Sky",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
@@ -3439,7 +3439,7 @@
|
||||
},
|
||||
"landisgyr_heat_meter": {
|
||||
"name": "Landis+Gyr Heat Meter",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
@@ -3451,7 +3451,7 @@
|
||||
},
|
||||
"lastfm": {
|
||||
"name": "Last.fm",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
@@ -3482,13 +3482,13 @@
|
||||
},
|
||||
"leaone": {
|
||||
"name": "LeaOne",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"led_ble": {
|
||||
"name": "LED BLE",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
@@ -3536,7 +3536,7 @@
|
||||
"name": "LG Netcast"
|
||||
},
|
||||
"lg_soundbar": {
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling",
|
||||
"name": "LG Soundbars"
|
||||
@@ -3569,7 +3569,7 @@
|
||||
},
|
||||
"lifx": {
|
||||
"name": "LIFX",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
@@ -3727,7 +3727,7 @@
|
||||
},
|
||||
"loqed": {
|
||||
"name": "LOQED Touch Smart Lock",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
@@ -3796,7 +3796,7 @@
|
||||
},
|
||||
"mailgun": {
|
||||
"name": "Mailgun",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_push"
|
||||
},
|
||||
@@ -3866,7 +3866,7 @@
|
||||
},
|
||||
"medcom_ble": {
|
||||
"name": "Medcom Bluetooth",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
@@ -3899,7 +3899,7 @@
|
||||
"name": "Melnor",
|
||||
"integrations": {
|
||||
"melnor": {
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling",
|
||||
"name": "Melnor Bluetooth"
|
||||
|
||||
9
requirements_all.txt
generated
9
requirements_all.txt
generated
@@ -252,7 +252,7 @@ aioelectricitymaps==1.1.1
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==43.0.0
|
||||
aioesphomeapi==43.3.0
|
||||
|
||||
# homeassistant.components.matrix
|
||||
# homeassistant.components.slack
|
||||
@@ -1918,9 +1918,6 @@ pybalboa==1.1.3
|
||||
# homeassistant.components.bbox
|
||||
pybbox==0.0.5-alpha
|
||||
|
||||
# homeassistant.components.blackbird
|
||||
pyblackbird==0.6
|
||||
|
||||
# homeassistant.components.bluesound
|
||||
pyblu==2.0.5
|
||||
|
||||
@@ -2300,7 +2297,7 @@ pypaperless==4.1.1
|
||||
pypca==0.0.7
|
||||
|
||||
# homeassistant.components.lcn
|
||||
pypck==0.9.7
|
||||
pypck==0.9.8
|
||||
|
||||
# homeassistant.components.pglab
|
||||
pypglab==0.0.5
|
||||
@@ -2514,7 +2511,7 @@ python-google-weather-api==0.0.4
|
||||
python-homeassistant-analytics==0.9.0
|
||||
|
||||
# homeassistant.components.homewizard
|
||||
python-homewizard-energy==9.3.0
|
||||
python-homewizard-energy==10.0.0
|
||||
|
||||
# homeassistant.components.hp_ilo
|
||||
python-hpilo==4.4.3
|
||||
|
||||
9
requirements_test_all.txt
generated
9
requirements_test_all.txt
generated
@@ -243,7 +243,7 @@ aioelectricitymaps==1.1.1
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==43.0.0
|
||||
aioesphomeapi==43.3.0
|
||||
|
||||
# homeassistant.components.matrix
|
||||
# homeassistant.components.slack
|
||||
@@ -1637,9 +1637,6 @@ pyaussiebb==0.1.5
|
||||
# homeassistant.components.balboa
|
||||
pybalboa==1.1.3
|
||||
|
||||
# homeassistant.components.blackbird
|
||||
pyblackbird==0.6
|
||||
|
||||
# homeassistant.components.bluesound
|
||||
pyblu==2.0.5
|
||||
|
||||
@@ -1941,7 +1938,7 @@ pypalazzetti==0.1.20
|
||||
pypaperless==4.1.1
|
||||
|
||||
# homeassistant.components.lcn
|
||||
pypck==0.9.7
|
||||
pypck==0.9.8
|
||||
|
||||
# homeassistant.components.pglab
|
||||
pypglab==0.0.5
|
||||
@@ -2107,7 +2104,7 @@ python-google-weather-api==0.0.4
|
||||
python-homeassistant-analytics==0.9.0
|
||||
|
||||
# homeassistant.components.homewizard
|
||||
python-homewizard-energy==9.3.0
|
||||
python-homewizard-energy==10.0.0
|
||||
|
||||
# homeassistant.components.izone
|
||||
python-izone==1.2.9
|
||||
|
||||
@@ -12,7 +12,7 @@ from .model import Config, Integration
|
||||
from .requirements import PACKAGE_REGEX, PIP_VERSION_RANGE_SEPARATOR
|
||||
|
||||
_GO2RTC_SHA = (
|
||||
"baef0aa19d759fcfd31607b34ce8eaf039d496282bba57731e6ae326896d7640" # 1.9.12
|
||||
"f394f6329f5389a4c9a7fc54b09fdec9621bbb78bf7a672b973440bbdfb02241" # 1.9.13
|
||||
)
|
||||
|
||||
DOCKERFILE_TEMPLATE = r"""# Automatically generated by hassfest.
|
||||
|
||||
3
tests/components/blackbird/conftest.py
Normal file
3
tests/components/blackbird/conftest.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Fixtures for component."""
|
||||
|
||||
collect_ignore_glob = ["test_*.py"]
|
||||
@@ -25,6 +25,7 @@ async def setup_config_entry(
|
||||
device: Mock | None = None,
|
||||
fritz: Mock | None = None,
|
||||
template: Mock | None = None,
|
||||
trigger: Mock | None = None,
|
||||
) -> MockConfigEntry:
|
||||
"""Do setup of a MockConfigEntry."""
|
||||
entry = MockConfigEntry(
|
||||
@@ -39,6 +40,9 @@ async def setup_config_entry(
|
||||
if template is not None and fritz is not None:
|
||||
fritz().get_templates.return_value = [template]
|
||||
|
||||
if trigger is not None and fritz is not None:
|
||||
fritz().get_triggers.return_value = [trigger]
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
if device is not None:
|
||||
await hass.async_block_till_done()
|
||||
@@ -46,7 +50,10 @@ async def setup_config_entry(
|
||||
|
||||
|
||||
def set_devices(
|
||||
fritz: Mock, devices: list[Mock] | None = None, templates: list[Mock] | None = None
|
||||
fritz: Mock,
|
||||
devices: list[Mock] | None = None,
|
||||
templates: list[Mock] | None = None,
|
||||
triggers: list[Mock] | None = None,
|
||||
) -> None:
|
||||
"""Set list of devices or templates."""
|
||||
if devices is not None:
|
||||
@@ -55,6 +62,9 @@ def set_devices(
|
||||
if templates is not None:
|
||||
fritz().get_templates.return_value = templates
|
||||
|
||||
if triggers is not None:
|
||||
fritz().get_triggers.return_value = triggers
|
||||
|
||||
|
||||
class FritzEntityBaseMock(Mock):
|
||||
"""base mock of a AVM Fritz!Box binary sensor device."""
|
||||
@@ -199,3 +209,11 @@ class FritzDeviceCoverUnknownPositionMock(FritzDeviceCoverMock):
|
||||
"""Mock of a AVM Fritz!Box cover device with unknown position."""
|
||||
|
||||
levelpercentage = None
|
||||
|
||||
|
||||
class FritzTriggerMock(FritzEntityBaseMock):
|
||||
"""Mock of a AVM Fritz!Box smarthome trigger."""
|
||||
|
||||
active = True
|
||||
ain = "trg1234 56789"
|
||||
name = "fake_trigger"
|
||||
|
||||
@@ -47,3 +47,51 @@
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_setup[switch.fake_trigger-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': None,
|
||||
'entity_id': 'switch.fake_trigger',
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'fake_trigger',
|
||||
'platform': 'fritzbox',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'trg1234 56789',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_setup[switch.fake_trigger-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'fake_trigger',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.fake_trigger',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -23,12 +23,13 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import FritzDeviceSwitchMock, set_devices, setup_config_entry
|
||||
from . import FritzDeviceSwitchMock, FritzTriggerMock, set_devices, setup_config_entry
|
||||
from .const import CONF_FAKE_NAME, MOCK_CONFIG
|
||||
|
||||
from tests.common import async_fire_time_changed, snapshot_platform
|
||||
|
||||
ENTITY_ID = f"{SWITCH_DOMAIN}.{CONF_FAKE_NAME}"
|
||||
SWITCH_ENTITY_ID = f"{SWITCH_DOMAIN}.{CONF_FAKE_NAME}"
|
||||
TRIGGER_ENTITY_ID = f"{SWITCH_DOMAIN}.fake_trigger"
|
||||
|
||||
|
||||
async def test_setup(
|
||||
@@ -39,50 +40,56 @@ async def test_setup(
|
||||
) -> None:
|
||||
"""Test setup of platform."""
|
||||
device = FritzDeviceSwitchMock()
|
||||
trigger = FritzTriggerMock()
|
||||
|
||||
with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.SWITCH]):
|
||||
entry = await setup_config_entry(
|
||||
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
|
||||
hass,
|
||||
MOCK_CONFIG[DOMAIN][CONF_DEVICES][0],
|
||||
device=device,
|
||||
fritz=fritz,
|
||||
trigger=trigger,
|
||||
)
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
|
||||
|
||||
|
||||
async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
"""Test turn device on."""
|
||||
async def test_switch_turn_on(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
"""Test turn switch device on."""
|
||||
device = FritzDeviceSwitchMock()
|
||||
await setup_config_entry(
|
||||
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
|
||||
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], device=device, fritz=fritz
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True
|
||||
SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, True
|
||||
)
|
||||
assert device.set_switch_state_on.call_count == 1
|
||||
|
||||
|
||||
async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
"""Test turn device off."""
|
||||
async def test_switch_turn_off(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
"""Test turn switch device off."""
|
||||
device = FritzDeviceSwitchMock()
|
||||
|
||||
await setup_config_entry(
|
||||
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
|
||||
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], device=device, fritz=fritz
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True
|
||||
SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, True
|
||||
)
|
||||
|
||||
assert device.set_switch_state_off.call_count == 1
|
||||
|
||||
|
||||
async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
"""Test toggling while device is locked."""
|
||||
async def test_switch_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
"""Test toggling while switch device is locked."""
|
||||
device = FritzDeviceSwitchMock()
|
||||
device.lock = True
|
||||
|
||||
await setup_config_entry(
|
||||
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
|
||||
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], device=device, fritz=fritz
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
@@ -90,7 +97,7 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
match="Can't toggle switch while manual switching is disabled for the device",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True
|
||||
SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, True
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
@@ -98,17 +105,23 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
match="Can't toggle switch while manual switching is disabled for the device",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True
|
||||
SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, True
|
||||
)
|
||||
|
||||
|
||||
async def test_update(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
"""Test update without error."""
|
||||
device = FritzDeviceSwitchMock()
|
||||
trigger = FritzTriggerMock()
|
||||
await setup_config_entry(
|
||||
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
|
||||
hass,
|
||||
MOCK_CONFIG[DOMAIN][CONF_DEVICES][0],
|
||||
device=device,
|
||||
fritz=fritz,
|
||||
trigger=trigger,
|
||||
)
|
||||
assert fritz().update_devices.call_count == 1
|
||||
assert fritz().update_triggers.call_count == 1
|
||||
assert fritz().login.call_count == 1
|
||||
|
||||
next_update = dt_util.utcnow() + timedelta(seconds=200)
|
||||
@@ -116,6 +129,7 @@ async def test_update(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert fritz().update_devices.call_count == 2
|
||||
assert fritz().update_triggers.call_count == 2
|
||||
assert fritz().login.call_count == 1
|
||||
|
||||
|
||||
@@ -124,7 +138,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
device = FritzDeviceSwitchMock()
|
||||
fritz().update_devices.side_effect = HTTPError("Boom")
|
||||
entry = await setup_config_entry(
|
||||
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
|
||||
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], device=device, fritz=fritz
|
||||
)
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
assert fritz().update_devices.call_count == 2
|
||||
@@ -145,10 +159,10 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No
|
||||
device.energy = 0
|
||||
device.power = 0
|
||||
await setup_config_entry(
|
||||
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
|
||||
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], device=device, fritz=fritz
|
||||
)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
state = hass.states.get(SWITCH_ENTITY_ID)
|
||||
assert state
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
@@ -156,13 +170,19 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No
|
||||
async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
"""Test adding new discovered devices during runtime."""
|
||||
device = FritzDeviceSwitchMock()
|
||||
trigger = FritzTriggerMock()
|
||||
await setup_config_entry(
|
||||
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
|
||||
hass,
|
||||
MOCK_CONFIG[DOMAIN][CONF_DEVICES][0],
|
||||
device=device,
|
||||
fritz=fritz,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state
|
||||
assert hass.states.get(SWITCH_ENTITY_ID)
|
||||
assert hass.states.get(TRIGGER_ENTITY_ID)
|
||||
|
||||
# add new switch device
|
||||
new_device = FritzDeviceSwitchMock()
|
||||
new_device.ain = "7890 1234"
|
||||
new_device.name = "new_switch"
|
||||
@@ -172,5 +192,48 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
async_fire_time_changed(hass, next_update)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
state = hass.states.get(f"{SWITCH_DOMAIN}.new_switch")
|
||||
assert state
|
||||
assert hass.states.get(f"{SWITCH_DOMAIN}.new_switch")
|
||||
|
||||
# add new trigger
|
||||
new_trigger = FritzTriggerMock()
|
||||
new_trigger.ain = "trg7890 1234"
|
||||
new_trigger.name = "new_trigger"
|
||||
set_devices(fritz, triggers=[trigger, new_trigger])
|
||||
|
||||
next_update = dt_util.utcnow() + timedelta(seconds=200)
|
||||
async_fire_time_changed(hass, next_update)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert hass.states.get(f"{SWITCH_DOMAIN}.new_trigger")
|
||||
|
||||
|
||||
async def test_activate_trigger(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
"""Test activating a FRITZ! trigger."""
|
||||
trigger = FritzTriggerMock()
|
||||
await setup_config_entry(
|
||||
hass,
|
||||
MOCK_CONFIG[DOMAIN][CONF_DEVICES][0],
|
||||
fritz=fritz,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: TRIGGER_ENTITY_ID}, True
|
||||
)
|
||||
assert fritz().set_trigger_active.call_count == 1
|
||||
|
||||
|
||||
async def test_deactivate_trigger(hass: HomeAssistant, fritz: Mock) -> None:
|
||||
"""Test deactivating a FRITZ! trigger."""
|
||||
trigger = FritzTriggerMock()
|
||||
await setup_config_entry(
|
||||
hass,
|
||||
MOCK_CONFIG[DOMAIN][CONF_DEVICES][0],
|
||||
fritz=fritz,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: TRIGGER_ENTITY_ID}, True
|
||||
)
|
||||
assert fritz().set_trigger_inactive.call_count == 1
|
||||
|
||||
@@ -129,13 +129,15 @@ def config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"stream_source": "http://janebloggs:letmein2@example.com/stream",
|
||||
"username": "johnbloggs",
|
||||
"password": "letmein123",
|
||||
"limit_refetch_to_url_change": False,
|
||||
"authentication": "basic",
|
||||
"framerate": 2.0,
|
||||
"verify_ssl": True,
|
||||
"content_type": "image/jpeg",
|
||||
"advanced": {
|
||||
"framerate": 2.0,
|
||||
"verify_ssl": True,
|
||||
"limit_refetch_to_url_change": False,
|
||||
"authentication": "basic",
|
||||
},
|
||||
},
|
||||
version=1,
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
import contextlib
|
||||
from copy import deepcopy
|
||||
import errno
|
||||
from http import HTTPStatus
|
||||
import os.path
|
||||
@@ -24,6 +25,7 @@ from homeassistant.components.generic.const import (
|
||||
CONF_STILL_IMAGE_URL,
|
||||
CONF_STREAM_SOURCE,
|
||||
DOMAIN,
|
||||
SECTION_ADVANCED,
|
||||
)
|
||||
from homeassistant.components.stream import (
|
||||
CONF_RTSP_TRANSPORT,
|
||||
@@ -48,11 +50,13 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||
TESTDATA = {
|
||||
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
||||
CONF_STREAM_SOURCE: "http://127.0.0.1/testurl/2",
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_USERNAME: "fred_flintstone",
|
||||
CONF_PASSWORD: "bambam",
|
||||
CONF_FRAMERATE: 5,
|
||||
CONF_VERIFY_SSL: False,
|
||||
SECTION_ADVANCED: {
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_FRAMERATE: 5,
|
||||
CONF_VERIFY_SSL: False,
|
||||
},
|
||||
}
|
||||
|
||||
TESTDATA_ONLYSTILL = TESTDATA.copy()
|
||||
@@ -61,11 +65,6 @@ TESTDATA_ONLYSTILL.pop(CONF_STREAM_SOURCE)
|
||||
TESTDATA_ONLYSTREAM = TESTDATA.copy()
|
||||
TESTDATA_ONLYSTREAM.pop(CONF_STILL_IMAGE_URL)
|
||||
|
||||
TESTDATA_OPTIONS = {
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
||||
**TESTDATA,
|
||||
}
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.usefixtures("fakeimg_png")
|
||||
@@ -114,12 +113,14 @@ async def test_form(
|
||||
assert result2["options"] == {
|
||||
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
||||
CONF_STREAM_SOURCE: "http://127.0.0.1/testurl/2",
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_USERNAME: "fred_flintstone",
|
||||
CONF_PASSWORD: "bambam",
|
||||
CONF_CONTENT_TYPE: "image/png",
|
||||
CONF_FRAMERATE: 5.0,
|
||||
CONF_VERIFY_SSL: False,
|
||||
SECTION_ADVANCED: {
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_FRAMERATE: 5.0,
|
||||
CONF_VERIFY_SSL: False,
|
||||
},
|
||||
}
|
||||
|
||||
# Check that the preview image is disabled after.
|
||||
@@ -150,12 +151,14 @@ async def test_form_only_stillimage(
|
||||
assert result2["title"] == "127_0_0_1"
|
||||
assert result2["options"] == {
|
||||
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_USERNAME: "fred_flintstone",
|
||||
CONF_PASSWORD: "bambam",
|
||||
CONF_CONTENT_TYPE: "image/png",
|
||||
CONF_FRAMERATE: 5.0,
|
||||
CONF_VERIFY_SSL: False,
|
||||
SECTION_ADVANCED: {
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_FRAMERATE: 5.0,
|
||||
CONF_VERIFY_SSL: False,
|
||||
},
|
||||
}
|
||||
|
||||
assert respx.calls.call_count == 1
|
||||
@@ -376,8 +379,8 @@ async def test_form_rtsp_mode(
|
||||
mock_setup_entry: _patch[MagicMock],
|
||||
) -> None:
|
||||
"""Test we complete ok if the user enters a stream url."""
|
||||
data = TESTDATA.copy()
|
||||
data[CONF_RTSP_TRANSPORT] = "tcp"
|
||||
data = deepcopy(TESTDATA)
|
||||
data[SECTION_ADVANCED][CONF_RTSP_TRANSPORT] = "tcp"
|
||||
data[CONF_STREAM_SOURCE] = "rtsp://127.0.0.1/testurl/2"
|
||||
result1 = await hass.config_entries.flow.async_configure(user_flow["flow_id"], data)
|
||||
assert result1["type"] is FlowResultType.FORM
|
||||
@@ -390,14 +393,16 @@ async def test_form_rtsp_mode(
|
||||
assert result2["title"] == "127_0_0_1"
|
||||
assert result2["options"] == {
|
||||
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_STREAM_SOURCE: "rtsp://127.0.0.1/testurl/2",
|
||||
CONF_RTSP_TRANSPORT: "tcp",
|
||||
CONF_USERNAME: "fred_flintstone",
|
||||
CONF_PASSWORD: "bambam",
|
||||
CONF_CONTENT_TYPE: "image/png",
|
||||
CONF_FRAMERATE: 5.0,
|
||||
CONF_VERIFY_SSL: False,
|
||||
SECTION_ADVANCED: {
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_FRAMERATE: 5.0,
|
||||
CONF_VERIFY_SSL: False,
|
||||
CONF_RTSP_TRANSPORT: "tcp",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -423,13 +428,15 @@ async def test_form_only_stream(
|
||||
|
||||
assert result2["title"] == "127_0_0_1"
|
||||
assert result2["options"] == {
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_STREAM_SOURCE: "rtsp://user:pass@127.0.0.1/testurl/2",
|
||||
CONF_USERNAME: "fred_flintstone",
|
||||
CONF_PASSWORD: "bambam",
|
||||
CONF_CONTENT_TYPE: "image/jpeg",
|
||||
CONF_FRAMERATE: 5.0,
|
||||
CONF_VERIFY_SSL: False,
|
||||
SECTION_ADVANCED: {
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_FRAMERATE: 5.0,
|
||||
CONF_VERIFY_SSL: False,
|
||||
},
|
||||
}
|
||||
|
||||
with patch(
|
||||
@@ -447,9 +454,11 @@ async def test_form_still_and_stream_not_provided(
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
user_flow["flow_id"],
|
||||
{
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_FRAMERATE: 5,
|
||||
CONF_VERIFY_SSL: False,
|
||||
SECTION_ADVANCED: {
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_FRAMERATE: 5,
|
||||
CONF_VERIFY_SSL: False,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
@@ -887,8 +896,17 @@ async def test_migrate_existing_ids(
|
||||
hass: HomeAssistant, entity_registry: er.EntityRegistry
|
||||
) -> None:
|
||||
"""Test that existing ids are migrated for issue #70568."""
|
||||
test_data = {
|
||||
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
||||
CONF_STREAM_SOURCE: "http://127.0.0.1/testurl/2",
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_USERNAME: "fred_flintstone",
|
||||
CONF_PASSWORD: "bambam",
|
||||
CONF_FRAMERATE: 5,
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
||||
CONF_VERIFY_SSL: False,
|
||||
}
|
||||
|
||||
test_data = TESTDATA_OPTIONS.copy()
|
||||
test_data[CONF_CONTENT_TYPE] = "image/png"
|
||||
old_unique_id = "54321"
|
||||
entity_id = "camera.sample_camera"
|
||||
@@ -934,9 +952,12 @@ async def test_options_use_wallclock_as_timestamps(
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
data = deepcopy(TESTDATA)
|
||||
data[SECTION_ADVANCED][CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True
|
||||
result2 = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA},
|
||||
user_input=data,
|
||||
)
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
|
||||
@@ -966,7 +987,7 @@ async def test_options_use_wallclock_as_timestamps(
|
||||
assert result3["step_id"] == "init"
|
||||
result4 = await hass.config_entries.options.async_configure(
|
||||
result3["flow_id"],
|
||||
user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA},
|
||||
user_input=data,
|
||||
)
|
||||
assert result4["type"] is FlowResultType.FORM
|
||||
assert result4["step_id"] == "user_confirm"
|
||||
|
||||
@@ -26,11 +26,13 @@ async def test_entry_diagnostics(
|
||||
"stream_source": "http://****:****@example.com/****",
|
||||
"username": REDACTED,
|
||||
"password": REDACTED,
|
||||
"limit_refetch_to_url_change": False,
|
||||
"authentication": "basic",
|
||||
"framerate": 2.0,
|
||||
"verify_ssl": True,
|
||||
"content_type": "image/jpeg",
|
||||
"advanced": {
|
||||
"limit_refetch_to_url_change": False,
|
||||
"authentication": "basic",
|
||||
"framerate": 2.0,
|
||||
"verify_ssl": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,23 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.generic.const import (
|
||||
CONF_CONTENT_TYPE,
|
||||
CONF_FRAMERATE,
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
|
||||
CONF_STILL_IMAGE_URL,
|
||||
CONF_STREAM_SOURCE,
|
||||
DOMAIN,
|
||||
SECTION_ADVANCED,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
CONF_AUTHENTICATION,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
HTTP_BASIC_AUTHENTICATION,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
@@ -35,3 +51,44 @@ async def test_reload_on_title_change(
|
||||
assert (
|
||||
hass.states.get("camera.test_camera").attributes["friendly_name"] == "New Title"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("fakeimg_png")
|
||||
async def test_migration_to_version_2(hass: HomeAssistant) -> None:
|
||||
"""Test the File sensor with JSON entries."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Test Camera",
|
||||
unique_id="abc123",
|
||||
data={},
|
||||
options={
|
||||
CONF_STILL_IMAGE_URL: "http://joebloggs:letmein1@example.com/secret1/file.jpg?pw=qwerty",
|
||||
CONF_STREAM_SOURCE: "http://janebloggs:letmein2@example.com/stream",
|
||||
CONF_USERNAME: "johnbloggs",
|
||||
CONF_PASSWORD: "letmein123",
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_FRAMERATE: 2.0,
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_CONTENT_TYPE: "image/jpeg",
|
||||
},
|
||||
version=1,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.version == 2
|
||||
assert entry.options == {
|
||||
CONF_STILL_IMAGE_URL: "http://joebloggs:letmein1@example.com/secret1/file.jpg?pw=qwerty",
|
||||
CONF_STREAM_SOURCE: "http://janebloggs:letmein2@example.com/stream",
|
||||
CONF_USERNAME: "johnbloggs",
|
||||
CONF_PASSWORD: "letmein123",
|
||||
CONF_CONTENT_TYPE: "image/jpeg",
|
||||
SECTION_ADVANCED: {
|
||||
CONF_FRAMERATE: 2.0,
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"mode": "zero",
|
||||
"permissions": ["charge_allowed", "discharge_allowed"],
|
||||
"battery_count": 2,
|
||||
"power_w": -404,
|
||||
"target_power_w": -400,
|
||||
"max_consumption_w": 1600,
|
||||
|
||||
@@ -279,9 +279,14 @@
|
||||
dict({
|
||||
'data': dict({
|
||||
'batteries': dict({
|
||||
'battery_count': 2,
|
||||
'max_consumption_w': 1600.0,
|
||||
'max_production_w': 800.0,
|
||||
'mode': 'zero',
|
||||
'permissions': list([
|
||||
'charge_allowed',
|
||||
'discharge_allowed',
|
||||
]),
|
||||
'power_w': -404.0,
|
||||
'target_power_w': -400.0,
|
||||
}),
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Device Battery group mode',
|
||||
'options': list([
|
||||
<Mode.ZERO: 'zero'>,
|
||||
<Mode.STANDBY: 'standby'>,
|
||||
<Mode.TO_FULL: 'to_full'>,
|
||||
'standby',
|
||||
'to_full',
|
||||
'zero',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -24,9 +24,9 @@
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
<Mode.ZERO: 'zero'>,
|
||||
<Mode.STANDBY: 'standby'>,
|
||||
<Mode.TO_FULL: 'to_full'>,
|
||||
'standby',
|
||||
'to_full',
|
||||
'zero',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user