Compare commits

..

60 Commits

Author SHA1 Message Date
farmio
2f15f5b4ec Move DPT translations into config_panel root key 2025-12-18 15:57:28 +01:00
farmio
5628a13e7d Add missing DPT sensor device classes 2025-12-18 15:57:28 +01:00
farmio
dce0ff0973 Add DPT name translations 2025-12-18 15:57:28 +01:00
farmio
09c9a51dcb add dpt selector translations 2025-12-18 15:57:28 +01:00
farmio
d661aa129a validate device_class, state_class and unit 2025-12-18 15:57:28 +01:00
farmio
27570233f9 sort in BE for test consistency 2025-12-18 15:57:28 +01:00
farmio
2632b6682b unit_o_m, device_class translations 2025-12-18 15:57:28 +01:00
Matthias Alphart
69a3dd8246 Update strings.json
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-12-18 15:57:27 +01:00
farmio
8a2dde73dd add tests 2025-12-18 15:57:27 +01:00
farmio
81d0c4638d Support KNX sensor entity configuration from UI 2025-12-18 15:57:27 +01:00
Matthias Alphart
3c865c6f41 Support KNX fan entity configuration from UI (#159167) 2025-12-18 15:54:55 +01:00
Raphael Hehl
3b32c4bcbf Remove custom device_class from unifiprotect doorbell_text select entity (#159366)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2025-12-18 15:51:16 +01:00
dependabot[bot]
fcdc1cfed9 Bump github/codeql-action from 4.31.8 to 4.31.9 (#159248)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-18 15:45:47 +01:00
wollew
0fd782c4ab Raise exception if velux integration setup fails because of connection erros (#159231) 2025-12-18 15:44:42 +01:00
adam-the-hero
bbcaf69973 Bump quality scale for watergate to silver (#155353) 2025-12-18 15:30:15 +01:00
Denis Shulyaka
f2b713acac Exclude gpt-4o model from extended caching (#159362) 2025-12-18 08:48:01 -05:00
LG-ThinQ-Integration
6c944d6b15 Adds a delay to the continuous control of the climate (#151177)
Co-authored-by: yunseon.park <yunseon.park@lge.com>
2025-12-18 14:46:11 +01:00
Raphael Hehl
4dd3abb16a Fix device classes in unifiprotect integration (#159281)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2025-12-18 14:43:44 +01:00
adam-the-hero
d2672b9ddf Introduce session inject to watergate integration (#159360) 2025-12-18 14:08:57 +01:00
Matthias Alphart
ff30492919 KNX unit tests: patch CEMIHandler at class level (#159317) 2025-12-18 14:02:02 +01:00
Duco Sebel
b5ccdf8165 Implement new battery charge modes in HomeWizard (#159107) 2025-12-18 14:01:37 +01:00
Robert Resch
b3c745cfa7 Bump go2rtc to 1.9.13 (#159043) 2025-12-18 14:00:50 +01:00
Robert Resch
67aeafa797 Add advanced section for generic camera config flow (#148430) 2025-12-18 13:30:25 +01:00
Michael
3d71b6de44 Add support for FRITZ! Smarthome routines (#158947) 2025-12-18 13:09:06 +01:00
Luke Lashley
5349045932 Add basic support for Q7 devices (#159274) 2025-12-18 12:30:20 +01:00
epenet
4960871c84 Revert name change in meteo_france (#159352) 2025-12-18 11:04:34 +01:00
epenet
af3861cd6b Rename attribute in Tuya climate wrapper (#159145) 2025-12-18 10:02:38 +01:00
epenet
f9a070e9b3 Use common options attribute in Tuya event wrapper (#159119) 2025-12-18 09:33:53 +01:00
epenet
fd503b2e33 Make VacuumEntityFeature.STATE conditional in Tuya vacuum (#159254) 2025-12-18 09:32:13 +01:00
epenet
e5a73fcf57 Disable blackbird integration (#157817) 2025-12-18 08:51:54 +01:00
Andre Lengwenus
6991e01489 Fix incorrect status updates for lcn (#159251) 2025-12-18 08:11:22 +01:00
Simone Chemelli
c8636ee6f3 Add missing strings for Shelly voltmeter sensor (#159332) 2025-12-18 08:05:46 +01:00
J. Nick Koston
52229dc5a8 Bump aioesphomeapi to 43.3.0 (#159141) 2025-12-17 20:22:38 -10:00
Andre Lengwenus
f013455843 Bump pypck to 0.9.8 (#159277) 2025-12-18 06:54:56 +01:00
Joost Lekkerkerker
cae5bca546 Add integration_type device to kostal_plenticore (#159288) 2025-12-17 21:00:27 +01:00
Joost Lekkerkerker
49299b06c6 Add integration_type device to kmtronic (#159286) 2025-12-17 20:58:58 +01:00
Paul Tarjan
8e39027ad5 Add guidance to not amend commits after review starts (#158804) 2025-12-17 20:58:43 +01:00
Joost Lekkerkerker
2a1ce2df61 Add integration_type service to kodi (#159287) 2025-12-17 20:57:47 +01:00
Joost Lekkerkerker
7a6d929150 Add integration_type device to kulersky (#159290) 2025-12-17 20:56:52 +01:00
Joost Lekkerkerker
6f4a112dbb Add integration_type hub to lacrosse_view (#159291) 2025-12-17 20:56:14 +01:00
Joost Lekkerkerker
2197b910fb Add integration_type device to landisgyr_heat_meter (#159293) 2025-12-17 20:55:11 +01:00
Joost Lekkerkerker
7e2a9cd7f9 Add integration_type hub to laundrify (#159295) 2025-12-17 20:54:20 +01:00
Joost Lekkerkerker
e7ed7a8ed2 Add integration_type hub to lcn (#159296) 2025-12-17 20:53:41 +01:00
Joost Lekkerkerker
9ba2d0defe Add integration_type device to leaone (#159297) 2025-12-17 20:52:35 +01:00
Joost Lekkerkerker
231300919c Add integration_type device to led_ble (#159298) 2025-12-17 20:51:45 +01:00
Joost Lekkerkerker
664c50586f Add integration_type device to lg_soundbar (#159299) 2025-12-17 20:51:00 +01:00
Joost Lekkerkerker
43b9ecfc2b Add integration_type device to lifx (#159302) 2025-12-17 20:48:33 +01:00
Joost Lekkerkerker
f1237ed52a Add integration_type hub to livisi (#159303) 2025-12-17 20:47:39 +01:00
Joost Lekkerkerker
ecf8f55cc4 Add integration_type device to loqed (#159305) 2025-12-17 20:45:23 +01:00
Joost Lekkerkerker
ff36693057 Add integration_type hub to lupusec (#159306) 2025-12-17 20:44:29 +01:00
Joost Lekkerkerker
005785997c Add integration_type hub to lutron (#159307) 2025-12-17 20:43:47 +01:00
Joost Lekkerkerker
9917b82b66 Add integration_type hub to lyric (#159309) 2025-12-17 20:42:30 +01:00
Joost Lekkerkerker
9c927406ac Add integration_type service to mailgun (#159310) 2025-12-17 20:41:43 +01:00
Joost Lekkerkerker
972d95602a Add integration_type hub to meater (#159311) 2025-12-17 20:41:10 +01:00
Joost Lekkerkerker
5e0549a18f Add integration_type device to medcom_ble (#159312) 2025-12-17 20:39:39 +01:00
Joost Lekkerkerker
bcbb159fb2 Add integration_type device to melnor (#159313) 2025-12-17 20:38:23 +01:00
Joost Lekkerkerker
0123ca656a Add integration_type hub to lg_thinq (#159300) 2025-12-17 20:34:25 +01:00
Joost Lekkerkerker
1f699c729c Add integration_type service to lastfm (#159294) 2025-12-17 20:33:49 +01:00
Joost Lekkerkerker
50c3fcfeba Add integration_type service to kraken (#159289) 2025-12-17 20:33:17 +01:00
Raphael Hehl
2af1e098cc Improve debug logging in UniFi Protect integration (#159318) 2025-12-17 20:31:33 +01:00
114 changed files with 3385 additions and 736 deletions

View File

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

View File

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

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

View File

@@ -133,7 +133,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"lawn_mower",
"light",
"media_player",
"schedule",
"switch",
"text",
"update",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
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,11 +469,13 @@ class GenericOptionsFlowHandler(OptionsFlow):
user_input = self.user_input
return self.async_show_form(
step_id="init",
data_schema=build_schema(
user_input or self.config_entry.options,
data_schema=self.add_suggested_values_to_schema(
build_schema(
True,
self.show_advanced_options,
),
user_input or self.config_entry.options,
),
errors=errors,
)

View File

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

View File

@@ -25,18 +25,25 @@
},
"step": {
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"still_image_url": "Still image URL (e.g. http://...)",
"stream_source": "Stream source URL (e.g. rtsp://...)",
"username": "[%key:common::config_flow::data::username%]"
},
"sections": {
"advanced": {
"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%]"
},
"description": "Enter the settings to connect to the camera."
"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%]",
"username": "[%key:common::config_flow::data::username%]"
},
"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",
"username": "[%key:common::config_flow::data::username%]",
"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": {

View File

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

View File

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

View File

@@ -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."""
if entry.runtime_data.data.device.supports_batteries():
async_add_entities(
HomeWizardSelectEntity(
[
HomeWizardBatteryModeSelectEntity(
coordinator=entry.runtime_data,
description=description,
)
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()

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,6 +187,9 @@ class GASelector(KNXSelectorBase):
"passive": self.passive,
}
if self.dpt is not None:
if isinstance(self.dpt, list):
options["dptClasses"] = self.dpt
else:
options["dptSelect"] = [
{
"value": item.value,
@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,7 @@
"LIFX Z"
]
},
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["aiolifx", "aiolifx_effects", "bitstring"],
"requirements": [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,13 +6,5 @@
"reload": {
"service": "mdi:reload"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:calendar-blank"
},
"turned_on": {
"trigger": "mdi:calendar-clock"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
"""Fixtures for component."""
collect_ignore_glob = ["test_*.py"]

View File

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

View File

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

View File

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

View File

@@ -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",
"content_type": "image/jpeg",
"advanced": {
"framerate": 2.0,
"verify_ssl": True,
"content_type": "image/jpeg",
"limit_refetch_to_url_change": False,
"authentication": "basic",
},
version=1,
},
version=2,
)
entry.add_to_hass(hass)
return entry

View File

@@ -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",
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",
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",
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",
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",
SECTION_ADVANCED: {
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
CONF_FRAMERATE: 5.0,
CONF_VERIFY_SSL: False,
},
}
with patch(
@@ -447,10 +454,12 @@ async def test_form_still_and_stream_not_provided(
result2 = await hass.config_entries.flow.async_configure(
user_flow["flow_id"],
{
SECTION_ADVANCED: {
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
CONF_FRAMERATE: 5,
CONF_VERIFY_SSL: False,
},
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "no_still_image_or_stream_url"}
@@ -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"

View File

@@ -26,11 +26,13 @@ async def test_entry_diagnostics(
"stream_source": "http://****:****@example.com/****",
"username": REDACTED,
"password": REDACTED,
"content_type": "image/jpeg",
"advanced": {
"limit_refetch_to_url_change": False,
"authentication": "basic",
"framerate": 2.0,
"verify_ssl": True,
"content_type": "image/jpeg",
},
},
}

View File

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

View File

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

View File

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

View File

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