mirror of
https://github.com/home-assistant/core.git
synced 2025-11-08 02:19:31 +00:00
Compare commits
226 Commits
copilot/mo
...
knx-moveou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e5a0cdacd | ||
|
|
39d76a24db | ||
|
|
3e17a97422 | ||
|
|
e6e78f86bd | ||
|
|
a72fe28d7a | ||
|
|
fa7ff1d996 | ||
|
|
2cc910f4b1 | ||
|
|
b686e4d8db | ||
|
|
07493e5b3e | ||
|
|
477073da75 | ||
|
|
b59bc45ef1 | ||
|
|
da055795c7 | ||
|
|
3cd17a2b9f | ||
|
|
d689400b3b | ||
|
|
a1f024eed8 | ||
|
|
07b6358fff | ||
|
|
6fa73f7f6a | ||
|
|
3c46b40cee | ||
|
|
4c9810a10e | ||
|
|
83e9fca6a2 | ||
|
|
fc9313f7ef | ||
|
|
278f32285a | ||
|
|
8c360908ef | ||
|
|
82c5337fcf | ||
|
|
7950f9ab38 | ||
|
|
66eeb41e56 | ||
|
|
1bef707cd1 | ||
|
|
2125a4123d | ||
|
|
27516dee6a | ||
|
|
40c9e5356e | ||
|
|
2521920376 | ||
|
|
16eb8315ee | ||
|
|
2c3d65b461 | ||
|
|
7e938f4f13 | ||
|
|
c5c4cf0284 | ||
|
|
68c38ac047 | ||
|
|
f5a6fa8be1 | ||
|
|
3c751918fd | ||
|
|
a3c8760b3f | ||
|
|
7bceaf74be | ||
|
|
750f06327a | ||
|
|
98bffdb9d3 | ||
|
|
174b0f7c01 | ||
|
|
c960bd2845 | ||
|
|
5679ab0f86 | ||
|
|
2761dcbc48 | ||
|
|
3a00d96571 | ||
|
|
c86ad896b8 | ||
|
|
9c1d8747be | ||
|
|
4a003114bd | ||
|
|
dcc3f14b1f | ||
|
|
7687d5ea48 | ||
|
|
27cc3c838a | ||
|
|
619cb91839 | ||
|
|
5e5e130d4e | ||
|
|
f6ac23cc58 | ||
|
|
244b6437b2 | ||
|
|
5dc271b201 | ||
|
|
531cc3e1ce | ||
|
|
ed7c3cb339 | ||
|
|
16e11ed801 | ||
|
|
32a7bf4dbb | ||
|
|
856c99dc22 | ||
|
|
a50b00b3c2 | ||
|
|
1df8b1063b | ||
|
|
32cd4364f6 | ||
|
|
0828a842a5 | ||
|
|
f63a527a01 | ||
|
|
254a9ecc25 | ||
|
|
a518907b09 | ||
|
|
cd85699151 | ||
|
|
f49dfbd459 | ||
|
|
3ed70bb751 | ||
|
|
b4b1065737 | ||
|
|
7267c3c04e | ||
|
|
6ac4d2dd59 | ||
|
|
03abd5d277 | ||
|
|
66bb0db08b | ||
|
|
56ae579e83 | ||
|
|
add1915b8a | ||
|
|
18ef4af8d0 | ||
|
|
3c6788212f | ||
|
|
dbd8b1bc19 | ||
|
|
d135f1c110 | ||
|
|
bb98ed6633 | ||
|
|
59dace572a | ||
|
|
735cf36a5b | ||
|
|
90b0f50b8f | ||
|
|
e731c07b77 | ||
|
|
2c75635e95 | ||
|
|
1f031695c2 | ||
|
|
fb279212a9 | ||
|
|
45869523d0 | ||
|
|
a753926f22 | ||
|
|
dc874ff53a | ||
|
|
3ef6865708 | ||
|
|
7f1989f9f2 | ||
|
|
97e338c760 | ||
|
|
101679c17d | ||
|
|
bc784c356e | ||
|
|
556cc57d8b | ||
|
|
eef6e96a93 | ||
|
|
56d237af7f | ||
|
|
e5d1902d2a | ||
|
|
a9a203678e | ||
|
|
7f6237cc63 | ||
|
|
5468e691ca | ||
|
|
67cbbc3522 | ||
|
|
504da54c11 | ||
|
|
cdda2ef5c8 | ||
|
|
f405f9eb4b | ||
|
|
634f71835a | ||
|
|
49bfb01fac | ||
|
|
ad8f7fdcab | ||
|
|
f82ec81062 | ||
|
|
03b0842a01 | ||
|
|
13e5cb5cc8 | ||
|
|
f18cdaf4d8 | ||
|
|
5b3bca1426 | ||
|
|
d812e9d43c | ||
|
|
fa1071b221 | ||
|
|
e48c2c6c0b | ||
|
|
bddd4100c0 | ||
|
|
70d8df2e95 | ||
|
|
08b3dd0173 | ||
|
|
6723a7c4e1 | ||
|
|
40d7f2a89e | ||
|
|
13b717e2da | ||
|
|
5fcfd3ad84 | ||
|
|
324a7b5443 | ||
|
|
491ae8f72c | ||
|
|
259247892f | ||
|
|
caeda0ef64 | ||
|
|
df35c535e4 | ||
|
|
f93b9e0ed0 | ||
|
|
48a3372cf2 | ||
|
|
d84fd72428 | ||
|
|
e8cb386962 | ||
|
|
5ac726703c | ||
|
|
688649a799 | ||
|
|
c5359ade3e | ||
|
|
4e60dedc1b | ||
|
|
221d74f83a | ||
|
|
fbbb3d6415 | ||
|
|
8297019011 | ||
|
|
61715dcff3 | ||
|
|
32b822ee99 | ||
|
|
e6c2e0ad80 | ||
|
|
1314427dc5 | ||
|
|
bf499a45f7 | ||
|
|
b955e22628 | ||
|
|
1b222ff5fd | ||
|
|
f0510e703f | ||
|
|
cbe3956e15 | ||
|
|
4588e9da8d | ||
|
|
5445890fdf | ||
|
|
9b49f77f86 | ||
|
|
566c8fb786 | ||
|
|
b36150c213 | ||
|
|
809070d2ad | ||
|
|
f4339dc031 | ||
|
|
f3b37d24b0 | ||
|
|
4c8348caa7 | ||
|
|
b9e7c102ea | ||
|
|
69d9fa89b7 | ||
|
|
6f3f5a5ec1 | ||
|
|
5ecfeca90a | ||
|
|
00e0570fd4 | ||
|
|
5a5b94f3af | ||
|
|
34f00d9b33 | ||
|
|
4cabc5b368 | ||
|
|
4045125422 | ||
|
|
d7393af76f | ||
|
|
ad41386b27 | ||
|
|
62d17ea20c | ||
|
|
c4954731d0 | ||
|
|
647723d3f0 | ||
|
|
51c500e22c | ||
|
|
f6fc13c1f2 | ||
|
|
0009a7a042 | ||
|
|
a3d1aa28e7 | ||
|
|
9f53eb9b76 | ||
|
|
f53a205ff3 | ||
|
|
d08517c3df | ||
|
|
d7398a44a1 | ||
|
|
9acfc0cb88 | ||
|
|
1b3d21523a | ||
|
|
1d407d1326 | ||
|
|
013346cead | ||
|
|
5abaabc9da | ||
|
|
32481312c3 | ||
|
|
bdc9eb37d3 | ||
|
|
e0afcbc02b | ||
|
|
cd56a6a98d | ||
|
|
9d85893bbb | ||
|
|
9e8a70225f | ||
|
|
96ec795d5e | ||
|
|
65b796070d | ||
|
|
32994812e5 | ||
|
|
66ff9d63a3 | ||
|
|
b2a63d4996 | ||
|
|
f9f37b7f2a | ||
|
|
7bdd9dd38a | ||
|
|
1e8aae0a89 | ||
|
|
cf668e9dc2 | ||
|
|
2e91c8700f | ||
|
|
9d14627daa | ||
|
|
73b8283748 | ||
|
|
edeaaa2e63 | ||
|
|
d26dd8fc39 | ||
|
|
34640ea735 | ||
|
|
46a2e21ef0 | ||
|
|
508af53e72 | ||
|
|
5f7440608c | ||
|
|
0d1aa38a26 | ||
|
|
929f8c148a | ||
|
|
92db1f5a04 | ||
|
|
e66b5ce0bf | ||
|
|
1e17150e9f | ||
|
|
792902de3d | ||
|
|
04d78c3dd5 | ||
|
|
5c8d5bfb84 | ||
|
|
99bff31869 | ||
|
|
d949119fb0 | ||
|
|
e7b737ece5 | ||
|
|
fb8ddac2e8 |
6
.github/workflows/builder.yml
vendored
6
.github/workflows/builder.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
||||
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
@@ -464,7 +464,7 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
|
||||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -535,7 +535,7 @@ jobs:
|
||||
python --version
|
||||
uv pip freeze >> pip_freeze.txt
|
||||
- name: Upload pip_freeze artifact
|
||||
uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: pip-freeze-${{ matrix.python-version }}
|
||||
path: pip_freeze.txt
|
||||
@@ -867,7 +867,7 @@ jobs:
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
|
||||
- name: Download pytest_buckets
|
||||
uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: pytest_buckets
|
||||
- &compile-english-translations
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -92,7 +92,7 @@ jobs:
|
||||
) > build_constraints.txt
|
||||
|
||||
- name: Upload env_file
|
||||
uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: env_file
|
||||
path: ./.env_file
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
|
||||
- &download-env-file
|
||||
name: Download env_file
|
||||
uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -1543,6 +1543,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/suez_water/ @ooii @jb101010-2
|
||||
/homeassistant/components/sun/ @home-assistant/core
|
||||
/tests/components/sun/ @home-assistant/core
|
||||
/homeassistant/components/sunricher_dali_center/ @niracler
|
||||
/tests/components/sunricher_dali_center/ @niracler
|
||||
/homeassistant/components/supla/ @mwegrzynek
|
||||
/homeassistant/components/surepetcare/ @benleb @danielhiversen
|
||||
/tests/components/surepetcare/ @benleb @danielhiversen
|
||||
|
||||
@@ -109,12 +109,12 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
self._get_reauth_entry(), data_updates=config
|
||||
)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
self._get_reconfigure_entry(), data_updates=config
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -248,7 +248,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(discovery_info[CONF_MAC])
|
||||
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: discovery_info[CONF_HOST]}
|
||||
updates={CONF_HOST: discovery_info[CONF_HOST]}, reload_on_update=False
|
||||
)
|
||||
|
||||
self.context.update(
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from .const import DEFAULT_PORT, DOMAIN
|
||||
from .errors import (
|
||||
ConnectionRefused,
|
||||
ConnectionReset,
|
||||
ConnectionTimeout,
|
||||
ResolveFailed,
|
||||
ValidationFailure,
|
||||
@@ -49,6 +50,8 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._errors[CONF_HOST] = "connection_timeout"
|
||||
except ConnectionRefused:
|
||||
self._errors[CONF_HOST] = "connection_refused"
|
||||
except ConnectionReset:
|
||||
self._errors[CONF_HOST] = "connection_reset"
|
||||
except ValidationFailure:
|
||||
return True
|
||||
else:
|
||||
|
||||
@@ -25,3 +25,7 @@ class ConnectionTimeout(TemporaryFailure):
|
||||
|
||||
class ConnectionRefused(TemporaryFailure):
|
||||
"""Network connection refused."""
|
||||
|
||||
|
||||
class ConnectionReset(TemporaryFailure):
|
||||
"""Network connection reset."""
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.util.ssl import get_default_context
|
||||
from .const import TIMEOUT
|
||||
from .errors import (
|
||||
ConnectionRefused,
|
||||
ConnectionReset,
|
||||
ConnectionTimeout,
|
||||
ResolveFailed,
|
||||
ValidationFailure,
|
||||
@@ -58,6 +59,8 @@ async def get_cert_expiry_timestamp(
|
||||
raise ConnectionRefused(
|
||||
f"Connection refused by server: {hostname}:{port}"
|
||||
) from err
|
||||
except ConnectionResetError as err:
|
||||
raise ConnectionReset(f"Connection reset by server: {hostname}:{port}") from err
|
||||
except ssl.CertificateError as err:
|
||||
raise ValidationFailure(err.verify_message) from err
|
||||
except ssl.SSLError as err:
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"error": {
|
||||
"resolve_failed": "This host cannot be resolved",
|
||||
"connection_timeout": "Timeout when connecting to this host",
|
||||
"connection_refused": "Connection refused when connecting to host"
|
||||
"connection_refused": "Connection refused when connecting to host",
|
||||
"connection_reset": "Connection reset when connecting to host"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
|
||||
@@ -5,7 +5,7 @@ from aiocomelit.const import BRIDGE
|
||||
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_VEDO_PIN, DEFAULT_PORT
|
||||
from .const import DEFAULT_PORT
|
||||
from .coordinator import (
|
||||
ComelitBaseCoordinator,
|
||||
ComelitConfigEntry,
|
||||
@@ -43,13 +43,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b
|
||||
entry.data[CONF_HOST],
|
||||
entry.data.get(CONF_PORT, DEFAULT_PORT),
|
||||
entry.data[CONF_PIN],
|
||||
entry.data.get(CONF_VEDO_PIN),
|
||||
session,
|
||||
)
|
||||
platforms = list(BRIDGE_PLATFORMS)
|
||||
# Add VEDO platforms if vedo_pin is configured
|
||||
if entry.data.get(CONF_VEDO_PIN):
|
||||
platforms.extend(VEDO_PLATFORMS)
|
||||
platforms = BRIDGE_PLATFORMS
|
||||
else:
|
||||
coordinator = ComelitVedoSystem(
|
||||
hass,
|
||||
@@ -74,10 +70,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) ->
|
||||
"""Unload a config entry."""
|
||||
|
||||
if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
|
||||
platforms = list(BRIDGE_PLATFORMS)
|
||||
# Add VEDO platforms if vedo_pin was configured
|
||||
if entry.data.get(CONF_VEDO_PIN):
|
||||
platforms.extend(VEDO_PLATFORMS)
|
||||
platforms = BRIDGE_PLATFORMS
|
||||
else:
|
||||
platforms = VEDO_PLATFORMS
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
from typing import cast
|
||||
|
||||
from aiocomelit.api import ComelitVedoAreaObject
|
||||
from aiocomelit.const import BRIDGE, AlarmAreaState
|
||||
from aiocomelit.const import AlarmAreaState
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntity,
|
||||
@@ -14,13 +14,11 @@ from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelState,
|
||||
CodeFormat,
|
||||
)
|
||||
from homeassistant.const import CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
|
||||
from .utils import DeviceType, alarm_device_listener
|
||||
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -58,34 +56,12 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Comelit VEDO system alarm control panel devices."""
|
||||
|
||||
if config_entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
# Only setup if bridge has VEDO alarm enabled
|
||||
if not coordinator.vedo_pin:
|
||||
return
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitBridgeAlarmEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in (coordinator.alarm_data or {})
|
||||
.get("alarm_areas", {})
|
||||
.values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
config_entry.async_on_unload(
|
||||
alarm_device_listener(coordinator, _add_new_entities, "alarm_areas")
|
||||
)
|
||||
else:
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
async_add_entities(
|
||||
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data["alarm_areas"].values()
|
||||
)
|
||||
async_add_entities(
|
||||
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data["alarm_areas"].values()
|
||||
)
|
||||
|
||||
|
||||
class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanelEntity):
|
||||
@@ -195,133 +171,3 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]
|
||||
)
|
||||
|
||||
|
||||
class ComelitBridgeAlarmEntity(
|
||||
CoordinatorEntity[ComelitSerialBridge], AlarmControlPanelEntity
|
||||
):
|
||||
"""Representation of a VEDO alarm panel on a Serial Bridge."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_code_format = CodeFormat.NUMBER
|
||||
_attr_code_arm_required = False
|
||||
_attr_supported_features = (
|
||||
AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
| AlarmControlPanelEntityFeature.ARM_HOME
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ComelitSerialBridge,
|
||||
area: ComelitVedoAreaObject,
|
||||
config_entry_entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the alarm panel."""
|
||||
self._area_index = area.index
|
||||
super().__init__(coordinator)
|
||||
# Use config_entry.entry_id as base for unique_id
|
||||
# because no serial number or mac is available
|
||||
self._attr_unique_id = f"{config_entry_entry_id}-{area.index}"
|
||||
self._attr_device_info = coordinator.platform_device_info(area, "area")
|
||||
if area.p2:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
|
||||
@property
|
||||
def _area(self) -> ComelitVedoAreaObject:
|
||||
"""Return area object."""
|
||||
if self.coordinator.alarm_data:
|
||||
return self.coordinator.alarm_data["alarm_areas"][self._area_index]
|
||||
# Return a default area object if no alarm data
|
||||
return ComelitVedoAreaObject(
|
||||
index=self._area_index,
|
||||
name="Unknown",
|
||||
p1=False,
|
||||
p2=False,
|
||||
ready=False,
|
||||
armed=0,
|
||||
alarm=False,
|
||||
alarm_memory=False,
|
||||
sabotage=False,
|
||||
anomaly=False,
|
||||
in_time=False,
|
||||
out_time=False,
|
||||
human_status=AlarmAreaState.UNKNOWN,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if alarm is available."""
|
||||
if not self.coordinator.alarm_data:
|
||||
return False
|
||||
if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]:
|
||||
return False
|
||||
return super().available
|
||||
|
||||
@property
|
||||
def alarm_state(self) -> AlarmControlPanelState | None:
|
||||
"""Return the state of the alarm."""
|
||||
|
||||
_LOGGER.debug(
|
||||
"Area %s status is: %s. Armed is %s",
|
||||
self._area.name,
|
||||
self._area.human_status,
|
||||
self._area.armed,
|
||||
)
|
||||
if self._area.human_status == AlarmAreaState.ARMED:
|
||||
if self._area.armed == ALARM_AREA_ARMED_STATUS[AWAY]:
|
||||
return AlarmControlPanelState.ARMED_AWAY
|
||||
if self._area.armed == ALARM_AREA_ARMED_STATUS[NIGHT]:
|
||||
return AlarmControlPanelState.ARMED_NIGHT
|
||||
return AlarmControlPanelState.ARMED_HOME
|
||||
|
||||
return {
|
||||
AlarmAreaState.DISARMED: AlarmControlPanelState.DISARMED,
|
||||
AlarmAreaState.ENTRY_DELAY: AlarmControlPanelState.DISARMING,
|
||||
AlarmAreaState.EXIT_DELAY: AlarmControlPanelState.ARMING,
|
||||
AlarmAreaState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
|
||||
}.get(self._area.human_status)
|
||||
|
||||
async def _async_update_state(self, area_state: AlarmAreaState, armed: int) -> None:
|
||||
"""Update state after action."""
|
||||
self._area.human_status = area_state
|
||||
self._area.armed = armed
|
||||
await self.async_update_ha_state()
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send disarm command."""
|
||||
if code != str(self.coordinator.vedo_pin):
|
||||
return
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[DISABLE]
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE]
|
||||
)
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[AWAY]
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY]
|
||||
)
|
||||
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send arm home command."""
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[HOME]
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1]
|
||||
)
|
||||
|
||||
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
||||
"""Send arm night command."""
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[NIGHT]
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]
|
||||
)
|
||||
|
||||
@@ -5,19 +5,17 @@ from __future__ import annotations
|
||||
from typing import cast
|
||||
|
||||
from aiocomelit import ComelitVedoZoneObject
|
||||
from aiocomelit.const import BRIDGE
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
|
||||
from .utils import DeviceType, alarm_device_listener, new_device_listener
|
||||
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
|
||||
from .utils import DeviceType, new_device_listener
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -30,47 +28,21 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up Comelit VEDO presence sensors."""
|
||||
|
||||
if config_entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
# Only setup if bridge has VEDO alarm enabled
|
||||
if not coordinator.vedo_pin:
|
||||
return
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitVedoBridgeBinarySensorEntity(
|
||||
coordinator, device, config_entry.entry_id
|
||||
)
|
||||
for device in (coordinator.alarm_data or {})
|
||||
.get("alarm_zones", {})
|
||||
.values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
config_entry.async_on_unload(
|
||||
alarm_device_listener(coordinator, _add_new_entities, "alarm_zones")
|
||||
)
|
||||
else:
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitVedoBinarySensorEntity(
|
||||
coordinator, device, config_entry.entry_id
|
||||
)
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
|
||||
)
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
|
||||
)
|
||||
|
||||
|
||||
class ComelitVedoBinarySensorEntity(
|
||||
@@ -101,41 +73,3 @@ class ComelitVedoBinarySensorEntity(
|
||||
return (
|
||||
self.coordinator.data["alarm_zones"][self._zone_index].status_api == "0001"
|
||||
)
|
||||
|
||||
|
||||
class ComelitVedoBridgeBinarySensorEntity(
|
||||
CoordinatorEntity[ComelitSerialBridge], BinarySensorEntity
|
||||
):
|
||||
"""VEDO sensor device on a Serial Bridge."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = BinarySensorDeviceClass.MOTION
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ComelitSerialBridge,
|
||||
zone: ComelitVedoZoneObject,
|
||||
config_entry_entry_id: str,
|
||||
) -> None:
|
||||
"""Init sensor entity."""
|
||||
self._zone_index = zone.index
|
||||
super().__init__(coordinator)
|
||||
# Use config_entry.entry_id as base for unique_id
|
||||
# because no serial number or mac is available
|
||||
self._attr_unique_id = f"{config_entry_entry_id}-presence-{zone.index}"
|
||||
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Sensor availability."""
|
||||
return self.coordinator.alarm_data is not None
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Presence detected."""
|
||||
if not self.coordinator.alarm_data:
|
||||
return False
|
||||
return (
|
||||
self.coordinator.alarm_data["alarm_zones"][self._zone_index].status_api
|
||||
== "0001"
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import _LOGGER, CONF_VEDO_PIN, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
|
||||
from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
|
||||
from .utils import async_client_session
|
||||
|
||||
DEFAULT_HOST = "192.168.1.252"
|
||||
@@ -34,7 +34,6 @@ USER_SCHEMA = vol.Schema(
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
|
||||
vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST),
|
||||
vol.Optional(CONF_VEDO_PIN): cv.string,
|
||||
}
|
||||
)
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
|
||||
@@ -43,7 +42,6 @@ STEP_RECONFIGURE = vol.Schema(
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_PORT): cv.port,
|
||||
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
|
||||
vol.Optional(CONF_VEDO_PIN): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -81,27 +79,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
finally:
|
||||
await api.logout()
|
||||
|
||||
# Validate VEDO PIN if provided and device type is BRIDGE
|
||||
if data.get(CONF_VEDO_PIN) and data.get(CONF_TYPE, BRIDGE) == BRIDGE:
|
||||
if not re.fullmatch(r"[0-9]{4,10}", data[CONF_VEDO_PIN]):
|
||||
raise InvalidVedoPin
|
||||
|
||||
# Verify VEDO is enabled with the provided PIN
|
||||
try:
|
||||
if not await api.vedo_enabled(data[CONF_VEDO_PIN]):
|
||||
raise InvalidVedoAuth
|
||||
except (aiocomelit_exceptions.CannotConnect, TimeoutError) as err:
|
||||
raise CannotConnect(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except aiocomelit_exceptions.CannotAuthenticate:
|
||||
raise InvalidVedoAuth(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_vedo_auth",
|
||||
) from None
|
||||
|
||||
return {"title": data[CONF_HOST]}
|
||||
|
||||
|
||||
@@ -129,10 +106,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "invalid_auth"
|
||||
except InvalidPin:
|
||||
errors["base"] = "invalid_pin"
|
||||
except InvalidVedoPin:
|
||||
errors["base"] = "invalid_vedo_pin"
|
||||
except InvalidVedoAuth:
|
||||
errors["base"] = "invalid_vedo_auth"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
@@ -214,38 +187,19 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
data_to_validate = {
|
||||
CONF_HOST: updated_host,
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_PIN: user_input[CONF_PIN],
|
||||
CONF_TYPE: reconfigure_entry.data.get(CONF_TYPE, BRIDGE),
|
||||
}
|
||||
if CONF_VEDO_PIN in user_input:
|
||||
data_to_validate[CONF_VEDO_PIN] = user_input[CONF_VEDO_PIN]
|
||||
await validate_input(self.hass, data_to_validate)
|
||||
await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except InvalidPin:
|
||||
errors["base"] = "invalid_pin"
|
||||
except InvalidVedoPin:
|
||||
errors["base"] = "invalid_vedo_pin"
|
||||
except InvalidVedoAuth:
|
||||
errors["base"] = "invalid_vedo_auth"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
data_updates = {
|
||||
CONF_HOST: updated_host,
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_PIN: user_input[CONF_PIN],
|
||||
}
|
||||
if CONF_VEDO_PIN in user_input:
|
||||
data_updates[CONF_VEDO_PIN] = user_input[CONF_VEDO_PIN]
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry, data_updates=data_updates
|
||||
reconfigure_entry, data_updates={CONF_HOST: updated_host}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -265,11 +219,3 @@ class InvalidAuth(HomeAssistantError):
|
||||
|
||||
class InvalidPin(HomeAssistantError):
|
||||
"""Error to indicate an invalid pin."""
|
||||
|
||||
|
||||
class InvalidVedoPin(HomeAssistantError):
|
||||
"""Error to indicate an invalid VEDO pin."""
|
||||
|
||||
|
||||
class InvalidVedoAuth(HomeAssistantError):
|
||||
"""Error to indicate VEDO authentication failed."""
|
||||
|
||||
@@ -9,7 +9,6 @@ _LOGGER = logging.getLogger(__package__)
|
||||
DOMAIN = "comelit"
|
||||
DEFAULT_PORT = 80
|
||||
DEVICE_TYPE_LIST = [BRIDGE, VEDO]
|
||||
CONF_VEDO_PIN = "vedo_pin"
|
||||
|
||||
SCAN_INTERVAL = 5
|
||||
|
||||
|
||||
@@ -154,8 +154,6 @@ class ComelitSerialBridge(
|
||||
|
||||
_hw_version = "20003101"
|
||||
api: ComeliteSerialBridgeApi
|
||||
vedo_pin: str | None
|
||||
alarm_data: AlarmDataObject | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -164,49 +162,25 @@ class ComelitSerialBridge(
|
||||
host: str,
|
||||
port: int,
|
||||
pin: str,
|
||||
vedo_pin: str | None,
|
||||
session: ClientSession,
|
||||
) -> None:
|
||||
"""Initialize the scanner."""
|
||||
self.api = ComeliteSerialBridgeApi(host, port, pin, session)
|
||||
self.vedo_pin = vedo_pin
|
||||
super().__init__(hass, entry, BRIDGE, host)
|
||||
|
||||
async def _async_update_system_data(
|
||||
self,
|
||||
) -> dict[str, dict[int, ComelitSerialBridgeObject]]:
|
||||
"""Specific method for updating data."""
|
||||
devices = await self.api.get_all_devices()
|
||||
data = await self.api.get_all_devices()
|
||||
|
||||
if self.data:
|
||||
for dev_type in (CLIMATE, COVER, LIGHT, IRRIGATION, OTHER, SCENARIO):
|
||||
await self._async_remove_stale_devices(
|
||||
self.data[dev_type], devices[dev_type], dev_type
|
||||
self.data[dev_type], data[dev_type], dev_type
|
||||
)
|
||||
|
||||
# Get VEDO alarm data if vedo_pin is configured
|
||||
if self.vedo_pin:
|
||||
try:
|
||||
if await self.api.vedo_enabled(self.vedo_pin):
|
||||
self.alarm_data = await self.api.get_all_areas_and_zones()
|
||||
|
||||
# Remove stale alarm devices
|
||||
if self.alarm_data:
|
||||
previous_alarm_data = getattr(
|
||||
self, "_previous_alarm_data", None
|
||||
)
|
||||
if previous_alarm_data:
|
||||
for obj_type in ("alarm_areas", "alarm_zones"):
|
||||
await self._async_remove_stale_devices(
|
||||
previous_alarm_data[obj_type],
|
||||
self.alarm_data[obj_type],
|
||||
"area" if obj_type == "alarm_areas" else "zone",
|
||||
)
|
||||
self._previous_alarm_data = self.alarm_data
|
||||
except (CannotAuthenticate, CannotConnect, CannotRetrieveData):
|
||||
_LOGGER.warning("Failed to retrieve VEDO alarm data")
|
||||
|
||||
return devices
|
||||
return data
|
||||
|
||||
|
||||
class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import DeviceType, alarm_device_listener, new_device_listener
|
||||
from .utils import DeviceType, new_device_listener
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -83,30 +83,6 @@ async def async_setup_bridge_entry(
|
||||
new_device_listener(coordinator, _add_new_entities, OTHER)
|
||||
)
|
||||
|
||||
# Add VEDO sensors if bridge has alarm data
|
||||
if coordinator.vedo_pin:
|
||||
|
||||
def _add_new_alarm_entities(
|
||||
new_devices: list[DeviceType], dev_type: str
|
||||
) -> None:
|
||||
"""Add entities for new alarm zones."""
|
||||
entities = [
|
||||
ComelitVedoBridgeSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_VEDO_TYPES
|
||||
for device in (coordinator.alarm_data or {})
|
||||
.get("alarm_zones", {})
|
||||
.values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
config_entry.async_on_unload(
|
||||
alarm_device_listener(coordinator, _add_new_alarm_entities, "alarm_zones")
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_vedo_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -203,58 +179,3 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity
|
||||
return None
|
||||
|
||||
return cast(str, status.value)
|
||||
|
||||
|
||||
class ComelitVedoBridgeSensorEntity(
|
||||
CoordinatorEntity[ComelitSerialBridge], SensorEntity
|
||||
):
|
||||
"""VEDO sensor device on a Serial Bridge."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ComelitSerialBridge,
|
||||
zone: ComelitVedoZoneObject,
|
||||
config_entry_entry_id: str,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Init sensor entity."""
|
||||
self._zone_index = zone.index
|
||||
super().__init__(coordinator)
|
||||
# Use config_entry.entry_id as base for unique_id
|
||||
# because no serial number or mac is available
|
||||
self._attr_unique_id = f"{config_entry_entry_id}-{zone.index}"
|
||||
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
|
||||
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def _zone_object(self) -> ComelitVedoZoneObject:
|
||||
"""Zone object."""
|
||||
if self.coordinator.alarm_data:
|
||||
return self.coordinator.alarm_data["alarm_zones"][self._zone_index]
|
||||
# Return a default zone object if no alarm data
|
||||
return ComelitVedoZoneObject(
|
||||
index=self._zone_index,
|
||||
name="Unknown",
|
||||
status_api="0x000",
|
||||
status=0,
|
||||
human_status=AlarmZoneState.UNAVAILABLE,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Sensor availability."""
|
||||
return (
|
||||
self.coordinator.alarm_data is not None
|
||||
and self._zone_object.human_status != AlarmZoneState.UNAVAILABLE
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Sensor value."""
|
||||
if (status := self._zone_object.human_status) == AlarmZoneState.UNKNOWN:
|
||||
return None
|
||||
|
||||
return cast(str, status.value)
|
||||
|
||||
@@ -15,29 +15,25 @@
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"pin": "[%key:common::config_flow::data::pin%]",
|
||||
"type": "Device type",
|
||||
"vedo_pin": "VEDO alarm PIN (optional)"
|
||||
"type": "Device type"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Comelit device.",
|
||||
"port": "The port of your Comelit device.",
|
||||
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]",
|
||||
"type": "The type of your Comelit device.",
|
||||
"vedo_pin": "Optional PIN for VEDO alarm system on Serial Bridge devices. Leave empty if you don't have VEDO alarm enabled."
|
||||
"type": "The type of your Comelit device."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"pin": "[%key:common::config_flow::data::pin%]",
|
||||
"vedo_pin": "[%key:component::comelit::config::step::user::data::vedo_pin%]"
|
||||
"pin": "[%key:common::config_flow::data::pin%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::comelit::config::step::user::data_description::host%]",
|
||||
"port": "[%key:component::comelit::config::step::user::data_description::port%]",
|
||||
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]",
|
||||
"vedo_pin": "[%key:component::comelit::config::step::user::data_description::vedo_pin%]"
|
||||
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -48,16 +44,12 @@
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_pin": "The provided PIN is invalid. It must be a 4-10 digit number.",
|
||||
"invalid_vedo_pin": "The provided VEDO PIN is invalid. It must be a 4-10 digit number.",
|
||||
"invalid_vedo_auth": "The provided VEDO PIN is incorrect or VEDO alarm is not enabled on this device.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_pin": "[%key:component::comelit::config::abort::invalid_pin%]",
|
||||
"invalid_vedo_pin": "[%key:component::comelit::config::abort::invalid_vedo_pin%]",
|
||||
"invalid_vedo_auth": "[%key:component::comelit::config::abort::invalid_vedo_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -158,35 +158,3 @@ def new_device_listener(
|
||||
_check_devices()
|
||||
|
||||
return coordinator.async_add_listener(_check_devices)
|
||||
|
||||
|
||||
def alarm_device_listener(
|
||||
coordinator: ComelitBaseCoordinator,
|
||||
new_devices_callback: Callable[
|
||||
[list[ComelitVedoAreaObject | ComelitVedoZoneObject], str],
|
||||
None,
|
||||
],
|
||||
data_type: str,
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to coordinator updates to check for new alarm devices on bridge."""
|
||||
known_devices: dict[str, list[int]] = {}
|
||||
|
||||
def _check_alarm_devices() -> None:
|
||||
"""Check for new alarm devices and call callback with any new devices."""
|
||||
# For ComelitSerialBridge with alarm_data
|
||||
if not hasattr(coordinator, "alarm_data") or not coordinator.alarm_data:
|
||||
return
|
||||
|
||||
new_devices: list[ComelitVedoAreaObject | ComelitVedoZoneObject] = []
|
||||
for _id in coordinator.alarm_data[data_type]:
|
||||
if _id not in (id_list := known_devices.get(data_type, [])):
|
||||
known_devices.update({data_type: [*id_list, _id]})
|
||||
new_devices.append(coordinator.alarm_data[data_type][_id])
|
||||
|
||||
if new_devices:
|
||||
new_devices_callback(new_devices, data_type)
|
||||
|
||||
# Check for devices immediately
|
||||
_check_alarm_devices()
|
||||
|
||||
return coordinator.async_add_listener(_check_alarm_devices)
|
||||
|
||||
@@ -184,7 +184,8 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
CONF_API_KEY: self.api_key,
|
||||
}
|
||||
},
|
||||
reload_on_update=False,
|
||||
)
|
||||
|
||||
except TimeoutError:
|
||||
@@ -231,7 +232,8 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
updates={
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
}
|
||||
},
|
||||
reload_on_update=False,
|
||||
)
|
||||
|
||||
self.context.update(
|
||||
@@ -265,7 +267,8 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
CONF_API_KEY: self.api_key,
|
||||
}
|
||||
},
|
||||
reload_on_update=False,
|
||||
)
|
||||
|
||||
self.context["configuration_url"] = HASSIO_CONFIGURATION_URL
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EcovacsConfigEntry
|
||||
from .const import SUPPORTED_LIFESPANS
|
||||
from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS
|
||||
from .entity import (
|
||||
EcovacsCapabilityEntityDescription,
|
||||
EcovacsDescriptionEntity,
|
||||
@@ -62,7 +62,7 @@ STATION_ENTITY_DESCRIPTIONS = tuple(
|
||||
key=f"station_action_{action.name.lower()}",
|
||||
translation_key=f"station_action_{action.name.lower()}",
|
||||
)
|
||||
for action in StationAction
|
||||
for action in SUPPORTED_STATION_ACTIONS
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,11 @@ SUPPORTED_LIFESPANS = (
|
||||
LifeSpan.STATION_FILTER,
|
||||
)
|
||||
|
||||
SUPPORTED_STATION_ACTIONS = (StationAction.EMPTY_DUSTBIN,)
|
||||
SUPPORTED_STATION_ACTIONS = (
|
||||
StationAction.CLEAN_BASE,
|
||||
StationAction.DRY_MOP,
|
||||
StationAction.EMPTY_DUSTBIN,
|
||||
)
|
||||
|
||||
LEGACY_SUPPORTED_LIFESPANS = (
|
||||
"main_brush",
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"reset_lifespan_round_mop": {
|
||||
"default": "mdi:broom"
|
||||
},
|
||||
"station_action_clean_base": {
|
||||
"default": "mdi:home"
|
||||
},
|
||||
"station_action_dry_mop": {
|
||||
"default": "mdi:broom"
|
||||
},
|
||||
"station_action_empty_dustbin": {
|
||||
"default": "mdi:delete-restore"
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==15.1.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==16.1.0"]
|
||||
}
|
||||
|
||||
@@ -70,6 +70,12 @@
|
||||
"reset_lifespan_side_brush": {
|
||||
"name": "Reset side brush lifespan"
|
||||
},
|
||||
"station_action_clean_base": {
|
||||
"name": "Clean base"
|
||||
},
|
||||
"station_action_dry_mop": {
|
||||
"name": "Dry mop"
|
||||
},
|
||||
"station_action_empty_dustbin": {
|
||||
"name": "Empty dustbin"
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/firefly_iii",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyfirefly==0.1.6"]
|
||||
"requirements": ["pyfirefly==0.1.8"]
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_RECOMMENDED,
|
||||
LOGGER,
|
||||
RECOMMENDED_A_TASK_MAX_TOKENS,
|
||||
RECOMMENDED_AI_TASK_MAX_TOKENS,
|
||||
RECOMMENDED_IMAGE_MODEL,
|
||||
)
|
||||
from .entity import (
|
||||
@@ -80,7 +80,7 @@ class GoogleGenerativeAITaskEntity(
|
||||
) -> ai_task.GenDataTaskResult:
|
||||
"""Handle a generate data task."""
|
||||
await self._async_handle_chat_log(
|
||||
chat_log, task.structure, default_max_tokens=RECOMMENDED_A_TASK_MAX_TOKENS
|
||||
chat_log, task.structure, default_max_tokens=RECOMMENDED_AI_TASK_MAX_TOKENS
|
||||
)
|
||||
|
||||
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
|
||||
|
||||
@@ -33,7 +33,7 @@ RECOMMENDED_TOP_K = 64
|
||||
CONF_MAX_TOKENS = "max_tokens"
|
||||
RECOMMENDED_MAX_TOKENS = 3000
|
||||
# Input 5000, output 19400 = 0.05 USD
|
||||
RECOMMENDED_A_TASK_MAX_TOKENS = 19400
|
||||
RECOMMENDED_AI_TASK_MAX_TOKENS = 19400
|
||||
CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold"
|
||||
CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold"
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold"
|
||||
|
||||
@@ -36,7 +36,7 @@ DEFAULT_URL = SERVER_URLS[0]
|
||||
|
||||
DOMAIN = "growatt_server"
|
||||
|
||||
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
|
||||
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
LOGIN_INVALID_AUTH_CODE = "502"
|
||||
|
||||
|
||||
162
homeassistant/components/growatt_server/number.py
Normal file
162
homeassistant/components/growatt_server/number.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Number platform for Growatt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from growattServer import GrowattV1ApiError
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberEntityDescription
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = (
|
||||
1 # Serialize updates as inverter does not handle concurrent requests
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKeysMixin):
|
||||
"""Describes Growatt number entity."""
|
||||
|
||||
write_key: str | None = None # Parameter ID for writing (if different from api_key)
|
||||
|
||||
|
||||
# Note that the Growatt V1 API uses different keys for reading and writing parameters.
|
||||
# Reading values returns camelCase keys, while writing requires snake_case keys.
|
||||
|
||||
MIN_NUMBER_TYPES: tuple[GrowattNumberEntityDescription, ...] = (
|
||||
GrowattNumberEntityDescription(
|
||||
key="battery_charge_power_limit",
|
||||
translation_key="battery_charge_power_limit",
|
||||
api_key="chargePowerCommand", # Key returned by V1 API
|
||||
write_key="charge_power", # Key used to write parameter
|
||||
native_step=1,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
GrowattNumberEntityDescription(
|
||||
key="battery_charge_soc_limit",
|
||||
translation_key="battery_charge_soc_limit",
|
||||
api_key="wchargeSOCLowLimit", # Key returned by V1 API
|
||||
write_key="charge_stop_soc", # Key used to write parameter
|
||||
native_step=1,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
GrowattNumberEntityDescription(
|
||||
key="battery_discharge_power_limit",
|
||||
translation_key="battery_discharge_power_limit",
|
||||
api_key="disChargePowerCommand", # Key returned by V1 API
|
||||
write_key="discharge_power", # Key used to write parameter
|
||||
native_step=1,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
GrowattNumberEntityDescription(
|
||||
key="battery_discharge_soc_limit",
|
||||
translation_key="battery_discharge_soc_limit",
|
||||
api_key="wdisChargeSOCLowLimit", # Key returned by V1 API
|
||||
write_key="discharge_stop_soc", # Key used to write parameter
|
||||
native_step=1,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GrowattConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Growatt number entities."""
|
||||
runtime_data = entry.runtime_data
|
||||
|
||||
# Add number entities for each MIN device (only supported with V1 API)
|
||||
async_add_entities(
|
||||
GrowattNumber(device_coordinator, description)
|
||||
for device_coordinator in runtime_data.devices.values()
|
||||
if (
|
||||
device_coordinator.device_type == "min"
|
||||
and device_coordinator.api_version == "v1"
|
||||
)
|
||||
for description in MIN_NUMBER_TYPES
|
||||
)
|
||||
|
||||
|
||||
class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity):
|
||||
"""Representation of a Growatt number."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
entity_description: GrowattNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: GrowattCoordinator,
|
||||
description: GrowattNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the number."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.device_id)},
|
||||
manufacturer="Growatt",
|
||||
name=coordinator.device_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the current value of the number."""
|
||||
value = self.coordinator.data.get(self.entity_description.api_key)
|
||||
if value is None:
|
||||
return None
|
||||
return int(value)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the value of the number."""
|
||||
# Use write_key if specified, otherwise fall back to api_key
|
||||
parameter_id = (
|
||||
self.entity_description.write_key or self.entity_description.api_key
|
||||
)
|
||||
int_value = int(value)
|
||||
|
||||
try:
|
||||
# Use V1 API to write parameter
|
||||
await self.hass.async_add_executor_job(
|
||||
self.coordinator.api.min_write_parameter,
|
||||
self.coordinator.device_id,
|
||||
parameter_id,
|
||||
int_value,
|
||||
)
|
||||
except GrowattV1ApiError as e:
|
||||
raise HomeAssistantError(f"Error while setting parameter: {e}") from e
|
||||
|
||||
# If no exception was raised, the write was successful
|
||||
_LOGGER.debug(
|
||||
"Set parameter %s to %s",
|
||||
parameter_id,
|
||||
value,
|
||||
)
|
||||
|
||||
# Update the value in coordinator data to avoid triggering an immediate
|
||||
# refresh that would hit the API rate limit (5-minute polling interval)
|
||||
self.coordinator.data[self.entity_description.api_key] = int_value
|
||||
self.async_write_ha_state()
|
||||
@@ -504,6 +504,20 @@
|
||||
"name": "Maximum power"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"battery_charge_power_limit": {
|
||||
"name": "Battery charge power limit"
|
||||
},
|
||||
"battery_charge_soc_limit": {
|
||||
"name": "Battery charge SOC limit"
|
||||
},
|
||||
"battery_discharge_power_limit": {
|
||||
"name": "Battery discharge power limit"
|
||||
},
|
||||
"battery_discharge_soc_limit": {
|
||||
"name": "Battery discharge SOC limit"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"ac_charge": {
|
||||
"name": "Charge from grid"
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homematicip"],
|
||||
"requirements": ["homematicip==2.3.0"]
|
||||
"requirements": ["homematicip==2.3.1"]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.config_entries import SOURCE_REAUTH
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
|
||||
@@ -71,6 +72,25 @@ async def async_unload_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry)
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
def get_main_device(
|
||||
hass: HomeAssistant, entry: HomeWizardConfigEntry
|
||||
) -> dr.DeviceEntry | None:
|
||||
"""Helper function to get the main device for the config entry."""
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry_id=entry.entry_id
|
||||
)
|
||||
|
||||
if not device_entries:
|
||||
return None
|
||||
|
||||
# Get first device that is not a sub-device, as this is the main device in HomeWizard
|
||||
# This is relevant for the P1 Meter which may create sub-devices for external utility meters
|
||||
return next(
|
||||
(device for device in device_entries if device.via_device_id is None), None
|
||||
)
|
||||
|
||||
|
||||
async def async_check_v2_support_and_create_issue(
|
||||
hass: HomeAssistant, entry: HomeWizardConfigEntry
|
||||
) -> None:
|
||||
@@ -79,6 +99,16 @@ async def async_check_v2_support_and_create_issue(
|
||||
if not await has_v2_api(entry.data[CONF_IP_ADDRESS], async_get_clientsession(hass)):
|
||||
return
|
||||
|
||||
title = entry.title
|
||||
|
||||
# Try to get the name from the device registry
|
||||
# This is to make it clearer which device needs reconfiguration, as the config entry title is kept default most of the time
|
||||
if main_device := get_main_device(hass, entry):
|
||||
device_name = main_device.name_by_user or main_device.name
|
||||
|
||||
if device_name and entry.title != device_name:
|
||||
title = f"{entry.title} ({device_name})"
|
||||
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
@@ -88,7 +118,7 @@ async def async_check_v2_support_and_create_issue(
|
||||
learn_more_url="https://home-assistant.io/integrations/homewizard/#which-button-do-i-need-to-press-to-configure-the-device",
|
||||
translation_key="migrate_to_v2_api",
|
||||
translation_placeholders={
|
||||
"title": entry.title,
|
||||
"title": title,
|
||||
},
|
||||
severity=IssueSeverity.WARNING,
|
||||
data={"entry_id": entry.entry_id},
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"issues": {
|
||||
"migrate_to_v2_api": {
|
||||
"title": "Update authentication method",
|
||||
"title": "Update the authentication method for {title}",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.5.0"]
|
||||
"requirements": ["aioautomower==2.6.0"]
|
||||
}
|
||||
|
||||
@@ -445,7 +445,8 @@
|
||||
"main_area": "Main area",
|
||||
"secondary_area": "Secondary area",
|
||||
"home": "Home",
|
||||
"demo": "Demo"
|
||||
"demo": "Demo",
|
||||
"poi": "Point of interest"
|
||||
}
|
||||
},
|
||||
"uptime": {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/israel_rail",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["israelrailapi"],
|
||||
"requirements": ["israel-rail-api==0.1.3"]
|
||||
"requirements": ["israel-rail-api==0.1.4"]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from xknx.devices import (
|
||||
)
|
||||
from xknx.devices.fan import FanSpeedMode
|
||||
from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode
|
||||
from xknx.remote_value.remote_value_setpoint_shift import SetpointShiftMode
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.climate import (
|
||||
@@ -34,13 +35,53 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
)
|
||||
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 .const import CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, KNX_MODULE_KEY
|
||||
from .entity import KnxYamlEntity
|
||||
from .const import (
|
||||
CONF_SYNC_STATE,
|
||||
CONTROLLER_MODES,
|
||||
CURRENT_HVAC_ACTIONS,
|
||||
DOMAIN,
|
||||
KNX_MODULE_KEY,
|
||||
ClimateConf,
|
||||
)
|
||||
from .entity import (
|
||||
KnxUiEntity,
|
||||
KnxUiEntityPlatformController,
|
||||
KnxYamlEntity,
|
||||
_KnxEntityBase,
|
||||
)
|
||||
from .knx_module import KNXModule
|
||||
from .schema import ClimateSchema
|
||||
from .storage.const import (
|
||||
CONF_ENTITY,
|
||||
CONF_GA_ACTIVE,
|
||||
CONF_GA_CONTROLLER_MODE,
|
||||
CONF_GA_CONTROLLER_STATUS,
|
||||
CONF_GA_FAN_SPEED,
|
||||
CONF_GA_FAN_SWING,
|
||||
CONF_GA_FAN_SWING_HORIZONTAL,
|
||||
CONF_GA_HEAT_COOL,
|
||||
CONF_GA_HUMIDITY_CURRENT,
|
||||
CONF_GA_ON_OFF,
|
||||
CONF_GA_OP_MODE_COMFORT,
|
||||
CONF_GA_OP_MODE_ECO,
|
||||
CONF_GA_OP_MODE_PROTECTION,
|
||||
CONF_GA_OP_MODE_STANDBY,
|
||||
CONF_GA_OPERATION_MODE,
|
||||
CONF_GA_SETPOINT_SHIFT,
|
||||
CONF_GA_TEMPERATURE_CURRENT,
|
||||
CONF_GA_TEMPERATURE_TARGET,
|
||||
CONF_GA_VALVE,
|
||||
CONF_IGNORE_AUTO_MODE,
|
||||
CONF_TARGET_TEMPERATURE,
|
||||
)
|
||||
from .storage.entity_store_schema import ConfClimateFanSpeedMode, ConfSetpointShiftMode
|
||||
from .storage.util import ConfigExtractor
|
||||
|
||||
ATTR_COMMAND_VALUE = "command_value"
|
||||
CONTROLLER_MODES_INV = {value: key for key, value in CONTROLLER_MODES.items()}
|
||||
@@ -53,12 +94,30 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up climate(s) for KNX platform."""
|
||||
knx_module = hass.data[KNX_MODULE_KEY]
|
||||
config: list[ConfigType] = knx_module.config_yaml[Platform.CLIMATE]
|
||||
|
||||
async_add_entities(
|
||||
KNXClimate(knx_module, entity_config) for entity_config in config
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.CLIMATE,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiClimate,
|
||||
),
|
||||
)
|
||||
|
||||
entities: list[KnxYamlEntity | KnxUiEntity] = []
|
||||
if yaml_platform_config := knx_module.config_yaml.get(Platform.CLIMATE):
|
||||
entities.extend(
|
||||
KnxYamlClimate(knx_module, entity_config)
|
||||
for entity_config in yaml_platform_config
|
||||
)
|
||||
if ui_config := knx_module.config_store.data["entities"].get(Platform.CLIMATE):
|
||||
entities.extend(
|
||||
KnxUiClimate(knx_module, unique_id, config)
|
||||
for unique_id, config in ui_config.items()
|
||||
)
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
|
||||
"""Return a KNX Climate device to be used within XKNX."""
|
||||
@@ -99,8 +158,8 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
|
||||
group_address_heat_cool_state=config.get(
|
||||
ClimateSchema.CONF_HEAT_COOL_STATE_ADDRESS
|
||||
),
|
||||
operation_modes=config.get(ClimateSchema.CONF_OPERATION_MODES),
|
||||
controller_modes=config.get(ClimateSchema.CONF_CONTROLLER_MODES),
|
||||
operation_modes=config.get(ClimateConf.OPERATION_MODES),
|
||||
controller_modes=config.get(ClimateConf.CONTROLLER_MODES),
|
||||
)
|
||||
|
||||
return XknxClimate(
|
||||
@@ -120,24 +179,24 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
|
||||
ClimateSchema.CONF_SETPOINT_SHIFT_STATE_ADDRESS
|
||||
),
|
||||
setpoint_shift_mode=config.get(ClimateSchema.CONF_SETPOINT_SHIFT_MODE),
|
||||
setpoint_shift_max=config[ClimateSchema.CONF_SETPOINT_SHIFT_MAX],
|
||||
setpoint_shift_min=config[ClimateSchema.CONF_SETPOINT_SHIFT_MIN],
|
||||
temperature_step=config[ClimateSchema.CONF_TEMPERATURE_STEP],
|
||||
setpoint_shift_max=config[ClimateConf.SETPOINT_SHIFT_MAX],
|
||||
setpoint_shift_min=config[ClimateConf.SETPOINT_SHIFT_MIN],
|
||||
temperature_step=config[ClimateConf.TEMPERATURE_STEP],
|
||||
group_address_on_off=config.get(ClimateSchema.CONF_ON_OFF_ADDRESS),
|
||||
group_address_on_off_state=config.get(ClimateSchema.CONF_ON_OFF_STATE_ADDRESS),
|
||||
on_off_invert=config[ClimateSchema.CONF_ON_OFF_INVERT],
|
||||
on_off_invert=config[ClimateConf.ON_OFF_INVERT],
|
||||
group_address_active_state=config.get(ClimateSchema.CONF_ACTIVE_STATE_ADDRESS),
|
||||
group_address_command_value_state=config.get(
|
||||
ClimateSchema.CONF_COMMAND_VALUE_STATE_ADDRESS
|
||||
),
|
||||
min_temp=config.get(ClimateSchema.CONF_MIN_TEMP),
|
||||
max_temp=config.get(ClimateSchema.CONF_MAX_TEMP),
|
||||
min_temp=config.get(ClimateConf.MIN_TEMP),
|
||||
max_temp=config.get(ClimateConf.MAX_TEMP),
|
||||
mode=climate_mode,
|
||||
group_address_fan_speed=config.get(ClimateSchema.CONF_FAN_SPEED_ADDRESS),
|
||||
group_address_fan_speed_state=config.get(
|
||||
ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS
|
||||
),
|
||||
fan_speed_mode=config[ClimateSchema.CONF_FAN_SPEED_MODE],
|
||||
fan_speed_mode=config[ClimateConf.FAN_SPEED_MODE],
|
||||
group_address_swing=config.get(ClimateSchema.CONF_SWING_ADDRESS),
|
||||
group_address_swing_state=config.get(ClimateSchema.CONF_SWING_STATE_ADDRESS),
|
||||
group_address_horizontal_swing=config.get(
|
||||
@@ -152,91 +211,195 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
|
||||
)
|
||||
|
||||
|
||||
class KNXClimate(KnxYamlEntity, ClimateEntity):
|
||||
def _create_climate_ui(xknx: XKNX, conf: ConfigExtractor, name: str) -> XknxClimate:
|
||||
"""Return a KNX Climate device to be used within XKNX from UI config."""
|
||||
sync_state = conf.get(CONF_SYNC_STATE)
|
||||
op_modes: list[str | HVACOperationMode] = list(HVACOperationMode)
|
||||
if conf.get(CONF_IGNORE_AUTO_MODE):
|
||||
op_modes.remove(HVACOperationMode.AUTO)
|
||||
|
||||
climate_mode = XknxClimateMode(
|
||||
xknx,
|
||||
name=f"{name} Mode",
|
||||
group_address_operation_mode=conf.get_write(CONF_GA_OPERATION_MODE),
|
||||
group_address_operation_mode_state=conf.get_state_and_passive(
|
||||
CONF_GA_OPERATION_MODE
|
||||
),
|
||||
group_address_operation_mode_comfort=conf.get_write_and_passive(
|
||||
CONF_GA_OP_MODE_COMFORT
|
||||
),
|
||||
group_address_operation_mode_economy=conf.get_write_and_passive(
|
||||
CONF_GA_OP_MODE_ECO
|
||||
),
|
||||
group_address_operation_mode_protection=conf.get_write_and_passive(
|
||||
CONF_GA_OP_MODE_PROTECTION
|
||||
),
|
||||
group_address_operation_mode_standby=conf.get_write_and_passive(
|
||||
CONF_GA_OP_MODE_STANDBY
|
||||
),
|
||||
group_address_controller_status=conf.get_write(CONF_GA_CONTROLLER_STATUS),
|
||||
group_address_controller_status_state=conf.get_state_and_passive(
|
||||
CONF_GA_CONTROLLER_STATUS
|
||||
),
|
||||
group_address_controller_mode=conf.get_write(CONF_GA_CONTROLLER_MODE),
|
||||
group_address_controller_mode_state=conf.get_state_and_passive(
|
||||
CONF_GA_CONTROLLER_MODE
|
||||
),
|
||||
group_address_heat_cool=conf.get_write(CONF_GA_HEAT_COOL),
|
||||
group_address_heat_cool_state=conf.get_state_and_passive(CONF_GA_HEAT_COOL),
|
||||
sync_state=sync_state,
|
||||
operation_modes=op_modes,
|
||||
)
|
||||
|
||||
sps_mode = None
|
||||
if _sps_dpt := conf.get_dpt(CONF_TARGET_TEMPERATURE, CONF_GA_SETPOINT_SHIFT):
|
||||
sps_mode = (
|
||||
SetpointShiftMode.DPT6010
|
||||
if _sps_dpt == ConfSetpointShiftMode.COUNT
|
||||
else SetpointShiftMode.DPT9002
|
||||
)
|
||||
_fan_speed_dpt = conf.get_dpt(CONF_GA_FAN_SPEED)
|
||||
fan_speed_mode = (
|
||||
FanSpeedMode.STEP
|
||||
if _fan_speed_dpt == ConfClimateFanSpeedMode.STEPS
|
||||
else FanSpeedMode.PERCENT
|
||||
)
|
||||
|
||||
return XknxClimate(
|
||||
xknx,
|
||||
name=name,
|
||||
group_address_temperature=conf.get_state_and_passive(
|
||||
CONF_GA_TEMPERATURE_CURRENT
|
||||
),
|
||||
group_address_target_temperature=conf.get_write(
|
||||
CONF_TARGET_TEMPERATURE, CONF_GA_TEMPERATURE_TARGET
|
||||
),
|
||||
group_address_target_temperature_state=conf.get_state_and_passive(
|
||||
CONF_TARGET_TEMPERATURE, CONF_GA_TEMPERATURE_TARGET
|
||||
),
|
||||
group_address_setpoint_shift=conf.get_write(
|
||||
CONF_TARGET_TEMPERATURE, CONF_GA_SETPOINT_SHIFT
|
||||
),
|
||||
group_address_setpoint_shift_state=conf.get_state_and_passive(
|
||||
CONF_TARGET_TEMPERATURE, CONF_GA_SETPOINT_SHIFT
|
||||
),
|
||||
setpoint_shift_mode=sps_mode,
|
||||
setpoint_shift_max=conf.get(
|
||||
CONF_TARGET_TEMPERATURE, ClimateConf.SETPOINT_SHIFT_MAX, default=6
|
||||
),
|
||||
setpoint_shift_min=conf.get(
|
||||
CONF_TARGET_TEMPERATURE, ClimateConf.SETPOINT_SHIFT_MIN, default=-6
|
||||
),
|
||||
temperature_step=conf.get(
|
||||
CONF_TARGET_TEMPERATURE, ClimateConf.TEMPERATURE_STEP, default=0.1
|
||||
),
|
||||
group_address_on_off=conf.get_write(CONF_GA_ON_OFF),
|
||||
group_address_on_off_state=conf.get_state_and_passive(CONF_GA_ON_OFF),
|
||||
on_off_invert=conf.get(ClimateConf.ON_OFF_INVERT, default=False),
|
||||
group_address_active_state=conf.get_state_and_passive(CONF_GA_ACTIVE),
|
||||
group_address_command_value_state=conf.get_state_and_passive(CONF_GA_VALVE),
|
||||
sync_state=sync_state,
|
||||
min_temp=conf.get(ClimateConf.MIN_TEMP),
|
||||
max_temp=conf.get(ClimateConf.MAX_TEMP),
|
||||
mode=climate_mode,
|
||||
group_address_fan_speed=conf.get_write(CONF_GA_FAN_SPEED),
|
||||
group_address_fan_speed_state=conf.get_state_and_passive(CONF_GA_FAN_SPEED),
|
||||
fan_speed_mode=fan_speed_mode,
|
||||
group_address_humidity_state=conf.get_state_and_passive(
|
||||
CONF_GA_HUMIDITY_CURRENT
|
||||
),
|
||||
group_address_swing=conf.get_write(CONF_GA_FAN_SWING),
|
||||
group_address_swing_state=conf.get_state_and_passive(CONF_GA_FAN_SWING),
|
||||
group_address_horizontal_swing=conf.get_write(CONF_GA_FAN_SWING_HORIZONTAL),
|
||||
group_address_horizontal_swing_state=conf.get_state_and_passive(
|
||||
CONF_GA_FAN_SWING_HORIZONTAL
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _KnxClimate(ClimateEntity, _KnxEntityBase):
|
||||
"""Representation of a KNX climate device."""
|
||||
|
||||
_device: XknxClimate
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = "knx_climate"
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of a KNX climate device."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_climate(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
default_hvac_mode: HVACMode
|
||||
_last_hvac_mode: HVACMode
|
||||
fan_zero_mode: str
|
||||
_fan_modes_percentages: list[int]
|
||||
|
||||
def _init_from_device_config(
|
||||
self,
|
||||
device: XknxClimate,
|
||||
default_hvac_mode: HVACMode,
|
||||
fan_max_step: int,
|
||||
fan_zero_mode: str,
|
||||
) -> None:
|
||||
"""Set attributes that depend on device config."""
|
||||
self.default_hvac_mode = default_hvac_mode
|
||||
# non-OFF HVAC mode to be used when turning on the device without on_off address
|
||||
self._last_hvac_mode = self.default_hvac_mode
|
||||
|
||||
self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
if self._device.supports_on_off:
|
||||
if device.supports_on_off:
|
||||
self._attr_supported_features |= (
|
||||
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
if (
|
||||
self._device.mode is not None
|
||||
and len(self._device.mode.controller_modes) >= 2
|
||||
and HVACControllerMode.OFF in self._device.mode.controller_modes
|
||||
device.mode is not None
|
||||
and len(device.mode.controller_modes) >= 2
|
||||
and HVACControllerMode.OFF in device.mode.controller_modes
|
||||
):
|
||||
self._attr_supported_features |= (
|
||||
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
if (
|
||||
self._device.mode is not None
|
||||
and self._device.mode.operation_modes # empty list when not writable
|
||||
device.mode is not None
|
||||
and device.mode.operation_modes # empty list when not writable
|
||||
):
|
||||
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
|
||||
self._attr_preset_modes = [
|
||||
mode.name.lower() for mode in self._device.mode.operation_modes
|
||||
mode.name.lower() for mode in device.mode.operation_modes
|
||||
]
|
||||
|
||||
fan_max_step = config[ClimateSchema.CONF_FAN_MAX_STEP]
|
||||
self.fan_zero_mode = fan_zero_mode
|
||||
self._fan_modes_percentages = [
|
||||
int(100 * i / fan_max_step) for i in range(fan_max_step + 1)
|
||||
]
|
||||
self.fan_zero_mode: str = config[ClimateSchema.CONF_FAN_ZERO_MODE]
|
||||
|
||||
if self._device.fan_speed is not None and self._device.fan_speed.initialized:
|
||||
if device.fan_speed is not None and device.fan_speed.initialized:
|
||||
self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
|
||||
|
||||
if fan_max_step == 3:
|
||||
self._attr_fan_modes = [
|
||||
self.fan_zero_mode,
|
||||
fan_zero_mode,
|
||||
FAN_LOW,
|
||||
FAN_MEDIUM,
|
||||
FAN_HIGH,
|
||||
]
|
||||
elif fan_max_step == 2:
|
||||
self._attr_fan_modes = [self.fan_zero_mode, FAN_LOW, FAN_HIGH]
|
||||
self._attr_fan_modes = [fan_zero_mode, FAN_LOW, FAN_HIGH]
|
||||
elif fan_max_step == 1:
|
||||
self._attr_fan_modes = [self.fan_zero_mode, FAN_ON]
|
||||
elif self._device.fan_speed_mode == FanSpeedMode.STEP:
|
||||
self._attr_fan_modes = [self.fan_zero_mode] + [
|
||||
self._attr_fan_modes = [fan_zero_mode, FAN_ON]
|
||||
elif device.fan_speed_mode == FanSpeedMode.STEP:
|
||||
self._attr_fan_modes = [fan_zero_mode] + [
|
||||
str(i) for i in range(1, fan_max_step + 1)
|
||||
]
|
||||
else:
|
||||
self._attr_fan_modes = [self.fan_zero_mode] + [
|
||||
self._attr_fan_modes = [fan_zero_mode] + [
|
||||
f"{percentage}%" for percentage in self._fan_modes_percentages[1:]
|
||||
]
|
||||
if self._device.swing.initialized:
|
||||
|
||||
if device.swing.initialized:
|
||||
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
|
||||
self._attr_swing_modes = [SWING_ON, SWING_OFF]
|
||||
|
||||
if self._device.horizontal_swing.initialized:
|
||||
if device.horizontal_swing.initialized:
|
||||
self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
|
||||
self._attr_swing_horizontal_modes = [SWING_ON, SWING_OFF]
|
||||
|
||||
self._attr_target_temperature_step = self._device.temperature_step
|
||||
self._attr_unique_id = (
|
||||
f"{self._device.temperature.group_address_state}_"
|
||||
f"{self._device.target_temperature.group_address_state}_"
|
||||
f"{self._device.target_temperature.group_address}_"
|
||||
f"{self._device._setpoint_shift.group_address}" # noqa: SLF001
|
||||
)
|
||||
self.default_hvac_mode: HVACMode = config[
|
||||
ClimateSchema.CONF_DEFAULT_CONTROLLER_MODE
|
||||
]
|
||||
# non-OFF HVAC mode to be used when turning on the device without on_off address
|
||||
self._last_hvac_mode: HVACMode = self.default_hvac_mode
|
||||
self._attr_target_temperature_step = device.temperature_step
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
@@ -475,3 +638,63 @@ class KNXClimate(KnxYamlEntity, ClimateEntity):
|
||||
if hvac_mode is not HVACMode.OFF:
|
||||
self._last_hvac_mode = hvac_mode
|
||||
super().after_update_callback(device)
|
||||
|
||||
|
||||
class KnxYamlClimate(_KnxClimate, KnxYamlEntity):
|
||||
"""Representation of a KNX climate device configured from YAML."""
|
||||
|
||||
_device: XknxClimate
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of a KNX climate device."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_climate(knx_module.xknx, config),
|
||||
)
|
||||
default_hvac_mode: HVACMode = config[ClimateConf.DEFAULT_CONTROLLER_MODE]
|
||||
fan_max_step = config[ClimateConf.FAN_MAX_STEP]
|
||||
fan_zero_mode: str = config[ClimateConf.FAN_ZERO_MODE]
|
||||
self._init_from_device_config(
|
||||
device=self._device,
|
||||
default_hvac_mode=default_hvac_mode,
|
||||
fan_max_step=fan_max_step,
|
||||
fan_zero_mode=fan_zero_mode,
|
||||
)
|
||||
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = (
|
||||
f"{self._device.temperature.group_address_state}_"
|
||||
f"{self._device.target_temperature.group_address_state}_"
|
||||
f"{self._device.target_temperature.group_address}_"
|
||||
f"{self._device._setpoint_shift.group_address}" # noqa: SLF001
|
||||
)
|
||||
|
||||
|
||||
class KnxUiClimate(_KnxClimate, KnxUiEntity):
|
||||
"""Representation of a KNX climate device configured from the UI."""
|
||||
|
||||
_device: XknxClimate
|
||||
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: ConfigType
|
||||
) -> None:
|
||||
"""Initialize of a KNX climate device."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
unique_id=unique_id,
|
||||
entity_config=config[CONF_ENTITY],
|
||||
)
|
||||
knx_conf = ConfigExtractor(config[DOMAIN])
|
||||
self._device = _create_climate_ui(
|
||||
knx_module.xknx, knx_conf, config[CONF_ENTITY][CONF_NAME]
|
||||
)
|
||||
|
||||
default_hvac_mode = HVACMode(knx_conf.get(ClimateConf.DEFAULT_CONTROLLER_MODE))
|
||||
fan_max_step = knx_conf.get(ClimateConf.FAN_MAX_STEP)
|
||||
fan_zero_mode = knx_conf.get(ClimateConf.FAN_ZERO_MODE)
|
||||
self._init_from_device_config(
|
||||
device=self._device,
|
||||
default_hvac_mode=default_hvac_mode,
|
||||
fan_max_step=fan_max_step,
|
||||
fan_zero_mode=fan_zero_mode,
|
||||
)
|
||||
|
||||
@@ -160,6 +160,7 @@ SUPPORTED_PLATFORMS_YAML: Final = {
|
||||
|
||||
SUPPORTED_PLATFORMS_UI: Final = {
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.SWITCH,
|
||||
@@ -193,3 +194,23 @@ class CoverConf:
|
||||
INVERT_UPDOWN: Final = "invert_updown"
|
||||
INVERT_POSITION: Final = "invert_position"
|
||||
INVERT_ANGLE: Final = "invert_angle"
|
||||
|
||||
|
||||
class ClimateConf:
|
||||
"""Common config keys for climate."""
|
||||
|
||||
MIN_TEMP: Final = "min_temp"
|
||||
MAX_TEMP: Final = "max_temp"
|
||||
TEMPERATURE_STEP: Final = "temperature_step"
|
||||
SETPOINT_SHIFT_MAX: Final = "setpoint_shift_max"
|
||||
SETPOINT_SHIFT_MIN: Final = "setpoint_shift_min"
|
||||
|
||||
ON_OFF_INVERT: Final = "on_off_invert"
|
||||
|
||||
OPERATION_MODES: Final = "operation_modes"
|
||||
CONTROLLER_MODES: Final = "controller_modes"
|
||||
DEFAULT_CONTROLLER_MODE: Final = "default_controller_mode"
|
||||
|
||||
FAN_MAX_STEP: Final = "fan_max_step"
|
||||
FAN_SPEED_MODE: Final = "fan_speed_mode"
|
||||
FAN_ZERO_MODE: Final = "fan_zero_mode"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.10.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2025.10.17.202411"
|
||||
"knx-frontend==2025.10.26.81530"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ from .const import (
|
||||
CONF_STATE_ADDRESS,
|
||||
CONF_SYNC_STATE,
|
||||
KNX_ADDRESS,
|
||||
ClimateConf,
|
||||
ColorTempModes,
|
||||
CoverConf,
|
||||
FanZeroMode,
|
||||
@@ -306,10 +307,7 @@ class ClimateSchema(KNXPlatformSchema):
|
||||
CONF_SETPOINT_SHIFT_ADDRESS = "setpoint_shift_address"
|
||||
CONF_SETPOINT_SHIFT_STATE_ADDRESS = "setpoint_shift_state_address"
|
||||
CONF_SETPOINT_SHIFT_MODE = "setpoint_shift_mode"
|
||||
CONF_SETPOINT_SHIFT_MAX = "setpoint_shift_max"
|
||||
CONF_SETPOINT_SHIFT_MIN = "setpoint_shift_min"
|
||||
CONF_TEMPERATURE_ADDRESS = "temperature_address"
|
||||
CONF_TEMPERATURE_STEP = "temperature_step"
|
||||
CONF_TARGET_TEMPERATURE_ADDRESS = "target_temperature_address"
|
||||
CONF_TARGET_TEMPERATURE_STATE_ADDRESS = "target_temperature_state_address"
|
||||
CONF_OPERATION_MODE_ADDRESS = "operation_mode_address"
|
||||
@@ -327,19 +325,10 @@ class ClimateSchema(KNXPlatformSchema):
|
||||
CONF_OPERATION_MODE_NIGHT_ADDRESS = "operation_mode_night_address"
|
||||
CONF_OPERATION_MODE_COMFORT_ADDRESS = "operation_mode_comfort_address"
|
||||
CONF_OPERATION_MODE_STANDBY_ADDRESS = "operation_mode_standby_address"
|
||||
CONF_OPERATION_MODES = "operation_modes"
|
||||
CONF_CONTROLLER_MODES = "controller_modes"
|
||||
CONF_DEFAULT_CONTROLLER_MODE = "default_controller_mode"
|
||||
CONF_ON_OFF_ADDRESS = "on_off_address"
|
||||
CONF_ON_OFF_STATE_ADDRESS = "on_off_state_address"
|
||||
CONF_ON_OFF_INVERT = "on_off_invert"
|
||||
CONF_MIN_TEMP = "min_temp"
|
||||
CONF_MAX_TEMP = "max_temp"
|
||||
CONF_FAN_SPEED_ADDRESS = "fan_speed_address"
|
||||
CONF_FAN_SPEED_STATE_ADDRESS = "fan_speed_state_address"
|
||||
CONF_FAN_MAX_STEP = "fan_max_step"
|
||||
CONF_FAN_SPEED_MODE = "fan_speed_mode"
|
||||
CONF_FAN_ZERO_MODE = "fan_zero_mode"
|
||||
CONF_HUMIDITY_STATE_ADDRESS = "humidity_state_address"
|
||||
CONF_SWING_ADDRESS = "swing_address"
|
||||
CONF_SWING_STATE_ADDRESS = "swing_state_address"
|
||||
@@ -359,13 +348,13 @@ class ClimateSchema(KNXPlatformSchema):
|
||||
{
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(
|
||||
CONF_SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX
|
||||
ClimateConf.SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX
|
||||
): vol.All(int, vol.Range(min=0, max=32)),
|
||||
vol.Optional(
|
||||
CONF_SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN
|
||||
ClimateConf.SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN
|
||||
): vol.All(int, vol.Range(min=-32, max=0)),
|
||||
vol.Optional(
|
||||
CONF_TEMPERATURE_STEP, default=DEFAULT_TEMPERATURE_STEP
|
||||
ClimateConf.TEMPERATURE_STEP, default=DEFAULT_TEMPERATURE_STEP
|
||||
): vol.All(float, vol.Range(min=0, max=2)),
|
||||
vol.Required(CONF_TEMPERATURE_ADDRESS): ga_list_validator,
|
||||
vol.Required(CONF_TARGET_TEMPERATURE_STATE_ADDRESS): ga_list_validator,
|
||||
@@ -408,29 +397,29 @@ class ClimateSchema(KNXPlatformSchema):
|
||||
vol.Optional(CONF_ON_OFF_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_ON_OFF_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(
|
||||
CONF_ON_OFF_INVERT, default=DEFAULT_ON_OFF_INVERT
|
||||
ClimateConf.ON_OFF_INVERT, default=DEFAULT_ON_OFF_INVERT
|
||||
): cv.boolean,
|
||||
vol.Optional(CONF_OPERATION_MODES): vol.All(
|
||||
vol.Optional(ClimateConf.OPERATION_MODES): vol.All(
|
||||
cv.ensure_list,
|
||||
[backwards_compatible_xknx_climate_enum_member(HVACOperationMode)],
|
||||
),
|
||||
vol.Optional(CONF_CONTROLLER_MODES): vol.All(
|
||||
vol.Optional(ClimateConf.CONTROLLER_MODES): vol.All(
|
||||
cv.ensure_list,
|
||||
[backwards_compatible_xknx_climate_enum_member(HVACControllerMode)],
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT
|
||||
ClimateConf.DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT
|
||||
): vol.Coerce(HVACMode),
|
||||
vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
|
||||
vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
|
||||
vol.Optional(ClimateConf.MIN_TEMP): vol.Coerce(float),
|
||||
vol.Optional(ClimateConf.MAX_TEMP): vol.Coerce(float),
|
||||
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
|
||||
vol.Optional(CONF_FAN_SPEED_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_FAN_SPEED_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_FAN_MAX_STEP, default=3): cv.byte,
|
||||
vol.Optional(ClimateConf.FAN_MAX_STEP, default=3): cv.byte,
|
||||
vol.Optional(
|
||||
CONF_FAN_SPEED_MODE, default=DEFAULT_FAN_SPEED_MODE
|
||||
ClimateConf.FAN_SPEED_MODE, default=DEFAULT_FAN_SPEED_MODE
|
||||
): vol.All(vol.Upper, cv.enum(FanSpeedMode)),
|
||||
vol.Optional(CONF_FAN_ZERO_MODE, default=FAN_OFF): vol.Coerce(
|
||||
vol.Optional(ClimateConf.FAN_ZERO_MODE, default=FAN_OFF): vol.Coerce(
|
||||
FanZeroMode
|
||||
),
|
||||
vol.Optional(CONF_SWING_ADDRESS): ga_list_validator,
|
||||
|
||||
@@ -39,6 +39,10 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DESCRIPTION_PLACEHOLDERS = {
|
||||
"sensor_value_types_url": "https://www.home-assistant.io/integrations/knx/#value-types"
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
@@ -48,6 +52,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_KNX_SEND,
|
||||
service_send_to_knx_bus,
|
||||
schema=SERVICE_KNX_SEND_SCHEMA,
|
||||
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -63,6 +68,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_KNX_EVENT_REGISTER,
|
||||
service_event_register_modify,
|
||||
schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA,
|
||||
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
|
||||
)
|
||||
|
||||
async_register_admin_service(
|
||||
@@ -71,6 +77,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_KNX_EXPOSURE_REGISTER,
|
||||
service_exposure_register_modify,
|
||||
schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA,
|
||||
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
|
||||
)
|
||||
|
||||
async_register_admin_service(
|
||||
|
||||
@@ -14,6 +14,28 @@ CONF_DPT: Final = "dpt"
|
||||
CONF_GA_SENSOR: Final = "ga_sensor"
|
||||
CONF_GA_SWITCH: Final = "ga_switch"
|
||||
|
||||
# Climate
|
||||
CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current"
|
||||
CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current"
|
||||
CONF_TARGET_TEMPERATURE: Final = "target_temperature"
|
||||
CONF_GA_TEMPERATURE_TARGET: Final = "ga_temperature_target"
|
||||
CONF_GA_SETPOINT_SHIFT: Final = "ga_setpoint_shift"
|
||||
CONF_GA_ACTIVE: Final = "ga_active"
|
||||
CONF_GA_VALVE: Final = "ga_valve"
|
||||
CONF_GA_OPERATION_MODE: Final = "ga_operation_mode"
|
||||
CONF_IGNORE_AUTO_MODE: Final = "ignore_auto_mode"
|
||||
CONF_GA_OP_MODE_COMFORT: Final = "ga_operation_mode_comfort"
|
||||
CONF_GA_OP_MODE_ECO: Final = "ga_operation_mode_economy"
|
||||
CONF_GA_OP_MODE_STANDBY: Final = "ga_operation_mode_standby"
|
||||
CONF_GA_OP_MODE_PROTECTION: Final = "ga_operation_mode_protection"
|
||||
CONF_GA_HEAT_COOL: Final = "ga_heat_cool"
|
||||
CONF_GA_ON_OFF: Final = "ga_on_off"
|
||||
CONF_GA_CONTROLLER_MODE: Final = "ga_controller_mode"
|
||||
CONF_GA_CONTROLLER_STATUS: Final = "ga_controller_status"
|
||||
CONF_GA_FAN_SPEED: Final = "ga_fan_speed"
|
||||
CONF_GA_FAN_SWING: Final = "ga_fan_swing"
|
||||
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"
|
||||
|
||||
@@ -4,6 +4,7 @@ from enum import StrEnum, unique
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import HVACMode
|
||||
from homeassistant.const import (
|
||||
CONF_ENTITY_CATEGORY,
|
||||
CONF_ENTITY_ID,
|
||||
@@ -24,8 +25,10 @@ from ..const import (
|
||||
CONF_SYNC_STATE,
|
||||
DOMAIN,
|
||||
SUPPORTED_PLATFORMS_UI,
|
||||
ClimateConf,
|
||||
ColorTempModes,
|
||||
CoverConf,
|
||||
FanZeroMode,
|
||||
)
|
||||
from .const import (
|
||||
CONF_COLOR,
|
||||
@@ -34,27 +37,47 @@ from .const import (
|
||||
CONF_DATA,
|
||||
CONF_DEVICE_INFO,
|
||||
CONF_ENTITY,
|
||||
CONF_GA_ACTIVE,
|
||||
CONF_GA_ANGLE,
|
||||
CONF_GA_BLUE_BRIGHTNESS,
|
||||
CONF_GA_BLUE_SWITCH,
|
||||
CONF_GA_BRIGHTNESS,
|
||||
CONF_GA_COLOR,
|
||||
CONF_GA_COLOR_TEMP,
|
||||
CONF_GA_CONTROLLER_MODE,
|
||||
CONF_GA_CONTROLLER_STATUS,
|
||||
CONF_GA_FAN_SPEED,
|
||||
CONF_GA_FAN_SWING,
|
||||
CONF_GA_FAN_SWING_HORIZONTAL,
|
||||
CONF_GA_GREEN_BRIGHTNESS,
|
||||
CONF_GA_GREEN_SWITCH,
|
||||
CONF_GA_HEAT_COOL,
|
||||
CONF_GA_HUE,
|
||||
CONF_GA_HUMIDITY_CURRENT,
|
||||
CONF_GA_ON_OFF,
|
||||
CONF_GA_OP_MODE_COMFORT,
|
||||
CONF_GA_OP_MODE_ECO,
|
||||
CONF_GA_OP_MODE_PROTECTION,
|
||||
CONF_GA_OP_MODE_STANDBY,
|
||||
CONF_GA_OPERATION_MODE,
|
||||
CONF_GA_POSITION_SET,
|
||||
CONF_GA_POSITION_STATE,
|
||||
CONF_GA_RED_BRIGHTNESS,
|
||||
CONF_GA_RED_SWITCH,
|
||||
CONF_GA_SATURATION,
|
||||
CONF_GA_SENSOR,
|
||||
CONF_GA_SETPOINT_SHIFT,
|
||||
CONF_GA_STEP,
|
||||
CONF_GA_STOP,
|
||||
CONF_GA_SWITCH,
|
||||
CONF_GA_TEMPERATURE_CURRENT,
|
||||
CONF_GA_TEMPERATURE_TARGET,
|
||||
CONF_GA_UP_DOWN,
|
||||
CONF_GA_VALVE,
|
||||
CONF_GA_WHITE_BRIGHTNESS,
|
||||
CONF_GA_WHITE_SWITCH,
|
||||
CONF_IGNORE_AUTO_MODE,
|
||||
CONF_TARGET_TEMPERATURE,
|
||||
)
|
||||
from .knx_selector import (
|
||||
AllSerializeFirst,
|
||||
@@ -109,7 +132,9 @@ BINARY_SENSOR_KNX_SCHEMA = vol.Schema(
|
||||
min=0, max=600, step=0.1, unit_of_measurement="s"
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
vol.Required(CONF_SYNC_STATE, default=True): SyncStateSelector(
|
||||
allow_false=True
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -311,8 +336,151 @@ SWITCH_KNX_SCHEMA = vol.Schema(
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@unique
|
||||
class ConfSetpointShiftMode(StrEnum):
|
||||
"""Enum for setpoint shift mode."""
|
||||
|
||||
COUNT = "6.010"
|
||||
FLOAT = "9.002"
|
||||
|
||||
|
||||
@unique
|
||||
class ConfClimateFanSpeedMode(StrEnum):
|
||||
"""Enum for climate fan speed mode."""
|
||||
|
||||
PERCENTAGE = "5.001"
|
||||
STEPS = "5.010"
|
||||
|
||||
|
||||
CLIMATE_KNX_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_GA_TEMPERATURE_CURRENT): GASelector(
|
||||
write=False, state_required=True, valid_dpt="9.001"
|
||||
),
|
||||
vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector(
|
||||
write=False, valid_dpt="9.002"
|
||||
),
|
||||
vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect(
|
||||
GroupSelectOption(
|
||||
translation_key="group_direct_temp",
|
||||
schema={
|
||||
vol.Required(CONF_GA_TEMPERATURE_TARGET): GASelector(
|
||||
write_required=True, valid_dpt="9.001"
|
||||
),
|
||||
vol.Required(
|
||||
ClimateConf.MIN_TEMP, default=7
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=-20, max=80, step=1, unit_of_measurement="°C"
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
ClimateConf.MAX_TEMP, default=28
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=100, step=1, unit_of_measurement="°C"
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
ClimateConf.TEMPERATURE_STEP, default=0.1
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0.1, max=2, step=0.1, unit_of_measurement="K"
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
GroupSelectOption(
|
||||
translation_key="group_setpoint_shift",
|
||||
schema={
|
||||
vol.Required(CONF_GA_TEMPERATURE_TARGET): GASelector(
|
||||
write=False, state_required=True, valid_dpt="9.001"
|
||||
),
|
||||
vol.Required(CONF_GA_SETPOINT_SHIFT): GASelector(
|
||||
write_required=True,
|
||||
state_required=True,
|
||||
dpt=ConfSetpointShiftMode,
|
||||
),
|
||||
vol.Required(
|
||||
ClimateConf.SETPOINT_SHIFT_MIN, default=-6
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=-32, max=0, step=1, unit_of_measurement="K"
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
ClimateConf.SETPOINT_SHIFT_MAX, default=6
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=32, step=1, unit_of_measurement="K"
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
ClimateConf.TEMPERATURE_STEP, default=0.1
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0.1, max=2, step=0.1, unit_of_measurement="K"
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
collapsible=False,
|
||||
),
|
||||
"section_activity": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_GA_ACTIVE): GASelector(write=False, valid_dpt="1"),
|
||||
vol.Optional(CONF_GA_VALVE): GASelector(write=False, valid_dpt="5.001"),
|
||||
"section_operation_mode": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_GA_OPERATION_MODE): GASelector(valid_dpt="20.102"),
|
||||
vol.Optional(CONF_IGNORE_AUTO_MODE): selector.BooleanSelector(),
|
||||
"section_operation_mode_individual": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_GA_OP_MODE_COMFORT): GASelector(state=False, valid_dpt="1"),
|
||||
vol.Optional(CONF_GA_OP_MODE_ECO): GASelector(state=False, valid_dpt="1"),
|
||||
vol.Optional(CONF_GA_OP_MODE_STANDBY): GASelector(state=False, valid_dpt="1"),
|
||||
vol.Optional(CONF_GA_OP_MODE_PROTECTION): GASelector(
|
||||
state=False, valid_dpt="1"
|
||||
),
|
||||
"section_heat_cool": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_GA_HEAT_COOL): GASelector(valid_dpt="1.100"),
|
||||
"section_on_off": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_GA_ON_OFF): GASelector(valid_dpt="1"),
|
||||
vol.Optional(ClimateConf.ON_OFF_INVERT): selector.BooleanSelector(),
|
||||
"section_controller_mode": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_GA_CONTROLLER_MODE): GASelector(valid_dpt="20.105"),
|
||||
vol.Optional(CONF_GA_CONTROLLER_STATUS): GASelector(write=False),
|
||||
vol.Required(
|
||||
ClimateConf.DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT
|
||||
): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=list(HVACMode),
|
||||
translation_key="component.climate.selector.hvac_mode",
|
||||
)
|
||||
),
|
||||
"section_fan": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_GA_FAN_SPEED): GASelector(dpt=ConfClimateFanSpeedMode),
|
||||
vol.Required(ClimateConf.FAN_MAX_STEP, default=3): AllSerializeFirst(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(min=1, max=100, step=1)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
vol.Required(
|
||||
ClimateConf.FAN_ZERO_MODE, default=FanZeroMode.OFF
|
||||
): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=list(FanZeroMode),
|
||||
translation_key="component.knx.config_panel.entities.create.climate.knx.fan_zero_mode",
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_GA_FAN_SWING): GASelector(valid_dpt="1"),
|
||||
vol.Optional(CONF_GA_FAN_SWING_HORIZONTAL): GASelector(valid_dpt="1"),
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
},
|
||||
)
|
||||
|
||||
KNX_SCHEMA_FOR_PLATFORM = {
|
||||
Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA,
|
||||
Platform.CLIMATE: CLIMATE_KNX_SCHEMA,
|
||||
Platform.COVER: COVER_KNX_SCHEMA,
|
||||
Platform.LIGHT: LIGHT_KNX_SCHEMA,
|
||||
Platform.SWITCH: SWITCH_KNX_SCHEMA,
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
},
|
||||
"type": {
|
||||
"name": "Value type",
|
||||
"description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)."
|
||||
"description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see {sensor_value_types_url})."
|
||||
},
|
||||
"response": {
|
||||
"name": "Send as Response",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"type": {
|
||||
"name": "Value type",
|
||||
"description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)."
|
||||
"description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see {sensor_value_types_url})."
|
||||
},
|
||||
"remove": {
|
||||
"name": "Remove event registration",
|
||||
@@ -315,7 +315,7 @@
|
||||
},
|
||||
"type": {
|
||||
"name": "Value type",
|
||||
"description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)."
|
||||
"description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see {sensor_value_types_url})."
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
@@ -412,6 +412,164 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"description": "The KNX climate platform is used as an interface to heating actuators, HVAC gateways, etc.",
|
||||
"knx": {
|
||||
"ga_temperature_current": {
|
||||
"label": "Current temperature"
|
||||
},
|
||||
"ga_humidity_current": {
|
||||
"label": "Current humidity"
|
||||
},
|
||||
"target_temperature": {
|
||||
"title": "Target temperature",
|
||||
"description": "Set the target temperature.",
|
||||
"options": {
|
||||
"group_direct_temp": {
|
||||
"label": "Absolute setpoint",
|
||||
"description": "Set the target temperature by an absolute value."
|
||||
},
|
||||
"group_setpoint_shift": {
|
||||
"label": "Setpoint shift",
|
||||
"description": "Shift the target temperature from a base setpoint."
|
||||
}
|
||||
},
|
||||
"ga_temperature_target": {
|
||||
"label": "Target temperature",
|
||||
"description": "Current absolute target temperature."
|
||||
},
|
||||
"min_temp": {
|
||||
"label": "Minimum temperature",
|
||||
"description": "Minimum temperature that can be set."
|
||||
},
|
||||
"max_temp": {
|
||||
"label": "Maximum temperature",
|
||||
"description": "Maximum temperature that can be set."
|
||||
},
|
||||
"temperature_step": {
|
||||
"label": "Temperature step",
|
||||
"description": "Smallest step size to change the temperature. For setpoint shift configurations this sets the scale factor of the shift value."
|
||||
},
|
||||
"ga_setpoint_shift": {
|
||||
"label": "Setpoint shift",
|
||||
"description": "Target temperature deviation from a base setpoint."
|
||||
},
|
||||
"setpoint_shift_min": {
|
||||
"label": "Minimum setpoint shift",
|
||||
"description": "Lowest allowed deviation from the base setpoint."
|
||||
},
|
||||
"setpoint_shift_max": {
|
||||
"label": "Maximum setpoint shift",
|
||||
"description": "Highest allowed deviation from the base setpoint."
|
||||
}
|
||||
},
|
||||
"section_activity": {
|
||||
"title": "Activity",
|
||||
"description": "Determine if the device is active or idle."
|
||||
},
|
||||
"ga_active": {
|
||||
"label": "Active",
|
||||
"description": "Binary value indicating if the device is active or idle. If configured, this takes precedence over valve position."
|
||||
},
|
||||
"ga_valve": {
|
||||
"label": "Valve position",
|
||||
"description": "Current control value / valve position in percent. `0` sets the climate entity to idle."
|
||||
},
|
||||
"section_operation_mode": {
|
||||
"title": "Operation mode",
|
||||
"description": "Set the preset mode of the device."
|
||||
},
|
||||
"ga_operation_mode": {
|
||||
"label": "Operation mode",
|
||||
"description": "Current operation mode."
|
||||
},
|
||||
"ignore_auto_mode": {
|
||||
"label": "Ignore auto mode",
|
||||
"description": "Enable when your controller doesn't support `auto` mode. It will be ignored by the integration then."
|
||||
},
|
||||
"section_operation_mode_individual": {
|
||||
"title": "Individual operation modes",
|
||||
"description": "Set the preset mode of the device using individual group addresses."
|
||||
},
|
||||
"ga_operation_mode_comfort": {
|
||||
"label": "Comfort mode"
|
||||
},
|
||||
"ga_operation_mode_economy": {
|
||||
"label": "Economy mode"
|
||||
},
|
||||
"ga_operation_mode_standby": {
|
||||
"label": "Standby mode"
|
||||
},
|
||||
"ga_operation_mode_protection": {
|
||||
"label": "Building protection mode"
|
||||
},
|
||||
"section_heat_cool": {
|
||||
"title": "Heating/Cooling",
|
||||
"description": "Set whether the device is in heating or cooling mode."
|
||||
},
|
||||
"ga_heat_cool": {
|
||||
"label": "Heating/Cooling"
|
||||
},
|
||||
"section_on_off": {
|
||||
"title": "On/Off",
|
||||
"description": "Turn the device on or off."
|
||||
},
|
||||
"ga_on_off": {
|
||||
"label": "On/Off"
|
||||
},
|
||||
"on_off_invert": {
|
||||
"label": "[%key:component::knx::config_panel::entities::create::binary_sensor::knx::invert::label%]",
|
||||
"description": "[%key:component::knx::config_panel::entities::create::binary_sensor::knx::invert::description%]"
|
||||
},
|
||||
"section_controller_mode": {
|
||||
"title": "Controller mode",
|
||||
"description": "Set the mode of the climate device."
|
||||
},
|
||||
"ga_controller_mode": {
|
||||
"label": "Controller mode"
|
||||
},
|
||||
"ga_controller_status": {
|
||||
"label": "Controller status",
|
||||
"description": "HVAC controller mode and preset status. Eberle Status octet (KNX AN 097/07 rev 3) non-standardized DPT."
|
||||
},
|
||||
"default_controller_mode": {
|
||||
"label": "Default mode",
|
||||
"description": "Climate mode to be set on initialization."
|
||||
},
|
||||
"section_fan": {
|
||||
"title": "Fan",
|
||||
"description": "Configuration for fan control (AC units)."
|
||||
},
|
||||
"ga_fan_speed": {
|
||||
"label": "Fan speed",
|
||||
"description": "Set the current fan speed.",
|
||||
"options": {
|
||||
"5_001": "Percent",
|
||||
"5_010": "Steps"
|
||||
}
|
||||
},
|
||||
"fan_max_step": {
|
||||
"label": "Fan steps",
|
||||
"description": "The maximum amount of steps for the fan."
|
||||
},
|
||||
"fan_zero_mode": {
|
||||
"label": "Zero fan speed mode",
|
||||
"description": "Set the mode that represents fan speed `0`.",
|
||||
"options": {
|
||||
"off": "[%key:common::state::off%]",
|
||||
"auto": "[%key:common::state::auto%]"
|
||||
}
|
||||
},
|
||||
"ga_fan_swing": {
|
||||
"label": "Fan swing",
|
||||
"description": "Toggle (vertical) fan swing mode. Use this if only one direction is supported."
|
||||
},
|
||||
"ga_fan_swing_horizontal": {
|
||||
"label": "Fan horizontal swing",
|
||||
"description": "Toggle horizontal fan swing mode."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cover": {
|
||||
"description": "The KNX cover platform is used as an interface to shutter actuators.",
|
||||
"knx": {
|
||||
|
||||
@@ -20,13 +20,14 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .adapter import MatterAdapter
|
||||
from .addon import get_addon_manager
|
||||
@@ -40,10 +41,13 @@ from .helpers import (
|
||||
node_from_ha_device_id,
|
||||
)
|
||||
from .models import MatterDeviceInfo
|
||||
from .services import async_setup_services
|
||||
|
||||
CONNECT_TIMEOUT = 10
|
||||
LISTEN_READY_TIMEOUT = 30
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
@callback
|
||||
@cache
|
||||
@@ -64,6 +68,12 @@ def get_matter_device_info(
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Matter integration services."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Matter from a config entry."""
|
||||
if use_addon := entry.data.get(CONF_USE_ADDON):
|
||||
|
||||
@@ -155,4 +155,18 @@ DISCOVERY_SCHEMAS = [
|
||||
required_attributes=(clusters.SmokeCoAlarm.Attributes.AcceptedCommandList,),
|
||||
value_contains=clusters.SmokeCoAlarm.Commands.SelfTestRequest.command_id,
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BUTTON,
|
||||
entity_description=MatterButtonEntityDescription(
|
||||
key="WaterHeaterManagementCancelBoost",
|
||||
translation_key="cancel_boost",
|
||||
command=clusters.WaterHeaterManagement.Commands.CancelBoost,
|
||||
),
|
||||
entity_class=MatterCommandButton,
|
||||
required_attributes=(
|
||||
clusters.WaterHeaterManagement.Attributes.AcceptedCommandList,
|
||||
),
|
||||
value_contains=clusters.WaterHeaterManagement.Commands.CancelBoost.command_id,
|
||||
allow_multi=True, # Also used in water_heater
|
||||
),
|
||||
]
|
||||
|
||||
@@ -163,5 +163,10 @@
|
||||
"default": "mdi:shield-lock"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"water_heater_boost": {
|
||||
"service": "mdi:water-boiler"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
homeassistant/components/matter/services.py
Normal file
38
homeassistant/components/matter/services.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Services for Matter devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
ATTR_DURATION = "duration"
|
||||
ATTR_EMERGENCY_BOOST = "emergency_boost"
|
||||
ATTR_TEMPORARY_SETPOINT = "temporary_setpoint"
|
||||
|
||||
SERVICE_WATER_HEATER_BOOST = "water_heater_boost"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the Matter services."""
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_WATER_HEATER_BOOST,
|
||||
entity_domain=WATER_HEATER_DOMAIN,
|
||||
schema={
|
||||
# duration >=1
|
||||
vol.Required(ATTR_DURATION): vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
vol.Optional(ATTR_EMERGENCY_BOOST): cv.boolean,
|
||||
vol.Optional(ATTR_TEMPORARY_SETPOINT): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=30, max=65)
|
||||
),
|
||||
},
|
||||
func="async_set_boost",
|
||||
)
|
||||
26
homeassistant/components/matter/services.yaml
Normal file
26
homeassistant/components/matter/services.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
water_heater_boost:
|
||||
target:
|
||||
entity:
|
||||
domain: water_heater
|
||||
fields:
|
||||
duration:
|
||||
selector:
|
||||
number:
|
||||
min: 60
|
||||
max: 14400
|
||||
step: 60
|
||||
mode: box
|
||||
default: 3600
|
||||
required: true
|
||||
emergency_boost:
|
||||
selector:
|
||||
boolean:
|
||||
default: false
|
||||
temporary_setpoint:
|
||||
selector:
|
||||
number:
|
||||
min: 30
|
||||
max: 65
|
||||
step: 1
|
||||
mode: slider
|
||||
default: 65
|
||||
@@ -106,6 +106,9 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"cancel_boost": {
|
||||
"name": "Cancel boost"
|
||||
},
|
||||
"pause": {
|
||||
"name": "[%key:common::action::pause%]"
|
||||
},
|
||||
@@ -590,6 +593,24 @@
|
||||
"description": "The Matter device to add to the other Matter network."
|
||||
}
|
||||
}
|
||||
},
|
||||
"water_heater_boost": {
|
||||
"name": "Boost water heater",
|
||||
"description": "Enables water heater boost for a specific duration.",
|
||||
"fields": {
|
||||
"duration": {
|
||||
"name": "Duration",
|
||||
"description": "Boost duration"
|
||||
},
|
||||
"emergency_boost": {
|
||||
"name": "Emergency boost",
|
||||
"description": "Whether to enable emergency boost mode."
|
||||
},
|
||||
"temporary_setpoint": {
|
||||
"name": "Temporary setpoint",
|
||||
"description": "Temporary setpoint temperature in Celsius during the boost period."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
from chip.clusters import Objects as clusters
|
||||
from chip.clusters.Types import Nullable
|
||||
from matter_server.client.models import device_types
|
||||
from matter_server.common.errors import MatterError
|
||||
from matter_server.common.helpers.util import create_attribute_path_from_attribute
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
@@ -25,6 +27,7 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .entity import MatterEntity, MatterEntityDescription
|
||||
@@ -40,6 +43,8 @@ WATER_HEATER_SYSTEM_MODE_MAP = {
|
||||
STATE_OFF: 0,
|
||||
}
|
||||
|
||||
DEFAULT_BOOST_DURATION = 3600 # 1 hour
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -78,6 +83,30 @@ class MatterWaterHeater(MatterEntity, WaterHeaterEntity):
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_platform_translation_key = "water_heater"
|
||||
|
||||
async def async_set_boost(
|
||||
self,
|
||||
duration: int,
|
||||
emergency_boost: bool = False,
|
||||
temporary_setpoint: int | None = None,
|
||||
) -> None:
|
||||
"""Set boost."""
|
||||
boost_info: clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct(
|
||||
duration=duration,
|
||||
emergencyBoost=emergency_boost,
|
||||
temporarySetpoint=(
|
||||
temporary_setpoint * TEMPERATURE_SCALING_FACTOR
|
||||
if temporary_setpoint is not None
|
||||
else Nullable
|
||||
),
|
||||
)
|
||||
try:
|
||||
await self.send_device_command(
|
||||
clusters.WaterHeaterManagement.Commands.Boost(boostInfo=boost_info)
|
||||
)
|
||||
except MatterError as err:
|
||||
raise HomeAssistantError(f"Error sending Boost command: {err}") from err
|
||||
self._update_from_device()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
target_temperature: float | None = kwargs.get(ATTR_TEMPERATURE)
|
||||
@@ -94,11 +123,11 @@ class MatterWaterHeater(MatterEntity, WaterHeaterEntity):
|
||||
async def async_set_operation_mode(self, operation_mode: str) -> None:
|
||||
"""Set new operation mode."""
|
||||
self._attr_current_operation = operation_mode
|
||||
# Boost 1h (3600s)
|
||||
# Use the constant for boost duration
|
||||
boost_info: type[
|
||||
clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct
|
||||
] = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct(
|
||||
duration=3600
|
||||
duration=DEFAULT_BOOST_DURATION
|
||||
)
|
||||
system_mode_value = WATER_HEATER_SYSTEM_MODE_MAP[operation_mode]
|
||||
await self.write_attribute(
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2025.09.26"],
|
||||
"requirements": ["yt-dlp[default]==2025.10.22"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -
|
||||
|
||||
Causes for this is config entry options changing.
|
||||
"""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
|
||||
@callback
|
||||
|
||||
@@ -3815,9 +3815,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
try_connection,
|
||||
new_entry_data,
|
||||
):
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry, data=new_entry_data
|
||||
)
|
||||
return self.async_update_and_abort(reauth_entry, data=new_entry_data)
|
||||
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
@@ -3863,7 +3861,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if can_connect:
|
||||
if is_reconfigure:
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
reconfigure_entry,
|
||||
data=validated_user_input,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_PROFILE_ID, DOMAIN
|
||||
@@ -23,11 +24,40 @@ AUTH_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns:
|
||||
"""Check if credentials are valid."""
|
||||
async def async_init_nextdns(
|
||||
hass: HomeAssistant, api_key: str, profile_id: str | None = None
|
||||
) -> NextDns:
|
||||
"""Check if credentials and profile_id are valid."""
|
||||
websession = async_get_clientsession(hass)
|
||||
|
||||
return await NextDns.create(websession, api_key)
|
||||
nextdns = await NextDns.create(websession, api_key)
|
||||
|
||||
if profile_id:
|
||||
if not any(profile.id == profile_id for profile in nextdns.profiles):
|
||||
raise ProfileNotAvailable
|
||||
|
||||
return nextdns
|
||||
|
||||
|
||||
async def async_validate_new_api_key(
|
||||
hass: HomeAssistant, user_input: dict[str, Any], profile_id: str
|
||||
) -> dict[str, str]:
|
||||
"""Validate the new API key during reconfiguration or reauth."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
await async_init_nextdns(hass, user_input[CONF_API_KEY], profile_id)
|
||||
except InvalidApiKeyError:
|
||||
errors["base"] = "invalid_api_key"
|
||||
except (ApiError, ClientConnectorError, RetryError, TimeoutError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except ProfileNotAvailable:
|
||||
errors["base"] = "profile_not_available"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@@ -107,20 +137,19 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
errors: dict[str, str] = {}
|
||||
entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
await async_init_nextdns(self.hass, user_input[CONF_API_KEY])
|
||||
except InvalidApiKeyError:
|
||||
errors["base"] = "invalid_api_key"
|
||||
except (ApiError, ClientConnectorError, RetryError, TimeoutError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
errors = await async_validate_new_api_key(
|
||||
self.hass, user_input, entry.data[CONF_PROFILE_ID]
|
||||
)
|
||||
if errors.get("base") == "profile_not_available":
|
||||
return self.async_abort(reason="profile_not_available")
|
||||
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=user_input
|
||||
entry,
|
||||
data_updates=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -128,3 +157,33 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=AUTH_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a reconfiguration flow initialized by the user."""
|
||||
errors: dict[str, str] = {}
|
||||
entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
errors = await async_validate_new_api_key(
|
||||
self.hass, user_input, entry.data[CONF_PROFILE_ID]
|
||||
)
|
||||
if errors.get("base") == "profile_not_available":
|
||||
return self.async_abort(reason="profile_not_available")
|
||||
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
entry,
|
||||
data_updates=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=AUTH_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class ProfileNotAvailable(HomeAssistantError):
|
||||
"""Error to indicate that the profile is not available after reconfig/reauth."""
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["nextdns"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["nextdns==4.1.0"]
|
||||
}
|
||||
|
||||
@@ -68,9 +68,7 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: todo
|
||||
comment: Allow API key to be changed in the re-configure flow.
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: This integration doesn't have any cases where raising an issue is needed.
|
||||
|
||||
@@ -24,6 +24,14 @@
|
||||
"data_description": {
|
||||
"api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -33,7 +41,9 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This NextDNS profile is already configured.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"profile_not_available": "The configured NextDNS profile is no longer available in your account. Remove the configuration and configure the integration again.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
|
||||
@@ -36,9 +36,9 @@ rules:
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/portainer",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyportainer==1.0.7"]
|
||||
"requirements": ["pyportainer==1.0.9"]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ from roborock import (
|
||||
RoborockInvalidUserAgreement,
|
||||
RoborockNoUserAgreement,
|
||||
)
|
||||
from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData
|
||||
from roborock.data import DeviceData, HomeDataDevice, HomeDataProduct, UserData
|
||||
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
|
||||
from roborock.version_a01_apis import RoborockMqttClientA01
|
||||
from roborock.web_api import RoborockApiClient
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from roborock.containers import RoborockStateCode
|
||||
from roborock.data import RoborockStateCode
|
||||
from roborock.roborock_typing import DeviceProp
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
|
||||
@@ -7,7 +7,7 @@ from copy import deepcopy
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from roborock.containers import UserData
|
||||
from roborock.data import UserData
|
||||
from roborock.exceptions import (
|
||||
RoborockAccountDoesNotExist,
|
||||
RoborockException,
|
||||
|
||||
@@ -10,13 +10,13 @@ import logging
|
||||
|
||||
from propcache.api import cached_property
|
||||
from roborock import HomeDataRoom
|
||||
from roborock.code_mappings import RoborockCategory
|
||||
from roborock.containers import (
|
||||
from roborock.data import (
|
||||
DeviceData,
|
||||
HomeDataDevice,
|
||||
HomeDataProduct,
|
||||
HomeDataScene,
|
||||
NetworkInfo,
|
||||
RoborockCategory,
|
||||
UserData,
|
||||
)
|
||||
from roborock.exceptions import RoborockException
|
||||
@@ -38,6 +38,11 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
@@ -274,6 +279,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
||||
try:
|
||||
await self.api.async_connect()
|
||||
await self.api.ping()
|
||||
async_delete_issue(
|
||||
self.hass, DOMAIN, f"cloud_api_used_{self.duid_slug}"
|
||||
)
|
||||
except RoborockException:
|
||||
_LOGGER.warning(
|
||||
"Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance",
|
||||
@@ -284,6 +292,19 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
||||
self.api = self.cloud_api
|
||||
self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL
|
||||
self._is_cloud_api = True
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"cloud_api_used_{self.duid_slug}",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="cloud_api_used",
|
||||
translation_placeholders={
|
||||
"device_name": self.roborock_device_info.device.name
|
||||
},
|
||||
learn_more_url="https://www.home-assistant.io/integrations/roborock/#the-integration-tells-me-it-cannot-reach-my-vacuum-and-is-using-the-cloud-api-and-that-this-is-not-supported-or-i-am-having-any-networking-issues",
|
||||
)
|
||||
|
||||
# Right now this should never be called if the cloud api is the primary api,
|
||||
# but in the future if it is, a new else should be added.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
from roborock.api import RoborockClient
|
||||
from roborock.command_cache import CacheableAttribute
|
||||
from roborock.containers import Consumable, Status
|
||||
from roborock.data import Consumable, Status
|
||||
from roborock.exceptions import RoborockException
|
||||
from roborock.roborock_message import RoborockDataProtocol
|
||||
from roborock.roborock_typing import RoborockCommand
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"python-roborock==2.50.2",
|
||||
"python-roborock==3.3.3",
|
||||
"vacuum-map-parser-roborock==0.1.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo
|
||||
from roborock.data import HomeDataDevice, HomeDataProduct, NetworkInfo
|
||||
from roborock.roborock_typing import DeviceProp
|
||||
from vacuum_map_parser_base.map_data import MapData
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from roborock.code_mappings import RoborockDockDustCollectionModeCode
|
||||
from roborock.data import RoborockDockDustCollectionModeCode
|
||||
from roborock.roborock_message import RoborockDataProtocol
|
||||
from roborock.roborock_typing import DeviceProp, RoborockCommand
|
||||
|
||||
|
||||
@@ -6,12 +6,15 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
|
||||
from roborock.code_mappings import DyadError, RoborockDyadStateCode, ZeoError, ZeoState
|
||||
from roborock.containers import (
|
||||
from roborock.data import (
|
||||
DyadError,
|
||||
RoborockDockErrorCode,
|
||||
RoborockDockTypeCode,
|
||||
RoborockDyadStateCode,
|
||||
RoborockErrorCode,
|
||||
RoborockStateCode,
|
||||
ZeoError,
|
||||
ZeoState,
|
||||
)
|
||||
from roborock.roborock_message import (
|
||||
RoborockDataProtocol,
|
||||
|
||||
@@ -39,6 +39,13 @@
|
||||
"wrong_account": "Wrong account: Please authenticate with the right account."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"cloud_api_used": {
|
||||
"title": "Cloud API used",
|
||||
"description": "The Roborock integration is unable to connect directly to {device_name} and falling back to the cloud API. This is not recommended as it can lead to rate limiting. Please make your vacuum accessible on the local network by your Home Assistant instance."
|
||||
}
|
||||
},
|
||||
|
||||
"options": {
|
||||
"step": {
|
||||
"drawables": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from roborock.code_mappings import RoborockStateCode
|
||||
from roborock.data import RoborockStateCode
|
||||
from roborock.roborock_message import RoborockDataProtocol
|
||||
from roborock.roborock_typing import RoborockCommand
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -98,6 +98,7 @@ BLOCK_SLEEPING_PLATFORMS: Final = [
|
||||
]
|
||||
RPC_SLEEPING_PLATFORMS: Final = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.SENSOR,
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
@@ -24,6 +24,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
CONF_SLEEP_PERIOD,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MODEL_FRANKEVER_WATER_VALVE,
|
||||
@@ -34,6 +35,7 @@ from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoo
|
||||
from .entity import (
|
||||
RpcEntityDescription,
|
||||
ShellyRpcAttributeEntity,
|
||||
ShellySleepingRpcAttributeEntity,
|
||||
async_setup_entry_rpc,
|
||||
get_entity_block_device_info,
|
||||
get_entity_rpc_device_info,
|
||||
@@ -190,9 +192,10 @@ async def async_setup_entry(
|
||||
if TYPE_CHECKING:
|
||||
assert coordinator is not None
|
||||
|
||||
await er.async_migrate_entries(
|
||||
hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator)
|
||||
)
|
||||
if coordinator.device.initialized:
|
||||
await er.async_migrate_entries(
|
||||
hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator)
|
||||
)
|
||||
|
||||
entities: list[ShellyButton] = []
|
||||
|
||||
@@ -208,22 +211,31 @@ async def async_setup_entry(
|
||||
return
|
||||
|
||||
# add RPC buttons
|
||||
async_setup_entry_rpc(
|
||||
hass, config_entry, async_add_entities, RPC_BUTTONS, RpcVirtualButton
|
||||
)
|
||||
if config_entry.data[CONF_SLEEP_PERIOD]:
|
||||
async_setup_entry_rpc(
|
||||
hass,
|
||||
config_entry,
|
||||
async_add_entities,
|
||||
RPC_BUTTONS,
|
||||
RpcSleepingSmokeMuteButton,
|
||||
)
|
||||
else:
|
||||
async_setup_entry_rpc(
|
||||
hass, config_entry, async_add_entities, RPC_BUTTONS, RpcVirtualButton
|
||||
)
|
||||
|
||||
# the user can remove virtual components from the device configuration, so
|
||||
# we need to remove orphaned entities
|
||||
virtual_button_component_ids = get_virtual_component_ids(
|
||||
coordinator.device.config, BUTTON_PLATFORM
|
||||
)
|
||||
async_remove_orphaned_entities(
|
||||
hass,
|
||||
config_entry.entry_id,
|
||||
coordinator.mac,
|
||||
BUTTON_PLATFORM,
|
||||
virtual_button_component_ids,
|
||||
)
|
||||
# the user can remove virtual components from the device configuration, so
|
||||
# we need to remove orphaned entities
|
||||
virtual_button_component_ids = get_virtual_component_ids(
|
||||
coordinator.device.config, BUTTON_PLATFORM
|
||||
)
|
||||
async_remove_orphaned_entities(
|
||||
hass,
|
||||
config_entry.entry_id,
|
||||
coordinator.mac,
|
||||
BUTTON_PLATFORM,
|
||||
virtual_button_component_ids,
|
||||
)
|
||||
|
||||
|
||||
class ShellyBaseButton(
|
||||
@@ -354,6 +366,31 @@ class RpcVirtualButton(ShellyRpcAttributeEntity, ButtonEntity):
|
||||
await self.coordinator.device.button_trigger(self._id, "single_push")
|
||||
|
||||
|
||||
class RpcSleepingSmokeMuteButton(ShellySleepingRpcAttributeEntity, ButtonEntity):
|
||||
"""Defines a Shelly RPC Smoke mute alarm button."""
|
||||
|
||||
entity_description: RpcButtonDescription
|
||||
|
||||
@rpc_call
|
||||
async def async_press(self) -> None:
|
||||
"""Triggers the Shelly button press service."""
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(self.coordinator, ShellyRpcCoordinator)
|
||||
|
||||
_id = int(self.key.split(":")[-1])
|
||||
await self.coordinator.device.smoke_mute_alarm(_id)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Available."""
|
||||
available = super().available
|
||||
|
||||
if self.coordinator.device.initialized:
|
||||
return available and self.status["alarm"]
|
||||
|
||||
return False
|
||||
|
||||
|
||||
RPC_BUTTONS = {
|
||||
"button_generic": RpcButtonDescription(
|
||||
key="button",
|
||||
@@ -379,4 +416,10 @@ RPC_BUTTONS = {
|
||||
entity_class=ShellyBluTrvButton,
|
||||
models={MODEL_BLU_GATEWAY_G3},
|
||||
),
|
||||
"smoke_mute": RpcButtonDescription(
|
||||
key="smoke",
|
||||
sub_key="mute",
|
||||
name="Mute alarm",
|
||||
translation_key="mute",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -399,6 +399,11 @@ class RpcShellyLightBase(ShellyRpcAttributeEntity, LightEntity):
|
||||
"""Return the rgbw color value [int, int, int, int]."""
|
||||
return (*self.status["rgb"], self.status["white"])
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int:
|
||||
"""Return the CT color value in Kelvin."""
|
||||
return cast(int, self.status["ct"])
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on light."""
|
||||
params: dict[str, Any] = {"id": self._id, "on": True}
|
||||
@@ -421,6 +426,12 @@ class RpcShellyLightBase(ShellyRpcAttributeEntity, LightEntity):
|
||||
params["rgb"] = list(kwargs[ATTR_RGBW_COLOR][:-1])
|
||||
params["white"] = kwargs[ATTR_RGBW_COLOR][-1]
|
||||
|
||||
if self.status.get("mode") is not None:
|
||||
if ATTR_COLOR_TEMP_KELVIN in kwargs:
|
||||
params["mode"] = "cct"
|
||||
elif ATTR_RGB_COLOR in kwargs:
|
||||
params["mode"] = "rgb"
|
||||
|
||||
await self.call_rpc(f"{self._component}.Set", params)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
@@ -479,10 +490,24 @@ class RpcShellyCctLight(RpcShellyLightBase):
|
||||
self._attr_min_color_temp_kelvin = KELVIN_MIN_VALUE_WHITE
|
||||
self._attr_max_color_temp_kelvin = KELVIN_MAX_VALUE
|
||||
|
||||
|
||||
class RpcShellyRgbCctLight(RpcShellyLightBase):
|
||||
"""Entity that controls a RGBCCT light on RPC based Shelly devices."""
|
||||
|
||||
_component = "RGBCCT"
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.RGB}
|
||||
_attr_supported_features = LightEntityFeature.TRANSITION
|
||||
_attr_min_color_temp_kelvin = KELVIN_MIN_VALUE_WHITE
|
||||
_attr_max_color_temp_kelvin = KELVIN_MAX_VALUE
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int:
|
||||
"""Return the CT color value in Kelvin."""
|
||||
return cast(int, self.status["ct"])
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode."""
|
||||
if self.status["mode"] == "cct":
|
||||
return ColorMode.COLOR_TEMP
|
||||
|
||||
return ColorMode.RGB
|
||||
|
||||
|
||||
class RpcShellyRgbLight(RpcShellyLightBase):
|
||||
@@ -529,6 +554,11 @@ LIGHTS: Final = {
|
||||
sub_key="output",
|
||||
entity_class=RpcShellyRgbLight,
|
||||
),
|
||||
"rgbcct": RpcEntityDescription(
|
||||
key="rgbcct",
|
||||
sub_key="output",
|
||||
entity_class=RpcShellyRgbCctLight,
|
||||
),
|
||||
"rgbw": RpcEntityDescription(
|
||||
key="rgbw",
|
||||
sub_key="output",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioshelly"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioshelly==13.14.0"],
|
||||
"requirements": ["aioshelly==13.15.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
||||
@@ -553,6 +553,14 @@ RPC_SENSORS: Final = {
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"power_rgbcct": RpcSensorDescription(
|
||||
key="rgbcct",
|
||||
sub_key="apower",
|
||||
name="Power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"a_act_power": RpcSensorDescription(
|
||||
key="em",
|
||||
sub_key="a_act_power",
|
||||
@@ -1023,6 +1031,17 @@ RPC_SENSORS: Final = {
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
"energy_rgbcct": RpcSensorDescription(
|
||||
key="rgbcct",
|
||||
sub_key="aenergy",
|
||||
name="Energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value=lambda status, _: status["total"],
|
||||
suggested_display_precision=2,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
"total_act": RpcSensorDescription(
|
||||
key="emdata",
|
||||
sub_key="total_act",
|
||||
|
||||
@@ -165,6 +165,7 @@ RPC_SWITCHES = {
|
||||
"boolean_zone0": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
entity_registry_enabled_default=False,
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
@@ -175,6 +176,7 @@ RPC_SWITCHES = {
|
||||
"boolean_zone1": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
entity_registry_enabled_default=False,
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
@@ -185,6 +187,7 @@ RPC_SWITCHES = {
|
||||
"boolean_zone2": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
entity_registry_enabled_default=False,
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
@@ -195,6 +198,7 @@ RPC_SWITCHES = {
|
||||
"boolean_zone3": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
entity_registry_enabled_default=False,
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
@@ -205,6 +209,7 @@ RPC_SWITCHES = {
|
||||
"boolean_zone4": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
entity_registry_enabled_default=False,
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
@@ -215,6 +220,7 @@ RPC_SWITCHES = {
|
||||
"boolean_zone5": RpcSwitchDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
entity_registry_enabled_default=False,
|
||||
is_on=lambda status: bool(status["value"]),
|
||||
method_on="boolean_set",
|
||||
method_off="boolean_set",
|
||||
|
||||
@@ -417,6 +417,11 @@ def get_rpc_sub_device_name(
|
||||
"""Get name based on device and channel name."""
|
||||
if key in device.config and key != "em:0":
|
||||
# workaround for Pro 3EM, we don't want to get name for em:0
|
||||
if (zone_id := get_irrigation_zone_id(device.config, key)) is not None:
|
||||
# workaround for Irrigation controller, name stored in "service:0"
|
||||
if zone_name := device.config["service:0"]["zones"][zone_id]["name"]:
|
||||
return cast(str, zone_name)
|
||||
|
||||
if entity_name := device.config[key].get("name"):
|
||||
return cast(str, entity_name)
|
||||
|
||||
@@ -787,6 +792,13 @@ async def get_rpc_scripts_event_types(
|
||||
return script_events
|
||||
|
||||
|
||||
def get_irrigation_zone_id(config: dict[str, Any], key: str) -> int | None:
|
||||
"""Return the zone id if the component is an irrigation zone."""
|
||||
if key in config and (zone := get_rpc_role_by_key(config, key)).startswith("zone"):
|
||||
return int(zone[4:])
|
||||
return None
|
||||
|
||||
|
||||
def get_rpc_device_info(
|
||||
device: RpcDevice,
|
||||
mac: str,
|
||||
@@ -823,7 +835,10 @@ def get_rpc_device_info(
|
||||
)
|
||||
|
||||
if (
|
||||
component not in (*All_LIGHT_TYPES, "cover", "em1", "switch")
|
||||
(
|
||||
component not in (*All_LIGHT_TYPES, "cover", "em1", "switch")
|
||||
and get_irrigation_zone_id(device.config, key) is None
|
||||
)
|
||||
or idx is None
|
||||
or len(get_rpc_key_instances(device.status, component, all_lights=True)) < 2
|
||||
):
|
||||
|
||||
@@ -17,7 +17,11 @@ from homeassistant.components.valve import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import MODEL_FRANKEVER_WATER_VALVE, MODEL_NEO_WATER_VALVE
|
||||
from .const import (
|
||||
MODEL_FRANKEVER_IRRIGATION_CONTROLLER,
|
||||
MODEL_FRANKEVER_WATER_VALVE,
|
||||
MODEL_NEO_WATER_VALVE,
|
||||
)
|
||||
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
|
||||
from .entity import (
|
||||
BlockEntityDescription,
|
||||
@@ -92,8 +96,8 @@ class RpcShellyWaterValve(RpcShellyBaseWaterValve):
|
||||
await self.coordinator.device.number_set(self._id, position)
|
||||
|
||||
|
||||
class RpcShellyNeoWaterValve(RpcShellyBaseWaterValve):
|
||||
"""Entity that controls a valve on RPC Shelly NEO Water Valve."""
|
||||
class RpcShellySimpleWaterValve(RpcShellyBaseWaterValve):
|
||||
"""Entity that controls a valve on RPC Shelly Open/Close Water Valve."""
|
||||
|
||||
_attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
|
||||
_attr_reports_position = False
|
||||
@@ -124,9 +128,51 @@ RPC_VALVES: dict[str, RpcValveDescription] = {
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
role="state",
|
||||
entity_class=RpcShellyNeoWaterValve,
|
||||
entity_class=RpcShellySimpleWaterValve,
|
||||
models={MODEL_NEO_WATER_VALVE},
|
||||
),
|
||||
"boolean_zone0": RpcValveDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
role="zone0",
|
||||
entity_class=RpcShellySimpleWaterValve,
|
||||
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
|
||||
),
|
||||
"boolean_zone1": RpcValveDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
role="zone1",
|
||||
entity_class=RpcShellySimpleWaterValve,
|
||||
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
|
||||
),
|
||||
"boolean_zone2": RpcValveDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
role="zone2",
|
||||
entity_class=RpcShellySimpleWaterValve,
|
||||
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
|
||||
),
|
||||
"boolean_zone3": RpcValveDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
role="zone3",
|
||||
entity_class=RpcShellySimpleWaterValve,
|
||||
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
|
||||
),
|
||||
"boolean_zone4": RpcValveDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
role="zone4",
|
||||
entity_class=RpcShellySimpleWaterValve,
|
||||
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
|
||||
),
|
||||
"boolean_zone5": RpcValveDescription(
|
||||
key="boolean",
|
||||
sub_key="value",
|
||||
role="zone5",
|
||||
entity_class=RpcShellySimpleWaterValve,
|
||||
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"thunder": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
"default": "mdi:weather-lightning"
|
||||
},
|
||||
"total_cloud": {
|
||||
"default": "mdi:cloud"
|
||||
|
||||
88
homeassistant/components/sunricher_dali_center/__init__.py
Normal file
88
homeassistant/components/sunricher_dali_center/__init__.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""The DALI Center integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from PySrDaliGateway import DaliGateway
|
||||
from PySrDaliGateway.exceptions import DaliGatewayError
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_USERNAME,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER
|
||||
from .types import DaliCenterConfigEntry, DaliCenterData
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool:
|
||||
"""Set up DALI Center from a config entry."""
|
||||
|
||||
gateway = DaliGateway(
|
||||
entry.data[CONF_SERIAL_NUMBER],
|
||||
entry.data[CONF_HOST],
|
||||
entry.data[CONF_PORT],
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_PASSWORD],
|
||||
name=entry.data[CONF_NAME],
|
||||
)
|
||||
gw_sn = gateway.gw_sn
|
||||
|
||||
try:
|
||||
await gateway.connect()
|
||||
except DaliGatewayError as exc:
|
||||
raise ConfigEntryNotReady(
|
||||
"You can try to delete the gateway and add it again"
|
||||
) from exc
|
||||
|
||||
def on_online_status(dev_id: str, available: bool) -> None:
|
||||
signal = f"{DOMAIN}_update_available_{dev_id}"
|
||||
hass.add_job(async_dispatcher_send, hass, signal, available)
|
||||
|
||||
gateway.on_online_status = on_online_status
|
||||
|
||||
try:
|
||||
devices = await gateway.discover_devices()
|
||||
except DaliGatewayError as exc:
|
||||
raise ConfigEntryNotReady(
|
||||
"Unable to discover devices from the gateway"
|
||||
) from exc
|
||||
|
||||
_LOGGER.debug("Discovered %d devices on gateway %s", len(devices), gw_sn)
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, gw_sn)},
|
||||
manufacturer=MANUFACTURER,
|
||||
name=gateway.name,
|
||||
model="SR-GW-EDA",
|
||||
serial_number=gw_sn,
|
||||
)
|
||||
|
||||
entry.runtime_data = DaliCenterData(
|
||||
gateway=gateway,
|
||||
devices=devices,
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, _PLATFORMS):
|
||||
await entry.runtime_data.gateway.disconnect()
|
||||
return unload_ok
|
||||
134
homeassistant/components/sunricher_dali_center/config_flow.py
Normal file
134
homeassistant/components/sunricher_dali_center/config_flow.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Config flow for the DALI Center integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from PySrDaliGateway import DaliGateway
|
||||
from PySrDaliGateway.discovery import DaliGatewayDiscovery
|
||||
from PySrDaliGateway.exceptions import DaliGatewayError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
)
|
||||
|
||||
from .const import CONF_SERIAL_NUMBER, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DaliCenterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for DALI Center."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovered_gateways: dict[str, DaliGateway] = {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_select_gateway()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({}),
|
||||
)
|
||||
|
||||
async def async_step_select_gateway(
|
||||
self, discovery_info: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle gateway discovery."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if discovery_info and "selected_gateway" in discovery_info:
|
||||
selected_sn = discovery_info["selected_gateway"]
|
||||
selected_gateway = self._discovered_gateways[selected_sn]
|
||||
|
||||
await self.async_set_unique_id(selected_gateway.gw_sn)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
try:
|
||||
await selected_gateway.connect()
|
||||
except DaliGatewayError as err:
|
||||
_LOGGER.debug(
|
||||
"Failed to connect to gateway %s during config flow",
|
||||
selected_gateway.gw_sn,
|
||||
exc_info=err,
|
||||
)
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
await selected_gateway.disconnect()
|
||||
return self.async_create_entry(
|
||||
title=selected_gateway.name,
|
||||
data={
|
||||
CONF_SERIAL_NUMBER: selected_gateway.gw_sn,
|
||||
CONF_HOST: selected_gateway.gw_ip,
|
||||
CONF_PORT: selected_gateway.port,
|
||||
CONF_NAME: selected_gateway.name,
|
||||
CONF_USERNAME: selected_gateway.username,
|
||||
CONF_PASSWORD: selected_gateway.passwd,
|
||||
},
|
||||
)
|
||||
|
||||
if not self._discovered_gateways:
|
||||
_LOGGER.debug("Starting gateway discovery")
|
||||
discovery = DaliGatewayDiscovery()
|
||||
try:
|
||||
discovered = await discovery.discover_gateways()
|
||||
except DaliGatewayError as err:
|
||||
_LOGGER.debug("Gateway discovery failed", exc_info=err)
|
||||
errors["base"] = "discovery_failed"
|
||||
else:
|
||||
configured_gateways = {
|
||||
entry.data[CONF_SERIAL_NUMBER]
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN)
|
||||
}
|
||||
|
||||
self._discovered_gateways = {
|
||||
gw.gw_sn: gw
|
||||
for gw in discovered
|
||||
if gw.gw_sn not in configured_gateways
|
||||
}
|
||||
|
||||
if not self._discovered_gateways:
|
||||
return self.async_show_form(
|
||||
step_id="select_gateway",
|
||||
errors=errors if errors else {"base": "no_devices_found"},
|
||||
data_schema=vol.Schema({}),
|
||||
)
|
||||
|
||||
gateway_options = [
|
||||
SelectOptionDict(
|
||||
value=sn,
|
||||
label=f"{gateway.name} [SN {sn}, IP {gateway.gw_ip}]",
|
||||
)
|
||||
for sn, gateway in self._discovered_gateways.items()
|
||||
]
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="select_gateway",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional("selected_gateway"): SelectSelector(
|
||||
SelectSelectorConfig(options=gateway_options, sort=True)
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
5
homeassistant/components/sunricher_dali_center/const.py
Normal file
5
homeassistant/components/sunricher_dali_center/const.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Constants for the DALI Center integration."""
|
||||
|
||||
DOMAIN = "sunricher_dali_center"
|
||||
MANUFACTURER = "Sunricher"
|
||||
CONF_SERIAL_NUMBER = "serial_number"
|
||||
190
homeassistant/components/sunricher_dali_center/light.py
Normal file
190
homeassistant/components/sunricher_dali_center/light.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Platform for light integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from PySrDaliGateway import Device
|
||||
from PySrDaliGateway.helper import is_light_device
|
||||
from PySrDaliGateway.types import LightStatus
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_HS_COLOR,
|
||||
ATTR_RGBW_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .types import DaliCenterConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: DaliCenterConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up DALI Center light entities from config entry."""
|
||||
runtime_data = entry.runtime_data
|
||||
gateway = runtime_data.gateway
|
||||
devices = runtime_data.devices
|
||||
|
||||
def _on_light_status(dev_id: str, status: LightStatus) -> None:
|
||||
signal = f"{DOMAIN}_update_{dev_id}"
|
||||
hass.add_job(async_dispatcher_send, hass, signal, status)
|
||||
|
||||
gateway.on_light_status = _on_light_status
|
||||
|
||||
async_add_entities(
|
||||
DaliCenterLight(device)
|
||||
for device in devices
|
||||
if is_light_device(device.dev_type)
|
||||
)
|
||||
|
||||
|
||||
class DaliCenterLight(LightEntity):
|
||||
"""Representation of a DALI Center Light."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_is_on: bool | None = None
|
||||
_attr_brightness: int | None = None
|
||||
_white_level: int | None = None
|
||||
_attr_color_mode: ColorMode | str | None = None
|
||||
_attr_color_temp_kelvin: int | None = None
|
||||
_attr_hs_color: tuple[float, float] | None = None
|
||||
_attr_rgbw_color: tuple[int, int, int, int] | None = None
|
||||
|
||||
def __init__(self, light: Device) -> None:
|
||||
"""Initialize the light entity."""
|
||||
|
||||
self._light = light
|
||||
self._unavailable_logged = False
|
||||
self._attr_unique_id = light.unique_id
|
||||
self._attr_available = light.status == "online"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, light.dev_id)},
|
||||
name=light.name,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=light.model,
|
||||
via_device=(DOMAIN, light.gw_sn),
|
||||
)
|
||||
self._attr_min_color_temp_kelvin = 1000
|
||||
self._attr_max_color_temp_kelvin = 8000
|
||||
|
||||
self._determine_features()
|
||||
|
||||
def _determine_features(self) -> None:
|
||||
supported_modes: set[ColorMode] = set()
|
||||
color_mode = self._light.color_mode
|
||||
color_mode_map: dict[str, ColorMode] = {
|
||||
"color_temp": ColorMode.COLOR_TEMP,
|
||||
"hs": ColorMode.HS,
|
||||
"rgbw": ColorMode.RGBW,
|
||||
}
|
||||
self._attr_color_mode = color_mode_map.get(color_mode, ColorMode.BRIGHTNESS)
|
||||
supported_modes.add(self._attr_color_mode)
|
||||
self._attr_supported_color_modes = supported_modes
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the light."""
|
||||
_LOGGER.debug(
|
||||
"Turning on light %s with kwargs: %s", self._attr_unique_id, kwargs
|
||||
)
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
||||
color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
hs_color = kwargs.get(ATTR_HS_COLOR)
|
||||
rgbw_color = kwargs.get(ATTR_RGBW_COLOR)
|
||||
self._light.turn_on(
|
||||
brightness=brightness,
|
||||
color_temp_kelvin=color_temp_kelvin,
|
||||
hs_color=hs_color,
|
||||
rgbw_color=rgbw_color,
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
self._light.turn_off()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity addition to Home Assistant."""
|
||||
|
||||
signal = f"{DOMAIN}_update_{self._attr_unique_id}"
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, signal, self._handle_device_update)
|
||||
)
|
||||
|
||||
signal = f"{DOMAIN}_update_available_{self._attr_unique_id}"
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, signal, self._handle_availability)
|
||||
)
|
||||
|
||||
# read_status() only queues a request on the gateway and relies on the
|
||||
# current event loop via call_later, so it must run in the loop thread.
|
||||
self._light.read_status()
|
||||
|
||||
@callback
|
||||
def _handle_availability(self, available: bool) -> None:
|
||||
self._attr_available = available
|
||||
if not available and not self._unavailable_logged:
|
||||
_LOGGER.info("Light %s became unavailable", self._attr_unique_id)
|
||||
self._unavailable_logged = True
|
||||
elif available and self._unavailable_logged:
|
||||
_LOGGER.info("Light %s is back online", self._attr_unique_id)
|
||||
self._unavailable_logged = False
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@callback
|
||||
def _handle_device_update(self, status: LightStatus) -> None:
|
||||
if status.get("is_on") is not None:
|
||||
self._attr_is_on = status["is_on"]
|
||||
|
||||
if status.get("brightness") is not None:
|
||||
self._attr_brightness = status["brightness"]
|
||||
|
||||
if status.get("white_level") is not None:
|
||||
self._white_level = status["white_level"]
|
||||
if self._attr_rgbw_color is not None and self._white_level is not None:
|
||||
self._attr_rgbw_color = (
|
||||
self._attr_rgbw_color[0],
|
||||
self._attr_rgbw_color[1],
|
||||
self._attr_rgbw_color[2],
|
||||
self._white_level,
|
||||
)
|
||||
|
||||
if (
|
||||
status.get("color_temp_kelvin") is not None
|
||||
and self._attr_supported_color_modes
|
||||
and ColorMode.COLOR_TEMP in self._attr_supported_color_modes
|
||||
):
|
||||
self._attr_color_temp_kelvin = status["color_temp_kelvin"]
|
||||
|
||||
if (
|
||||
status.get("hs_color") is not None
|
||||
and self._attr_supported_color_modes
|
||||
and ColorMode.HS in self._attr_supported_color_modes
|
||||
):
|
||||
self._attr_hs_color = status["hs_color"]
|
||||
|
||||
if (
|
||||
status.get("rgbw_color") is not None
|
||||
and self._attr_supported_color_modes
|
||||
and ColorMode.RGBW in self._attr_supported_color_modes
|
||||
):
|
||||
self._attr_rgbw_color = status["rgbw_color"]
|
||||
|
||||
self.async_write_ha_state()
|
||||
10
homeassistant/components/sunricher_dali_center/manifest.json
Normal file
10
homeassistant/components/sunricher_dali_center/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "sunricher_dali_center",
|
||||
"name": "DALI Center",
|
||||
"codeowners": ["@niracler"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/sunricher_dali_center",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["PySrDaliGateway==0.13.1"]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: todo
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: 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:
|
||||
status: exempt
|
||||
comment: Integration exposes only primary light entities.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: Light entities do not support device classes.
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
29
homeassistant/components/sunricher_dali_center/strings.json
Normal file
29
homeassistant/components/sunricher_dali_center/strings.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up DALI Center gateway",
|
||||
"description": "**Three-step process:**\n\n1. Ensure the gateway is powered and on the same network.\n2. Select **Submit** to start discovery (searches for up to 3 minutes)\n3. While discovery is in progress, press the **Reset** button on your DALI gateway device **once**.\n\nThe gateway will respond immediately after the button press."
|
||||
},
|
||||
"select_gateway": {
|
||||
"title": "Select DALI gateway",
|
||||
"description": "Select the gateway to configure.",
|
||||
"data": {
|
||||
"selected_gateway": "Gateway"
|
||||
},
|
||||
"data_description": {
|
||||
"selected_gateway": "Each option shows the gateway name, serial number, and IP address."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"discovery_failed": "Failed to discover DALI gateways on the network",
|
||||
"no_devices_found": "No DALI gateways found on the network",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
homeassistant/components/sunricher_dali_center/types.py
Normal file
18
homeassistant/components/sunricher_dali_center/types.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Type definitions for the DALI Center integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PySrDaliGateway import DaliGateway, Device
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
|
||||
@dataclass
|
||||
class DaliCenterData:
|
||||
"""Runtime data for the DALI Center integration."""
|
||||
|
||||
gateway: DaliGateway
|
||||
devices: list[Device]
|
||||
|
||||
|
||||
type DaliCenterConfigEntry = ConfigEntry[DaliCenterData]
|
||||
@@ -101,6 +101,7 @@ PLATFORMS_BY_TYPE = {
|
||||
SupportedModels.PLUG_MINI_EU.value: [Platform.SWITCH, Platform.SENSOR],
|
||||
SupportedModels.RELAY_SWITCH_2PM.value: [Platform.SWITCH, Platform.SENSOR],
|
||||
SupportedModels.GARAGE_DOOR_OPENER.value: [Platform.COVER, Platform.SENSOR],
|
||||
SupportedModels.CLIMATE_PANEL.value: [Platform.SENSOR, Platform.BINARY_SENSOR],
|
||||
}
|
||||
CLASS_BY_DEVICE = {
|
||||
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
|
||||
|
||||
@@ -24,7 +24,6 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = {
|
||||
),
|
||||
"motion_detected": BinarySensorEntityDescription(
|
||||
key="pir_state",
|
||||
name=None,
|
||||
device_class=BinarySensorDeviceClass.MOTION,
|
||||
),
|
||||
"contact_open": BinarySensorEntityDescription(
|
||||
|
||||
@@ -57,6 +57,7 @@ class SupportedModels(StrEnum):
|
||||
RELAY_SWITCH_2PM = "relay_switch_2pm"
|
||||
K11_PLUS_VACUUM = "k11+_vacuum"
|
||||
GARAGE_DOOR_OPENER = "garage_door_opener"
|
||||
CLIMATE_PANEL = "climate_panel"
|
||||
|
||||
|
||||
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
@@ -93,6 +94,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
SwitchbotModel.RELAY_SWITCH_2PM: SupportedModels.RELAY_SWITCH_2PM,
|
||||
SwitchbotModel.K11_VACUUM: SupportedModels.K11_PLUS_VACUUM,
|
||||
SwitchbotModel.GARAGE_DOOR_OPENER: SupportedModels.GARAGE_DOOR_OPENER,
|
||||
SwitchbotModel.CLIMATE_PANEL: SupportedModels.CLIMATE_PANEL,
|
||||
}
|
||||
|
||||
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
@@ -106,6 +108,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
SwitchbotModel.REMOTE: SupportedModels.REMOTE,
|
||||
SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER,
|
||||
SwitchbotModel.HUB3: SupportedModels.HUB3,
|
||||
SwitchbotModel.CLIMATE_PANEL: SupportedModels.CLIMATE_PANEL,
|
||||
}
|
||||
|
||||
SUPPORTED_MODEL_TYPES = (
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["telegram"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-telegram-bot[socks]==21.5"]
|
||||
"requirements": ["python-telegram-bot[socks]==22.1"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/thethingsnetwork",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["ttn_client==1.2.2"]
|
||||
"requirements": ["ttn_client==1.2.3"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/webostv",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiowebostv"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiowebostv==0.7.5"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -162,6 +162,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
|
||||
self._entry = entry
|
||||
self._client = entry.runtime_data
|
||||
self._attr_assumed_state = True
|
||||
self._unavailable_logged = False
|
||||
self._device_name = entry.title
|
||||
self._attr_unique_id = entry.unique_id
|
||||
self._sources = entry.options.get(CONF_SOURCES)
|
||||
@@ -348,19 +349,31 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
|
||||
):
|
||||
self._source_list["Live TV"] = app
|
||||
|
||||
def _set_availability(self, available: bool) -> None:
|
||||
"""Set availability and log changes only once."""
|
||||
self._attr_available = available
|
||||
if not available and not self._unavailable_logged:
|
||||
_LOGGER.info("LG webOS TV entity %s is unavailable", self.entity_id)
|
||||
self._unavailable_logged = True
|
||||
elif available and self._unavailable_logged:
|
||||
_LOGGER.info("LG webOS TV entity %s is back online", self.entity_id)
|
||||
self._unavailable_logged = False
|
||||
|
||||
@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
|
||||
async def async_update(self) -> None:
|
||||
"""Connect."""
|
||||
if self._client.is_connected():
|
||||
return
|
||||
|
||||
with suppress(*WEBOSTV_EXCEPTIONS):
|
||||
try:
|
||||
await self._client.connect()
|
||||
except WebOsTvPairError:
|
||||
self._entry.async_start_reauth(self.hass)
|
||||
else:
|
||||
update_client_key(self.hass, self._entry)
|
||||
try:
|
||||
await self._client.connect()
|
||||
except WEBOSTV_EXCEPTIONS:
|
||||
self._set_availability(bool(self._turn_on))
|
||||
except WebOsTvPairError:
|
||||
self._entry.async_start_reauth(self.hass)
|
||||
else:
|
||||
self._set_availability(True)
|
||||
update_client_key(self.hass, self._entry)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
|
||||
@@ -26,9 +26,9 @@ rules:
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
@@ -11,6 +11,7 @@ from xbox.webapi.api.provider.people.models import Person
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
@@ -18,7 +19,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import XboxConfigEntry, XboxUpdateCoordinator
|
||||
from .entity import XboxBaseEntity
|
||||
from .entity import XboxBaseEntity, check_deprecated_entity
|
||||
|
||||
|
||||
class XboxBinarySensor(StrEnum):
|
||||
@@ -37,6 +38,7 @@ class XboxBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
|
||||
is_on_fn: Callable[[Person], bool | None]
|
||||
entity_picture_fn: Callable[[Person], str | None] | None = None
|
||||
deprecated: bool | None = None
|
||||
|
||||
|
||||
def profile_pic(person: Person) -> str | None:
|
||||
@@ -82,13 +84,8 @@ SENSOR_DESCRIPTIONS: tuple[XboxBinarySensorEntityDescription, ...] = (
|
||||
),
|
||||
XboxBinarySensorEntityDescription(
|
||||
key=XboxBinarySensor.IN_PARTY,
|
||||
translation_key=XboxBinarySensor.IN_PARTY,
|
||||
is_on_fn=(
|
||||
lambda x: bool(x.multiplayer_summary.in_party)
|
||||
if x.multiplayer_summary
|
||||
else None
|
||||
),
|
||||
entity_registry_enabled_default=False,
|
||||
is_on_fn=lambda _: None,
|
||||
deprecated=True,
|
||||
),
|
||||
XboxBinarySensorEntityDescription(
|
||||
key=XboxBinarySensor.IN_GAME,
|
||||
@@ -97,13 +94,8 @@ SENSOR_DESCRIPTIONS: tuple[XboxBinarySensorEntityDescription, ...] = (
|
||||
),
|
||||
XboxBinarySensorEntityDescription(
|
||||
key=XboxBinarySensor.IN_MULTIPLAYER,
|
||||
translation_key=XboxBinarySensor.IN_MULTIPLAYER,
|
||||
is_on_fn=(
|
||||
lambda x: bool(x.multiplayer_summary.in_multiplayer_session)
|
||||
if x.multiplayer_summary
|
||||
else None
|
||||
),
|
||||
entity_registry_enabled_default=False,
|
||||
is_on_fn=lambda _: None,
|
||||
deprecated=True,
|
||||
),
|
||||
XboxBinarySensorEntityDescription(
|
||||
key=XboxBinarySensor.HAS_GAME_PASS,
|
||||
@@ -121,7 +113,9 @@ async def async_setup_entry(
|
||||
"""Set up Xbox Live friends."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
update_friends = partial(async_update_friends, coordinator, {}, async_add_entities)
|
||||
update_friends = partial(
|
||||
async_update_friends, hass, coordinator, {}, async_add_entities
|
||||
)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(update_friends))
|
||||
|
||||
@@ -152,6 +146,7 @@ class XboxBinarySensorEntity(XboxBaseEntity, BinarySensorEntity):
|
||||
|
||||
@callback
|
||||
def async_update_friends(
|
||||
hass: HomeAssistant,
|
||||
coordinator: XboxUpdateCoordinator,
|
||||
current: dict[str, list[XboxBinarySensorEntity]],
|
||||
async_add_entities,
|
||||
@@ -163,10 +158,11 @@ def async_update_friends(
|
||||
# Process new favorites, add them to Home Assistant
|
||||
new_entities: list[XboxBinarySensorEntity] = []
|
||||
for xuid in new_ids - current_ids:
|
||||
current[xuid] = [
|
||||
XboxBinarySensorEntity(coordinator, xuid, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
]
|
||||
current[xuid] = []
|
||||
for description in SENSOR_DESCRIPTIONS:
|
||||
entity = XboxBinarySensorEntity(coordinator, xuid, description)
|
||||
if check_deprecated_entity(hass, entity, BINARY_SENSOR_DOMAIN):
|
||||
current[xuid].append(entity)
|
||||
new_entities = new_entities + current[xuid]
|
||||
if new_entities:
|
||||
async_add_entities(new_entities)
|
||||
|
||||
@@ -4,6 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from xbox.webapi.api.provider.smartglass.models import ConsoleType, SmartglassConsole
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -83,3 +87,29 @@ class XboxConsoleBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]):
|
||||
def data(self) -> ConsoleData:
|
||||
"""Return coordinator data for this console."""
|
||||
return self.coordinator.data.consoles[self._console.id]
|
||||
|
||||
|
||||
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
|
||||
"""Get list of related automations and scripts."""
|
||||
used_in = automations_with_entity(hass, entity_id)
|
||||
used_in += scripts_with_entity(hass, entity_id)
|
||||
return used_in
|
||||
|
||||
|
||||
def check_deprecated_entity(
|
||||
hass: HomeAssistant,
|
||||
entity: XboxBaseEntity,
|
||||
entity_domain: str,
|
||||
) -> bool:
|
||||
"""Check for deprecated entity and remove it."""
|
||||
if not getattr(entity.entity_description, "deprecated", False):
|
||||
return True
|
||||
ent_reg = er.async_get(hass)
|
||||
if entity_id := ent_reg.async_get_entity_id(
|
||||
entity_domain,
|
||||
DOMAIN,
|
||||
f"{entity.xuid}_{entity.entity_description.key}",
|
||||
):
|
||||
ent_reg.async_remove(entity_id)
|
||||
|
||||
return False
|
||||
|
||||
@@ -7,12 +7,6 @@
|
||||
"gamer_score": {
|
||||
"default": "mdi:alpha-g-circle"
|
||||
},
|
||||
"account_tier": {
|
||||
"default": "mdi:microsoft-xbox"
|
||||
},
|
||||
"gold_tenure": {
|
||||
"default": "mdi:microsoft-xbox"
|
||||
},
|
||||
"last_online": {
|
||||
"default": "mdi:account-clock"
|
||||
},
|
||||
@@ -27,15 +21,9 @@
|
||||
"online": {
|
||||
"default": "mdi:account"
|
||||
},
|
||||
"in_party": {
|
||||
"default": "mdi:account-group"
|
||||
},
|
||||
"in_game": {
|
||||
"default": "mdi:microsoft-xbox-controller"
|
||||
},
|
||||
"in_multiplayer": {
|
||||
"default": "mdi:account-multiple"
|
||||
},
|
||||
"has_game_pass": {
|
||||
"default": "mdi:microsoft-xbox"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user