mirror of
https://github.com/home-assistant/core.git
synced 2026-02-20 20:59:14 +00:00
Compare commits
153 Commits
drop-ignor
...
govee_init
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2229bfbdf | ||
|
|
0996ad4d1d | ||
|
|
e8885de8c2 | ||
|
|
03d9c2cf7b | ||
|
|
7f3583587d | ||
|
|
e009440bf9 | ||
|
|
43dccf15ba | ||
|
|
c647ab1877 | ||
|
|
6b395b2703 | ||
|
|
882a44a1c2 | ||
|
|
3c9a505fc3 | ||
|
|
b2679ddc42 | ||
|
|
2055082993 | ||
|
|
6f49f9a12a | ||
|
|
36c560b7bf | ||
|
|
05abe7efe0 | ||
|
|
865ec96429 | ||
|
|
e6dbed0a87 | ||
|
|
a3fd2f692e | ||
|
|
eb7e00346d | ||
|
|
77159e612e | ||
|
|
05f9e25f29 | ||
|
|
7fa51117a9 | ||
|
|
9e87fa75f8 | ||
|
|
0188f2ffec | ||
|
|
c144aec03e | ||
|
|
1cb44aef64 | ||
|
|
900f2300ad | ||
|
|
b075fba594 | ||
|
|
c2ba97fb79 | ||
|
|
d0a373aecc | ||
|
|
758225edad | ||
|
|
8ab1a527a4 | ||
|
|
c7582b2f25 | ||
|
|
91b8a67ce2 | ||
|
|
2b13ff98da | ||
|
|
fd2d9c2ee2 | ||
|
|
61b5466dcc | ||
|
|
bc4af64bea | ||
|
|
3323f84c22 | ||
|
|
b1f48a5886 | ||
|
|
a14b1db886 | ||
|
|
9de89b923e | ||
|
|
21cf5dc321 | ||
|
|
fe32582233 | ||
|
|
6ebf19c4ba | ||
|
|
5794189f8d | ||
|
|
c336e58afc | ||
|
|
cdad602af0 | ||
|
|
520046cd82 | ||
|
|
e0b2ff0b2a | ||
|
|
6164198bde | ||
|
|
dd41b4cefd | ||
|
|
ccb8d6af44 | ||
|
|
6e8c064474 | ||
|
|
7079eda8d9 | ||
|
|
4e3832758b | ||
|
|
773c3c4f07 | ||
|
|
b73beba152 | ||
|
|
82589b613d | ||
|
|
c9b5f5f2c1 | ||
|
|
725b45db7f | ||
|
|
b194741a13 | ||
|
|
4615b4d104 | ||
|
|
2c7d9cb62e | ||
|
|
e229ba591a | ||
|
|
7914ebe54e | ||
|
|
3abaa99706 | ||
|
|
86d7fdfe1e | ||
|
|
676c42d578 | ||
|
|
39909b7493 | ||
|
|
6aef9a99e6 | ||
|
|
ff036f38a0 | ||
|
|
53e3b4caf0 | ||
|
|
dbdc030b74 | ||
|
|
ee0b24f808 | ||
|
|
c0fd8ff342 | ||
|
|
84d2ec484d | ||
|
|
844b20e2fc | ||
|
|
2bd07e6626 | ||
|
|
b91c07b2af | ||
|
|
37f0f1869f | ||
|
|
2fcbd77c95 | ||
|
|
b398197c07 | ||
|
|
cd5775ca35 | ||
|
|
fafa193549 | ||
|
|
ca4d537529 | ||
|
|
e9be363f29 | ||
|
|
0f874f7f03 | ||
|
|
14b147b3f7 | ||
|
|
8a1909e5d8 | ||
|
|
1fd873869f | ||
|
|
3b7b3454d8 | ||
|
|
c7276621eb | ||
|
|
6be1e4065f | ||
|
|
ba547c6bdb | ||
|
|
be25603b76 | ||
|
|
2e0f727981 | ||
|
|
122bc32f30 | ||
|
|
723825b579 | ||
|
|
5f6b446195 | ||
|
|
f59f14fe40 | ||
|
|
ab9b13302c | ||
|
|
f74fdd7605 | ||
|
|
f7628b87c8 | ||
|
|
3e31fbfee0 | ||
|
|
477797271a | ||
|
|
9f2677ddd8 | ||
|
|
558a49cb66 | ||
|
|
a9b64a15e6 | ||
|
|
0a734b7426 | ||
|
|
8df41dc73f | ||
|
|
e9039cec24 | ||
|
|
15cb102c39 | ||
|
|
30314ec88e | ||
|
|
428aa31749 | ||
|
|
0170d56893 | ||
|
|
eb7d973252 | ||
|
|
e3c98dcd09 | ||
|
|
9c71aea622 | ||
|
|
21978917b9 | ||
|
|
3b6a5b2c79 | ||
|
|
68792f02d4 | ||
|
|
bfea04b482 | ||
|
|
dc553f20e6 | ||
|
|
5631170900 | ||
|
|
60d4b050ac | ||
|
|
c5e261495f | ||
|
|
d1a1183b9a | ||
|
|
4dcfd5fb91 | ||
|
|
680f7fac1c | ||
|
|
7a41ce1fd8 | ||
|
|
937b4866c3 | ||
|
|
151e075e28 | ||
|
|
8094cfc404 | ||
|
|
b26483e09e | ||
|
|
728de32d75 | ||
|
|
8de1e3d27b | ||
|
|
cabf3b7ab9 | ||
|
|
f0e22cca56 | ||
|
|
294a3e5360 | ||
|
|
fdd753e70c | ||
|
|
392fc7ff91 | ||
|
|
d777c1c542 | ||
|
|
fa71fd3992 | ||
|
|
19f6340546 | ||
|
|
479cb7f1e1 | ||
|
|
d50d914928 | ||
|
|
551a71104e | ||
|
|
65cf61571a | ||
|
|
58ac3d2f45 | ||
|
|
654e132440 | ||
|
|
4af60ef3b9 |
1
.agent/skills
Symbolic link
1
.agent/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../.claude/skills/
|
||||
1
.gemini/skills
Symbolic link
1
.gemini/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../.claude/skills
|
||||
6
.github/workflows/stale.yml
vendored
6
.github/workflows/stale.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
# - No PRs marked as no-stale
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
|
||||
@@ -49,6 +49,7 @@ homeassistant.components.actiontec.*
|
||||
homeassistant.components.adax.*
|
||||
homeassistant.components.adguard.*
|
||||
homeassistant.components.aftership.*
|
||||
homeassistant.components.ai_task.*
|
||||
homeassistant.components.air_quality.*
|
||||
homeassistant.components.airgradient.*
|
||||
homeassistant.components.airly.*
|
||||
@@ -130,6 +131,7 @@ homeassistant.components.bring.*
|
||||
homeassistant.components.brother.*
|
||||
homeassistant.components.browser.*
|
||||
homeassistant.components.bryant_evolution.*
|
||||
homeassistant.components.bsblan.*
|
||||
homeassistant.components.bthome.*
|
||||
homeassistant.components.button.*
|
||||
homeassistant.components.calendar.*
|
||||
@@ -209,6 +211,7 @@ homeassistant.components.firefly_iii.*
|
||||
homeassistant.components.fitbit.*
|
||||
homeassistant.components.flexit_bacnet.*
|
||||
homeassistant.components.flux_led.*
|
||||
homeassistant.components.folder_watcher.*
|
||||
homeassistant.components.forecast_solar.*
|
||||
homeassistant.components.fritz.*
|
||||
homeassistant.components.fritzbox.*
|
||||
@@ -298,6 +301,7 @@ homeassistant.components.iotty.*
|
||||
homeassistant.components.ipp.*
|
||||
homeassistant.components.iqvia.*
|
||||
homeassistant.components.iron_os.*
|
||||
homeassistant.components.isal.*
|
||||
homeassistant.components.islamic_prayer_times.*
|
||||
homeassistant.components.isy994.*
|
||||
homeassistant.components.jellyfin.*
|
||||
@@ -308,6 +312,7 @@ homeassistant.components.knocki.*
|
||||
homeassistant.components.knx.*
|
||||
homeassistant.components.kraken.*
|
||||
homeassistant.components.kulersky.*
|
||||
homeassistant.components.labs.*
|
||||
homeassistant.components.lacrosse.*
|
||||
homeassistant.components.lacrosse_view.*
|
||||
homeassistant.components.lamarzocco.*
|
||||
@@ -367,6 +372,7 @@ homeassistant.components.my.*
|
||||
homeassistant.components.mysensors.*
|
||||
homeassistant.components.myuplink.*
|
||||
homeassistant.components.nam.*
|
||||
homeassistant.components.namecheapdns.*
|
||||
homeassistant.components.nasweb.*
|
||||
homeassistant.components.neato.*
|
||||
homeassistant.components.nest.*
|
||||
@@ -402,6 +408,7 @@ homeassistant.components.opnsense.*
|
||||
homeassistant.components.opower.*
|
||||
homeassistant.components.oralb.*
|
||||
homeassistant.components.otbr.*
|
||||
homeassistant.components.otp.*
|
||||
homeassistant.components.overkiz.*
|
||||
homeassistant.components.overseerr.*
|
||||
homeassistant.components.p1_monitor.*
|
||||
@@ -418,6 +425,7 @@ homeassistant.components.plugwise.*
|
||||
homeassistant.components.pooldose.*
|
||||
homeassistant.components.portainer.*
|
||||
homeassistant.components.powerfox.*
|
||||
homeassistant.components.powerfox_local.*
|
||||
homeassistant.components.powerwall.*
|
||||
homeassistant.components.private_ble_device.*
|
||||
homeassistant.components.prometheus.*
|
||||
@@ -436,10 +444,12 @@ homeassistant.components.radarr.*
|
||||
homeassistant.components.radio_browser.*
|
||||
homeassistant.components.rainforest_raven.*
|
||||
homeassistant.components.rainmachine.*
|
||||
homeassistant.components.random.*
|
||||
homeassistant.components.raspberry_pi.*
|
||||
homeassistant.components.rdw.*
|
||||
homeassistant.components.recollect_waste.*
|
||||
homeassistant.components.recorder.*
|
||||
homeassistant.components.recovery_mode.*
|
||||
homeassistant.components.redgtech.*
|
||||
homeassistant.components.remember_the_milk.*
|
||||
homeassistant.components.remote.*
|
||||
@@ -471,6 +481,7 @@ homeassistant.components.schlage.*
|
||||
homeassistant.components.scrape.*
|
||||
homeassistant.components.script.*
|
||||
homeassistant.components.search.*
|
||||
homeassistant.components.season.*
|
||||
homeassistant.components.select.*
|
||||
homeassistant.components.sensibo.*
|
||||
homeassistant.components.sensirion_ble.*
|
||||
@@ -522,6 +533,7 @@ homeassistant.components.synology_dsm.*
|
||||
homeassistant.components.system_health.*
|
||||
homeassistant.components.system_log.*
|
||||
homeassistant.components.systemmonitor.*
|
||||
homeassistant.components.systemnexa2.*
|
||||
homeassistant.components.tag.*
|
||||
homeassistant.components.tailscale.*
|
||||
homeassistant.components.tailwind.*
|
||||
@@ -564,6 +576,7 @@ homeassistant.components.update.*
|
||||
homeassistant.components.uptime.*
|
||||
homeassistant.components.uptime_kuma.*
|
||||
homeassistant.components.uptimerobot.*
|
||||
homeassistant.components.usage_prediction.*
|
||||
homeassistant.components.usb.*
|
||||
homeassistant.components.uvc.*
|
||||
homeassistant.components.vacuum.*
|
||||
@@ -582,6 +595,7 @@ homeassistant.components.water_heater.*
|
||||
homeassistant.components.watts.*
|
||||
homeassistant.components.watttime.*
|
||||
homeassistant.components.weather.*
|
||||
homeassistant.components.web_rtc.*
|
||||
homeassistant.components.webhook.*
|
||||
homeassistant.components.webostv.*
|
||||
homeassistant.components.websocket_api.*
|
||||
|
||||
16
CODEOWNERS
generated
16
CODEOWNERS
generated
@@ -792,8 +792,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/indevolt/ @xirtnl
|
||||
/homeassistant/components/inels/ @epdevlab
|
||||
/tests/components/inels/ @epdevlab
|
||||
/homeassistant/components/influxdb/ @mdegat01
|
||||
/tests/components/influxdb/ @mdegat01
|
||||
/homeassistant/components/influxdb/ @mdegat01 @Robbie1221
|
||||
/tests/components/influxdb/ @mdegat01 @Robbie1221
|
||||
/homeassistant/components/inkbird/ @bdraco
|
||||
/tests/components/inkbird/ @bdraco
|
||||
/homeassistant/components/input_boolean/ @home-assistant/core
|
||||
@@ -1098,8 +1098,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/nasweb/ @nasWebio
|
||||
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
|
||||
/tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
|
||||
/homeassistant/components/ness_alarm/ @nickw444
|
||||
/tests/components/ness_alarm/ @nickw444
|
||||
/homeassistant/components/ness_alarm/ @nickw444 @poshy163
|
||||
/tests/components/ness_alarm/ @nickw444 @poshy163
|
||||
/homeassistant/components/nest/ @allenporter
|
||||
/tests/components/nest/ @allenporter
|
||||
/homeassistant/components/netatmo/ @cgtobi
|
||||
@@ -1283,6 +1283,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/portainer/ @erwindouna
|
||||
/homeassistant/components/powerfox/ @klaasnicolaas
|
||||
/tests/components/powerfox/ @klaasnicolaas
|
||||
/homeassistant/components/powerfox_local/ @klaasnicolaas
|
||||
/tests/components/powerfox_local/ @klaasnicolaas
|
||||
/homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson
|
||||
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
|
||||
/homeassistant/components/prana/ @prana-dev-official
|
||||
@@ -1646,6 +1648,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/system_bridge/ @timmo001
|
||||
/homeassistant/components/systemmonitor/ @gjohansson-ST
|
||||
/tests/components/systemmonitor/ @gjohansson-ST
|
||||
/homeassistant/components/systemnexa2/ @konsulten @slangstrom
|
||||
/tests/components/systemnexa2/ @konsulten @slangstrom
|
||||
/homeassistant/components/tado/ @erwindouna
|
||||
/tests/components/tado/ @erwindouna
|
||||
/homeassistant/components/tag/ @home-assistant/core
|
||||
@@ -1671,6 +1675,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/telegram_bot/ @hanwg
|
||||
/homeassistant/components/tellduslive/ @fredrike
|
||||
/tests/components/tellduslive/ @fredrike
|
||||
/homeassistant/components/teltonika/ @karlbeecken
|
||||
/tests/components/teltonika/ @karlbeecken
|
||||
/homeassistant/components/template/ @Petro31 @home-assistant/core
|
||||
/tests/components/template/ @Petro31 @home-assistant/core
|
||||
/homeassistant/components/tesla_fleet/ @Bre77
|
||||
@@ -1737,6 +1743,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/trafikverket_train/ @gjohansson-ST
|
||||
/homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST
|
||||
/tests/components/trafikverket_weatherstation/ @gjohansson-ST
|
||||
/homeassistant/components/trane/ @bdraco
|
||||
/tests/components/trane/ @bdraco
|
||||
/homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
|
||||
/tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
|
||||
/homeassistant/components/trend/ @jpbede
|
||||
|
||||
5
homeassistant/brands/american_standard.json
Normal file
5
homeassistant/brands/american_standard.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "american_standard",
|
||||
"name": "American Standard",
|
||||
"integrations": ["nexia", "trane"]
|
||||
}
|
||||
5
homeassistant/brands/powerfox.json
Normal file
5
homeassistant/brands/powerfox.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "powerfox",
|
||||
"name": "Powerfox",
|
||||
"integrations": ["powerfox", "powerfox_local"]
|
||||
}
|
||||
5
homeassistant/brands/trane.json
Normal file
5
homeassistant/brands/trane.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "trane",
|
||||
"name": "Trane",
|
||||
"integrations": ["nexia", "trane"]
|
||||
}
|
||||
@@ -9,9 +9,13 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
DEFAULT_MAX_KELVIN,
|
||||
DEFAULT_MIN_KELVIN,
|
||||
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
filter_supported_color_modes,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -24,13 +28,20 @@ from .entity import AdsEntity
|
||||
from .hub import AdsHub
|
||||
|
||||
CONF_ADS_VAR_BRIGHTNESS = "adsvar_brightness"
|
||||
CONF_ADS_VAR_COLOR_TEMP_KELVIN = "adsvar_color_temp_kelvin"
|
||||
CONF_MIN_COLOR_TEMP_KELVIN = "min_color_temp_kelvin"
|
||||
CONF_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin"
|
||||
STATE_KEY_BRIGHTNESS = "brightness"
|
||||
STATE_KEY_COLOR_TEMP_KELVIN = "color_temp_kelvin"
|
||||
|
||||
DEFAULT_NAME = "ADS Light"
|
||||
PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ADS_VAR): cv.string,
|
||||
vol.Optional(CONF_ADS_VAR_BRIGHTNESS): cv.string,
|
||||
vol.Optional(CONF_ADS_VAR_COLOR_TEMP_KELVIN): cv.string,
|
||||
vol.Optional(CONF_MIN_COLOR_TEMP_KELVIN): cv.positive_int,
|
||||
vol.Optional(CONF_MAX_COLOR_TEMP_KELVIN): cv.positive_int,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
}
|
||||
)
|
||||
@@ -47,9 +58,24 @@ def setup_platform(
|
||||
|
||||
ads_var_enable: str = config[CONF_ADS_VAR]
|
||||
ads_var_brightness: str | None = config.get(CONF_ADS_VAR_BRIGHTNESS)
|
||||
ads_var_color_temp_kelvin: str | None = config.get(CONF_ADS_VAR_COLOR_TEMP_KELVIN)
|
||||
min_color_temp_kelvin: int | None = config.get(CONF_MIN_COLOR_TEMP_KELVIN)
|
||||
max_color_temp_kelvin: int | None = config.get(CONF_MAX_COLOR_TEMP_KELVIN)
|
||||
name: str = config[CONF_NAME]
|
||||
|
||||
add_entities([AdsLight(ads_hub, ads_var_enable, ads_var_brightness, name)])
|
||||
add_entities(
|
||||
[
|
||||
AdsLight(
|
||||
ads_hub,
|
||||
ads_var_enable,
|
||||
ads_var_brightness,
|
||||
ads_var_color_temp_kelvin,
|
||||
min_color_temp_kelvin,
|
||||
max_color_temp_kelvin,
|
||||
name,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class AdsLight(AdsEntity, LightEntity):
|
||||
@@ -60,18 +86,40 @@ class AdsLight(AdsEntity, LightEntity):
|
||||
ads_hub: AdsHub,
|
||||
ads_var_enable: str,
|
||||
ads_var_brightness: str | None,
|
||||
ads_var_color_temp_kelvin: str | None,
|
||||
min_color_temp_kelvin: int | None,
|
||||
max_color_temp_kelvin: int | None,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize AdsLight entity."""
|
||||
super().__init__(ads_hub, name, ads_var_enable)
|
||||
self._state_dict[STATE_KEY_BRIGHTNESS] = None
|
||||
self._state_dict[STATE_KEY_COLOR_TEMP_KELVIN] = None
|
||||
self._ads_var_brightness = ads_var_brightness
|
||||
self._ads_var_color_temp_kelvin = ads_var_color_temp_kelvin
|
||||
|
||||
# Determine supported color modes
|
||||
color_modes = {ColorMode.ONOFF}
|
||||
if ads_var_brightness is not None:
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
else:
|
||||
self._attr_color_mode = ColorMode.ONOFF
|
||||
self._attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
color_modes.add(ColorMode.BRIGHTNESS)
|
||||
if ads_var_color_temp_kelvin is not None:
|
||||
color_modes.add(ColorMode.COLOR_TEMP)
|
||||
|
||||
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
|
||||
self._attr_color_mode = next(iter(self._attr_supported_color_modes))
|
||||
|
||||
# Set color temperature range (static config values take precedence over defaults)
|
||||
if ads_var_color_temp_kelvin is not None:
|
||||
self._attr_min_color_temp_kelvin = (
|
||||
min_color_temp_kelvin
|
||||
if min_color_temp_kelvin is not None
|
||||
else DEFAULT_MIN_KELVIN
|
||||
)
|
||||
self._attr_max_color_temp_kelvin = (
|
||||
max_color_temp_kelvin
|
||||
if max_color_temp_kelvin is not None
|
||||
else DEFAULT_MAX_KELVIN
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register device notification."""
|
||||
@@ -84,11 +132,23 @@ class AdsLight(AdsEntity, LightEntity):
|
||||
STATE_KEY_BRIGHTNESS,
|
||||
)
|
||||
|
||||
if self._ads_var_color_temp_kelvin is not None:
|
||||
await self.async_initialize_device(
|
||||
self._ads_var_color_temp_kelvin,
|
||||
pyads.PLCTYPE_UINT,
|
||||
STATE_KEY_COLOR_TEMP_KELVIN,
|
||||
)
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
"""Return the brightness of the light (0..255)."""
|
||||
return self._state_dict[STATE_KEY_BRIGHTNESS]
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int | None:
|
||||
"""Return the color temperature in Kelvin."""
|
||||
return self._state_dict[STATE_KEY_COLOR_TEMP_KELVIN]
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if the entity is on."""
|
||||
@@ -97,6 +157,8 @@ class AdsLight(AdsEntity, LightEntity):
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on or set a specific dimmer value."""
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
||||
color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
|
||||
self._ads_hub.write_by_name(self._ads_var, True, pyads.PLCTYPE_BOOL)
|
||||
|
||||
if self._ads_var_brightness is not None and brightness is not None:
|
||||
@@ -104,6 +166,11 @@ class AdsLight(AdsEntity, LightEntity):
|
||||
self._ads_var_brightness, brightness, pyads.PLCTYPE_UINT
|
||||
)
|
||||
|
||||
if self._ads_var_color_temp_kelvin is not None and color_temp is not None:
|
||||
self._ads_hub.write_by_name(
|
||||
self._ads_var_color_temp_kelvin, color_temp, pyads.PLCTYPE_UINT
|
||||
)
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
self._ads_hub.write_by_name(self._ads_var, False, pyads.PLCTYPE_BOOL)
|
||||
|
||||
@@ -121,7 +121,7 @@ class AdvantageAirThingEntity(AdvantageAirEntity):
|
||||
return self.coordinator.data["myThings"]["things"][self._id]
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool:
|
||||
"""Return if the thing is considered on."""
|
||||
return self._data["value"] > 0
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: todo
|
||||
comment: https://developers.home-assistant.io/blog/2025/09/25/entity-services-api-changes/
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
|
||||
@@ -534,6 +534,10 @@ class Analytics:
|
||||
|
||||
payload = await _async_snapshot_payload(self._hass)
|
||||
|
||||
if not payload:
|
||||
LOGGER.info("Skipping snapshot submission, no data to send")
|
||||
return
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"home-assistant/{HA_VERSION}",
|
||||
|
||||
@@ -19,7 +19,6 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
DATA_REPAIR_DEFER_RELOAD,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DEPRECATED_MODELS,
|
||||
DOMAIN,
|
||||
@@ -34,7 +33,6 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Anthropic."""
|
||||
hass.data.setdefault(DOMAIN, {}).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
|
||||
await async_migrate_integration(hass)
|
||||
return True
|
||||
|
||||
@@ -85,11 +83,6 @@ async def async_update_options(
|
||||
hass: HomeAssistant, entry: AnthropicConfigEntry
|
||||
) -> None:
|
||||
"""Update options."""
|
||||
defer_reload_entries: set[str] = hass.data.setdefault(DOMAIN, {}).setdefault(
|
||||
DATA_REPAIR_DEFER_RELOAD, set()
|
||||
)
|
||||
if entry.entry_id in defer_reload_entries:
|
||||
return
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ CONF_WEB_SEARCH_REGION = "region"
|
||||
CONF_WEB_SEARCH_COUNTRY = "country"
|
||||
CONF_WEB_SEARCH_TIMEZONE = "timezone"
|
||||
|
||||
DATA_REPAIR_DEFER_RELOAD = "repair_defer_reload"
|
||||
|
||||
DEFAULT = {
|
||||
CONF_CHAT_MODEL: "claude-haiku-4-5",
|
||||
CONF_MAX_TOKENS: 3000,
|
||||
|
||||
@@ -34,10 +34,7 @@ rules:
|
||||
Integration does not subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data:
|
||||
status: todo
|
||||
comment: |
|
||||
To redesign deferred reloading.
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
@@ -12,16 +12,14 @@ from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.config_entries import ConfigEntryState, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
)
|
||||
|
||||
from .config_flow import get_model_list
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
DATA_REPAIR_DEFER_RELOAD,
|
||||
DEFAULT,
|
||||
DEPRECATED_MODELS,
|
||||
DOMAIN,
|
||||
)
|
||||
from .const import CONF_CHAT_MODEL, DEFAULT, DEPRECATED_MODELS, DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import AnthropicConfigEntry
|
||||
@@ -33,8 +31,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
_subentry_iter: Iterator[tuple[str, str]] | None
|
||||
_current_entry_id: str | None
|
||||
_current_subentry_id: str | None
|
||||
_reload_pending: set[str]
|
||||
_pending_updates: dict[str, dict[str, str]]
|
||||
_model_list_cache: dict[str, list[SelectOptionDict]] | None
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the flow."""
|
||||
@@ -42,33 +39,32 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
self._subentry_iter = None
|
||||
self._current_entry_id = None
|
||||
self._current_subentry_id = None
|
||||
self._reload_pending = set()
|
||||
self._pending_updates = {}
|
||||
self._model_list_cache = None
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
self, user_input: dict[str, str]
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
previous_entry_id: str | None = None
|
||||
if user_input is not None:
|
||||
previous_entry_id = self._async_update_current_subentry(user_input)
|
||||
self._clear_current_target()
|
||||
"""Handle the steps of a fix flow."""
|
||||
if user_input.get(CONF_CHAT_MODEL):
|
||||
self._async_update_current_subentry(user_input)
|
||||
|
||||
target = await self._async_next_target()
|
||||
next_entry_id = target[0].entry_id if target else None
|
||||
if previous_entry_id and previous_entry_id != next_entry_id:
|
||||
await self._async_apply_pending_updates(previous_entry_id)
|
||||
if target is None:
|
||||
await self._async_apply_all_pending_updates()
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
entry, subentry, model = target
|
||||
client = entry.runtime_data
|
||||
model_list = [
|
||||
model_option
|
||||
for model_option in await get_model_list(client)
|
||||
if not model_option["value"].startswith(tuple(DEPRECATED_MODELS))
|
||||
]
|
||||
if self._model_list_cache is None:
|
||||
self._model_list_cache = {}
|
||||
if entry.entry_id in self._model_list_cache:
|
||||
model_list = self._model_list_cache[entry.entry_id]
|
||||
else:
|
||||
client = entry.runtime_data
|
||||
model_list = [
|
||||
model_option
|
||||
for model_option in await get_model_list(client)
|
||||
if not model_option["value"].startswith(tuple(DEPRECATED_MODELS))
|
||||
]
|
||||
self._model_list_cache[entry.entry_id] = model_list
|
||||
|
||||
if "opus" in model:
|
||||
suggested_model = "claude-opus-4-5"
|
||||
@@ -124,6 +120,8 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
# Verify that the entry/subentry still exists and the model is still
|
||||
# deprecated. This may have changed since we started the repair flow.
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None:
|
||||
continue
|
||||
@@ -132,9 +130,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
if subentry is None:
|
||||
continue
|
||||
|
||||
model = self._pending_model(entry_id, subentry_id)
|
||||
if model is None:
|
||||
model = subentry.data.get(CONF_CHAT_MODEL)
|
||||
model = subentry.data.get(CONF_CHAT_MODEL)
|
||||
if not model or not model.startswith(tuple(DEPRECATED_MODELS)):
|
||||
continue
|
||||
|
||||
@@ -142,36 +138,30 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
self._current_subentry_id = subentry_id
|
||||
return entry, subentry, model
|
||||
|
||||
def _async_update_current_subentry(self, user_input: dict[str, str]) -> str | None:
|
||||
def _async_update_current_subentry(self, user_input: dict[str, str]) -> None:
|
||||
"""Update the currently selected subentry."""
|
||||
if not self._current_entry_id or not self._current_subentry_id:
|
||||
return None
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(self._current_entry_id)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
subentry = entry.subentries.get(self._current_subentry_id)
|
||||
if subentry is None:
|
||||
return None
|
||||
if (
|
||||
self._current_entry_id is None
|
||||
or self._current_subentry_id is None
|
||||
or (
|
||||
entry := self.hass.config_entries.async_get_entry(
|
||||
self._current_entry_id
|
||||
)
|
||||
)
|
||||
is None
|
||||
or (subentry := entry.subentries.get(self._current_subentry_id)) is None
|
||||
):
|
||||
raise HomeAssistantError("Subentry not found")
|
||||
|
||||
updated_data = {
|
||||
**subentry.data,
|
||||
CONF_CHAT_MODEL: user_input[CONF_CHAT_MODEL],
|
||||
}
|
||||
if updated_data == subentry.data:
|
||||
return entry.entry_id
|
||||
self._queue_pending_update(
|
||||
entry.entry_id,
|
||||
subentry.subentry_id,
|
||||
updated_data[CONF_CHAT_MODEL],
|
||||
self.hass.config_entries.async_update_subentry(
|
||||
entry,
|
||||
subentry,
|
||||
data=updated_data,
|
||||
)
|
||||
return entry.entry_id
|
||||
|
||||
def _clear_current_target(self) -> None:
|
||||
"""Clear current target tracking."""
|
||||
self._current_entry_id = None
|
||||
self._current_subentry_id = None
|
||||
|
||||
def _format_subentry_type(self, subentry_type: str) -> str:
|
||||
"""Return a user-friendly subentry type label."""
|
||||
@@ -181,91 +171,6 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
return "AI task"
|
||||
return subentry_type
|
||||
|
||||
def _queue_pending_update(
|
||||
self, entry_id: str, subentry_id: str, model: str
|
||||
) -> None:
|
||||
"""Store a pending model update for a subentry."""
|
||||
self._pending_updates.setdefault(entry_id, {})[subentry_id] = model
|
||||
|
||||
def _pending_model(self, entry_id: str, subentry_id: str) -> str | None:
|
||||
"""Return a pending model update if one exists."""
|
||||
return self._pending_updates.get(entry_id, {}).get(subentry_id)
|
||||
|
||||
def _mark_entry_for_reload(self, entry_id: str) -> None:
|
||||
"""Prevent reload until repairs are complete for the entry."""
|
||||
self._reload_pending.add(entry_id)
|
||||
defer_reload_entries: set[str] = self.hass.data.setdefault(
|
||||
DOMAIN, {}
|
||||
).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
|
||||
defer_reload_entries.add(entry_id)
|
||||
|
||||
async def _async_reload_entry(self, entry_id: str) -> None:
|
||||
"""Reload an entry once all repairs are completed."""
|
||||
if entry_id not in self._reload_pending:
|
||||
return
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is not None and entry.state is not ConfigEntryState.LOADED:
|
||||
self._clear_defer_reload(entry_id)
|
||||
self._reload_pending.discard(entry_id)
|
||||
return
|
||||
|
||||
if entry is not None:
|
||||
await self.hass.config_entries.async_reload(entry_id)
|
||||
|
||||
self._clear_defer_reload(entry_id)
|
||||
self._reload_pending.discard(entry_id)
|
||||
|
||||
def _clear_defer_reload(self, entry_id: str) -> None:
|
||||
"""Remove entry from the deferred reload set."""
|
||||
defer_reload_entries: set[str] = self.hass.data.setdefault(
|
||||
DOMAIN, {}
|
||||
).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
|
||||
defer_reload_entries.discard(entry_id)
|
||||
|
||||
async def _async_apply_pending_updates(self, entry_id: str) -> None:
|
||||
"""Apply pending subentry updates for a single entry."""
|
||||
updates = self._pending_updates.pop(entry_id, None)
|
||||
if not updates:
|
||||
return
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None or entry.state is not ConfigEntryState.LOADED:
|
||||
return
|
||||
|
||||
changed = False
|
||||
for subentry_id, model in updates.items():
|
||||
subentry = entry.subentries.get(subentry_id)
|
||||
if subentry is None:
|
||||
continue
|
||||
|
||||
updated_data = {
|
||||
**subentry.data,
|
||||
CONF_CHAT_MODEL: model,
|
||||
}
|
||||
if updated_data == subentry.data:
|
||||
continue
|
||||
|
||||
if not changed:
|
||||
self._mark_entry_for_reload(entry_id)
|
||||
changed = True
|
||||
|
||||
self.hass.config_entries.async_update_subentry(
|
||||
entry,
|
||||
subentry,
|
||||
data=updated_data,
|
||||
)
|
||||
|
||||
if not changed:
|
||||
return
|
||||
|
||||
await self._async_reload_entry(entry_id)
|
||||
|
||||
async def _async_apply_all_pending_updates(self) -> None:
|
||||
"""Apply all pending updates across entries."""
|
||||
for entry_id in list(self._pending_updates):
|
||||
await self._async_apply_pending_updates(entry_id)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -5,11 +5,10 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiobotocore.client import AioBaseClient as S3Client
|
||||
from aiobotocore.session import AioSession
|
||||
from botocore.exceptions import ClientError, ConnectionError, ParamValidationError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
|
||||
@@ -21,9 +20,9 @@ from .const import (
|
||||
DATA_BACKUP_AGENT_LISTENERS,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import S3ConfigEntry, S3DataUpdateCoordinator
|
||||
|
||||
type S3ConfigEntry = ConfigEntry[S3Client]
|
||||
|
||||
_PLATFORMS = (Platform.SENSOR,)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,7 +63,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
|
||||
entry.runtime_data = client
|
||||
coordinator = S3DataUpdateCoordinator(
|
||||
hass,
|
||||
entry=entry,
|
||||
client=client,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
def notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
@@ -72,11 +77,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
|
||||
|
||||
entry.async_on_unload(entry.async_on_state_change(notify_backup_listeners))
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
client = entry.runtime_data
|
||||
await client.__aexit__(None, None, None)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
||||
if not unload_ok:
|
||||
return False
|
||||
coordinator = entry.runtime_data
|
||||
await coordinator.client.__aexit__(None, None, None)
|
||||
return True
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import S3ConfigEntry
|
||||
from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .helpers import async_list_backups_from_s3
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
CACHE_TTL = 300
|
||||
@@ -93,7 +94,7 @@ class S3BackupAgent(BackupAgent):
|
||||
def __init__(self, hass: HomeAssistant, entry: S3ConfigEntry) -> None:
|
||||
"""Initialize the S3 agent."""
|
||||
super().__init__()
|
||||
self._client = entry.runtime_data
|
||||
self._client = entry.runtime_data.client
|
||||
self._bucket: str = entry.data[CONF_BUCKET]
|
||||
self.name = entry.title
|
||||
self.unique_id = entry.entry_id
|
||||
@@ -316,35 +317,8 @@ class S3BackupAgent(BackupAgent):
|
||||
if time() <= self._cache_expiration:
|
||||
return self._backup_cache
|
||||
|
||||
backups = {}
|
||||
paginator = self._client.get_paginator("list_objects_v2")
|
||||
metadata_files: list[dict[str, Any]] = []
|
||||
async for page in paginator.paginate(Bucket=self._bucket):
|
||||
metadata_files.extend(
|
||||
obj
|
||||
for obj in page.get("Contents", [])
|
||||
if obj["Key"].endswith(".metadata.json")
|
||||
)
|
||||
|
||||
for metadata_file in metadata_files:
|
||||
try:
|
||||
# Download and parse metadata file
|
||||
metadata_response = await self._client.get_object(
|
||||
Bucket=self._bucket, Key=metadata_file["Key"]
|
||||
)
|
||||
metadata_content = await metadata_response["Body"].read()
|
||||
metadata_json = json.loads(metadata_content)
|
||||
except (BotoCoreError, json.JSONDecodeError) as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to process metadata file %s: %s",
|
||||
metadata_file["Key"],
|
||||
err,
|
||||
)
|
||||
continue
|
||||
backup = AgentBackup.from_dict(metadata_json)
|
||||
backups[backup.backup_id] = backup
|
||||
|
||||
self._backup_cache = backups
|
||||
backups_list = await async_list_backups_from_s3(self._client, self._bucket)
|
||||
self._backup_cache = {b.backup_id: b for b in backups_list}
|
||||
self._cache_expiration = time() + CACHE_TTL
|
||||
|
||||
return self._backup_cache
|
||||
|
||||
70
homeassistant/components/aws_s3/coordinator.py
Normal file
70
homeassistant/components/aws_s3/coordinator.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""DataUpdateCoordinator for AWS S3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiobotocore.client import AioBaseClient as S3Client
|
||||
from botocore.exceptions import BotoCoreError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_BUCKET, DOMAIN
|
||||
from .helpers import async_list_backups_from_s3
|
||||
|
||||
SCAN_INTERVAL = timedelta(hours=6)
|
||||
|
||||
type S3ConfigEntry = ConfigEntry[S3DataUpdateCoordinator]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SensorData:
|
||||
"""Class to represent sensor data."""
|
||||
|
||||
all_backups_size: int
|
||||
|
||||
|
||||
class S3DataUpdateCoordinator(DataUpdateCoordinator[SensorData]):
|
||||
"""Class to manage fetching AWS S3 data from single endpoint."""
|
||||
|
||||
config_entry: S3ConfigEntry
|
||||
client: S3Client
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry: S3ConfigEntry,
|
||||
client: S3Client,
|
||||
) -> None:
|
||||
"""Initialize AWS S3 data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
self._bucket: str = entry.data[CONF_BUCKET]
|
||||
|
||||
async def _async_update_data(self) -> SensorData:
|
||||
"""Fetch data from AWS S3."""
|
||||
try:
|
||||
backups = await async_list_backups_from_s3(self.client, self._bucket)
|
||||
except BotoCoreError as error:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_fetching_data",
|
||||
) from error
|
||||
|
||||
all_backups_size = sum(b.size for b in backups)
|
||||
return SensorData(
|
||||
all_backups_size=all_backups_size,
|
||||
)
|
||||
33
homeassistant/components/aws_s3/entity.py
Normal file
33
homeassistant/components/aws_s3/entity.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Define the AWS S3 entity."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_BUCKET, DOMAIN
|
||||
from .coordinator import S3DataUpdateCoordinator
|
||||
|
||||
|
||||
class S3Entity(CoordinatorEntity[S3DataUpdateCoordinator]):
|
||||
"""Defines a base AWS S3 entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, coordinator: S3DataUpdateCoordinator, description: EntityDescription
|
||||
) -> None:
|
||||
"""Initialize an AWS S3 entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information about this AWS S3 device."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)},
|
||||
name=f"Bucket {self.coordinator.config_entry.data[CONF_BUCKET]}",
|
||||
manufacturer="AWS",
|
||||
model="AWS S3",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
57
homeassistant/components/aws_s3/helpers.py
Normal file
57
homeassistant/components/aws_s3/helpers.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Helpers for the AWS S3 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiobotocore.client import AioBaseClient as S3Client
|
||||
from botocore.exceptions import BotoCoreError
|
||||
|
||||
from homeassistant.components.backup import AgentBackup
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_list_backups_from_s3(
|
||||
client: S3Client,
|
||||
bucket: str,
|
||||
) -> list[AgentBackup]:
|
||||
"""List backups from an S3 bucket by reading metadata files."""
|
||||
paginator = client.get_paginator("list_objects_v2")
|
||||
metadata_files: list[dict[str, Any]] = []
|
||||
async for page in paginator.paginate(Bucket=bucket):
|
||||
metadata_files.extend(
|
||||
obj
|
||||
for obj in page.get("Contents", [])
|
||||
if obj["Key"].endswith(".metadata.json")
|
||||
)
|
||||
|
||||
backups: list[AgentBackup] = []
|
||||
for metadata_file in metadata_files:
|
||||
try:
|
||||
metadata_response = await client.get_object(
|
||||
Bucket=bucket, Key=metadata_file["Key"]
|
||||
)
|
||||
metadata_content = await metadata_response["Body"].read()
|
||||
metadata_json = json.loads(metadata_content)
|
||||
except (BotoCoreError, json.JSONDecodeError) as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to process metadata file %s: %s",
|
||||
metadata_file["Key"],
|
||||
err,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
backup = AgentBackup.from_dict(metadata_json)
|
||||
except (KeyError, TypeError, ValueError) as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to parse metadata in file %s: %s",
|
||||
metadata_file["Key"],
|
||||
err,
|
||||
)
|
||||
continue
|
||||
backups.append(backup)
|
||||
|
||||
return backups
|
||||
@@ -3,9 +3,10 @@
|
||||
"name": "AWS S3",
|
||||
"codeowners": ["@tomasbedrich"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["backup"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/aws_s3",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiobotocore"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiobotocore==2.21.1"]
|
||||
|
||||
@@ -3,9 +3,7 @@ rules:
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
@@ -20,12 +18,8 @@ rules:
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
@@ -40,21 +34,15 @@ rules:
|
||||
status: exempt
|
||||
comment: This integration does not have an options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
@@ -62,15 +50,11 @@ rules:
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: S3 is a cloud service that is not discovered on the network.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
docs-data-update: done
|
||||
docs-examples:
|
||||
status: exempt
|
||||
comment: The integration extends core functionality and does not require examples.
|
||||
docs-known-limitations:
|
||||
status: exempt
|
||||
comment: No known limitations.
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: This integration does not support physical devices.
|
||||
@@ -81,19 +65,11 @@ rules:
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: This integration does not have devices.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: This integration does not have entities.
|
||||
comment: This integration has a fixed set of devices.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
@@ -104,7 +80,7 @@ rules:
|
||||
comment: There are no issues which can be repaired.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: This integration does not have devices.
|
||||
comment: This is a service type integration with a single device.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
||||
66
homeassistant/components/aws_s3/sensor.py
Normal file
66
homeassistant/components/aws_s3/sensor.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Support for AWS S3 sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfInformation
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .coordinator import S3ConfigEntry, SensorData
|
||||
from .entity import S3Entity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class S3SensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes an AWS S3 sensor entity."""
|
||||
|
||||
value_fn: Callable[[SensorData], StateType]
|
||||
|
||||
|
||||
SENSORS: tuple[S3SensorEntityDescription, ...] = (
|
||||
S3SensorEntityDescription(
|
||||
key="backups_size",
|
||||
translation_key="backups_size",
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES,
|
||||
suggested_display_precision=0,
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.all_backups_size,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: S3ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AWS S3 sensor based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
S3SensorEntity(coordinator, description) for description in SENSORS
|
||||
)
|
||||
|
||||
|
||||
class S3SensorEntity(S3Entity, SensorEntity):
|
||||
"""Defines an AWS S3 sensor entity."""
|
||||
|
||||
entity_description: S3SensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
@@ -27,10 +27,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"backups_size": {
|
||||
"name": "Total size of backups"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to endpoint"
|
||||
},
|
||||
"error_fetching_data": {
|
||||
"message": "Error fetching data"
|
||||
},
|
||||
"invalid_bucket_name": {
|
||||
"message": "Invalid bucket name"
|
||||
},
|
||||
|
||||
@@ -34,7 +34,7 @@ class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity
|
||||
_attr_device_class = SwitchDeviceClass.SWITCH
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return whether switch is on."""
|
||||
return self._feature.is_on
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ class ShutterContactSensor(SHCEntity, BinarySensorEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool:
|
||||
"""Return the state of the sensor."""
|
||||
return self._device.state == SHCShutterContact.ShutterContactService.State.OPEN
|
||||
|
||||
@@ -93,7 +93,7 @@ class BatterySensor(SHCEntity, BinarySensorEntity):
|
||||
self._attr_unique_id = f"{device.serial}_battery"
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool:
|
||||
"""Return the state of the sensor."""
|
||||
return (
|
||||
self._device.batterylevel != SHCBatteryDevice.BatteryLevelService.State.OK
|
||||
|
||||
@@ -101,16 +101,16 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if self.coordinator.data.state.current_temperature is None:
|
||||
if (current_temp := self.coordinator.data.state.current_temperature) is None:
|
||||
return None
|
||||
return self.coordinator.data.state.current_temperature.value
|
||||
return current_temp.value
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
if self.coordinator.data.state.target_temperature is None:
|
||||
if (target_temp := self.coordinator.data.state.target_temperature) is None:
|
||||
return None
|
||||
return self.coordinator.data.state.target_temperature.value
|
||||
return target_temp.value
|
||||
|
||||
@property
|
||||
def _hvac_mode_value(self) -> int | str | None:
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""DataUpdateCoordinator for the BSB-Lan integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bsblan import (
|
||||
BSBLAN,
|
||||
@@ -14,7 +17,6 @@ from bsblan import (
|
||||
State,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
@@ -22,6 +24,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .const import DOMAIN, LOGGER, SCAN_INTERVAL_FAST, SCAN_INTERVAL_SLOW
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import BSBLanConfigEntry
|
||||
|
||||
# Filter lists for optimized API calls - only fetch parameters we actually use
|
||||
# This significantly reduces response time (~0.2s per parameter saved)
|
||||
STATE_INCLUDE = ["current_temperature", "target_temperature", "hvac_mode"]
|
||||
@@ -54,12 +59,12 @@ class BSBLanSlowData:
|
||||
class BSBLanCoordinator[T](DataUpdateCoordinator[T]):
|
||||
"""Base BSB-Lan coordinator."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
config_entry: BSBLanConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: BSBLanConfigEntry,
|
||||
client: BSBLAN,
|
||||
name: str,
|
||||
update_interval: timedelta,
|
||||
@@ -81,7 +86,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: BSBLanConfigEntry,
|
||||
client: BSBLAN,
|
||||
) -> None:
|
||||
"""Initialize the BSB-Lan fast coordinator."""
|
||||
@@ -126,7 +131,7 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: BSBLanConfigEntry,
|
||||
client: BSBLAN,
|
||||
) -> None:
|
||||
"""Initialize the BSB-Lan slow coordinator."""
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"requirements": ["python-bsblan==4.2.0"],
|
||||
"requirements": ["python-bsblan==4.2.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -81,58 +81,57 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
self._attr_available = True
|
||||
|
||||
# Set temperature limits based on device capabilities from slow coordinator
|
||||
dhw_config = (
|
||||
data.slow_coordinator.data.dhw_config
|
||||
if data.slow_coordinator.data
|
||||
else None
|
||||
)
|
||||
|
||||
# For min_temp: Use reduced_setpoint from config data (slow polling)
|
||||
if (
|
||||
data.slow_coordinator.data
|
||||
and data.slow_coordinator.data.dhw_config is not None
|
||||
and data.slow_coordinator.data.dhw_config.reduced_setpoint is not None
|
||||
and hasattr(data.slow_coordinator.data.dhw_config.reduced_setpoint, "value")
|
||||
dhw_config is not None
|
||||
and dhw_config.reduced_setpoint is not None
|
||||
and dhw_config.reduced_setpoint.value is not None
|
||||
):
|
||||
self._attr_min_temp = float(
|
||||
data.slow_coordinator.data.dhw_config.reduced_setpoint.value
|
||||
)
|
||||
self._attr_min_temp = dhw_config.reduced_setpoint.value
|
||||
else:
|
||||
self._attr_min_temp = 10.0 # Default minimum
|
||||
|
||||
# For max_temp: Use nominal_setpoint_max from config data (slow polling)
|
||||
if (
|
||||
data.slow_coordinator.data
|
||||
and data.slow_coordinator.data.dhw_config is not None
|
||||
and data.slow_coordinator.data.dhw_config.nominal_setpoint_max is not None
|
||||
and hasattr(
|
||||
data.slow_coordinator.data.dhw_config.nominal_setpoint_max, "value"
|
||||
)
|
||||
dhw_config is not None
|
||||
and dhw_config.nominal_setpoint_max is not None
|
||||
and dhw_config.nominal_setpoint_max.value is not None
|
||||
):
|
||||
self._attr_max_temp = float(
|
||||
data.slow_coordinator.data.dhw_config.nominal_setpoint_max.value
|
||||
)
|
||||
self._attr_max_temp = dhw_config.nominal_setpoint_max.value
|
||||
else:
|
||||
self._attr_max_temp = 65.0 # Default maximum
|
||||
|
||||
@property
|
||||
def current_operation(self) -> str | None:
|
||||
"""Return current operation."""
|
||||
if self.coordinator.data.dhw.operating_mode is None:
|
||||
if (operating_mode := self.coordinator.data.dhw.operating_mode) is None:
|
||||
return None
|
||||
# The operating_mode.value is an integer (0=Off, 1=On, 2=Eco)
|
||||
current_mode_value = self.coordinator.data.dhw.operating_mode.value
|
||||
if isinstance(current_mode_value, int):
|
||||
return BSBLAN_TO_HA_OPERATION_MODE.get(current_mode_value)
|
||||
if isinstance(operating_mode.value, int):
|
||||
return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value)
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if self.coordinator.data.dhw.dhw_actual_value_top_temperature is None:
|
||||
if (
|
||||
current_temp := self.coordinator.data.dhw.dhw_actual_value_top_temperature
|
||||
) is None:
|
||||
return None
|
||||
return self.coordinator.data.dhw.dhw_actual_value_top_temperature.value
|
||||
return current_temp.value
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
if self.coordinator.data.dhw.nominal_setpoint is None:
|
||||
if (target_temp := self.coordinator.data.dhw.nominal_setpoint) is None:
|
||||
return None
|
||||
return self.coordinator.data.dhw.nominal_setpoint.value
|
||||
return target_temp.value
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
|
||||
@@ -16,7 +16,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.SELECT, Platform.SWITCH]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.MEDIA_PLAYER,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"entity": {
|
||||
"number": {
|
||||
"room_correction_intensity": {
|
||||
"default": "mdi:home-sound-out"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"audio_output": {
|
||||
"default": "mdi:audio-input-stereo-minijack"
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiostreammagic"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiostreammagic==2.12.1"],
|
||||
"requirements": ["aiostreammagic==2.13.0"],
|
||||
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
|
||||
}
|
||||
|
||||
88
homeassistant/components/cambridge_audio/number.py
Normal file
88
homeassistant/components/cambridge_audio/number.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Support for Cambridge Audio number entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiostreammagic import StreamMagicClient
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import CambridgeAudioConfigEntry
|
||||
from .entity import CambridgeAudioEntity, command
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class CambridgeAudioNumberEntityDescription(NumberEntityDescription):
|
||||
"""Describes Cambridge Audio number entity."""
|
||||
|
||||
exists_fn: Callable[[StreamMagicClient], bool] = lambda _: True
|
||||
value_fn: Callable[[StreamMagicClient], int]
|
||||
set_value_fn: Callable[[StreamMagicClient, int], Awaitable[None]]
|
||||
|
||||
|
||||
def room_correction_intensity(client: StreamMagicClient) -> int:
|
||||
"""Get room correction intensity."""
|
||||
if TYPE_CHECKING:
|
||||
assert client.audio.tilt_eq is not None
|
||||
return client.audio.tilt_eq.intensity
|
||||
|
||||
|
||||
CONTROL_ENTITIES: tuple[CambridgeAudioNumberEntityDescription, ...] = (
|
||||
CambridgeAudioNumberEntityDescription(
|
||||
key="room_correction_intensity",
|
||||
translation_key="room_correction_intensity",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_min_value=-15,
|
||||
native_max_value=15,
|
||||
native_step=1,
|
||||
exists_fn=lambda client: client.audio.tilt_eq is not None,
|
||||
value_fn=room_correction_intensity,
|
||||
set_value_fn=lambda client, value: client.set_room_correction_intensity(value),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CambridgeAudioConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Cambridge Audio number entities based on a config entry."""
|
||||
client = entry.runtime_data
|
||||
async_add_entities(
|
||||
CambridgeAudioNumber(entry.runtime_data, description)
|
||||
for description in CONTROL_ENTITIES
|
||||
if description.exists_fn(client)
|
||||
)
|
||||
|
||||
|
||||
class CambridgeAudioNumber(CambridgeAudioEntity, NumberEntity):
|
||||
"""Defines a Cambridge Audio number entity."""
|
||||
|
||||
entity_description: CambridgeAudioNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: StreamMagicClient,
|
||||
description: CambridgeAudioNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize Cambridge Audio number entity."""
|
||||
super().__init__(client)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{client.info.unit_id}-{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the state of the number."""
|
||||
return self.entity_description.value_fn(self.client)
|
||||
|
||||
@command
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the selected value."""
|
||||
await self.entity_description.set_value_fn(self.client, int(value))
|
||||
@@ -35,6 +35,11 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"number": {
|
||||
"room_correction_intensity": {
|
||||
"name": "Room correction intensity"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"audio_output": {
|
||||
"name": "Audio output"
|
||||
|
||||
@@ -75,11 +75,12 @@ C4_TO_HA_HVAC_MODE = {
|
||||
|
||||
HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()}
|
||||
|
||||
# Map the five known Control4 HVAC states to Home Assistant HVAC actions
|
||||
# Map Control4 HVAC states to Home Assistant HVAC actions
|
||||
C4_TO_HA_HVAC_ACTION = {
|
||||
"off": HVACAction.OFF,
|
||||
"heat": HVACAction.HEATING,
|
||||
"cool": HVACAction.COOLING,
|
||||
"idle": HVACAction.IDLE,
|
||||
"dry": HVACAction.DRYING,
|
||||
"fan": HVACAction.FAN,
|
||||
}
|
||||
@@ -292,8 +293,14 @@ class Control4Climate(Control4Entity, ClimateEntity):
|
||||
c4_state = data.get(CONTROL4_HVAC_STATE)
|
||||
if c4_state is None:
|
||||
return None
|
||||
# Convert state to lowercase for mapping
|
||||
action = C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
|
||||
# Substring match for multi-stage systems that report
|
||||
# e.g. "Stage 1 Heat", "Stage 2 Cool"
|
||||
if action is None:
|
||||
if "heat" in str(c4_state).lower():
|
||||
action = HVACAction.HEATING
|
||||
elif "cool" in str(c4_state).lower():
|
||||
action = HVACAction.COOLING
|
||||
if action is None:
|
||||
_LOGGER.debug("Unknown HVAC state received from Control4: %s", c4_state)
|
||||
return action
|
||||
|
||||
@@ -189,7 +189,7 @@ class Control4Light(Control4Entity, LightEntity):
|
||||
return C4Light(self.runtime_data.director, self._idx)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool:
|
||||
"""Return whether this light is on or off."""
|
||||
if self._is_dimmer:
|
||||
for var in CONTROL4_DIMMER_VARS:
|
||||
|
||||
@@ -59,21 +59,10 @@ class DanfossAir(SwitchEntity):
|
||||
def __init__(self, data, name, state_command, on_command, off_command):
|
||||
"""Initialize the switch."""
|
||||
self._data = data
|
||||
self._name = name
|
||||
self._attr_name = name
|
||||
self._state_command = state_command
|
||||
self._on_command = on_command
|
||||
self._off_command = off_command
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the switch."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if switch is on."""
|
||||
return self._state
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
@@ -89,6 +78,6 @@ class DanfossAir(SwitchEntity):
|
||||
"""Update the switch's state."""
|
||||
self._data.update()
|
||||
|
||||
self._state = self._data.get_value(self._state_command)
|
||||
if self._state is None:
|
||||
self._attr_is_on = self._data.get_value(self._state_command)
|
||||
if self._attr_is_on is None:
|
||||
_LOGGER.debug("Could not get data for %s", self._state_command)
|
||||
|
||||
@@ -137,7 +137,7 @@ class DecoraWifiLight(LightEntity):
|
||||
return int(self._switch.brightness * 255 / 100)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if switch is on."""
|
||||
return self._switch.power == "ON"
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from homeassistant.components.vacuum import (
|
||||
ATTR_CLEANED_AREA,
|
||||
Segment,
|
||||
StateVacuumEntity,
|
||||
VacuumActivity,
|
||||
VacuumEntityFeature,
|
||||
@@ -14,8 +15,11 @@ from homeassistant.components.vacuum import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import event
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF
|
||||
|
||||
SUPPORT_BASIC_SERVICES = (
|
||||
@@ -45,9 +49,17 @@ SUPPORT_ALL_SERVICES = (
|
||||
| VacuumEntityFeature.LOCATE
|
||||
| VacuumEntityFeature.MAP
|
||||
| VacuumEntityFeature.CLEAN_SPOT
|
||||
| VacuumEntityFeature.CLEAN_AREA
|
||||
)
|
||||
|
||||
FAN_SPEEDS = ["min", "medium", "high", "max"]
|
||||
DEMO_SEGMENTS = [
|
||||
Segment(id="living_room", name="Living room"),
|
||||
Segment(id="kitchen", name="Kitchen"),
|
||||
Segment(id="bedroom_1", name="Master bedroom", group="Bedrooms"),
|
||||
Segment(id="bedroom_2", name="Guest bedroom", group="Bedrooms"),
|
||||
Segment(id="bathroom", name="Bathroom"),
|
||||
]
|
||||
DEMO_VACUUM_COMPLETE = "Demo vacuum 0 ground floor"
|
||||
DEMO_VACUUM_MOST = "Demo vacuum 1 first floor"
|
||||
DEMO_VACUUM_BASIC = "Demo vacuum 2 second floor"
|
||||
@@ -63,11 +75,11 @@ async def async_setup_entry(
|
||||
"""Set up the Demo config entry."""
|
||||
async_add_entities(
|
||||
[
|
||||
StateDemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES),
|
||||
StateDemoVacuum(DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES),
|
||||
StateDemoVacuum(DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES),
|
||||
StateDemoVacuum(DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES),
|
||||
StateDemoVacuum(DEMO_VACUUM_NONE, VacuumEntityFeature(0)),
|
||||
StateDemoVacuum("vacuum_1", DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES),
|
||||
StateDemoVacuum("vacuum_2", DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES),
|
||||
StateDemoVacuum("vacuum_3", DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES),
|
||||
StateDemoVacuum("vacuum_4", DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES),
|
||||
StateDemoVacuum("vacuum_5", DEMO_VACUUM_NONE, VacuumEntityFeature(0)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -75,13 +87,21 @@ async def async_setup_entry(
|
||||
class StateDemoVacuum(StateVacuumEntity):
|
||||
"""Representation of a demo vacuum supporting states."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
_attr_translation_key = "model_s"
|
||||
|
||||
def __init__(self, name: str, supported_features: VacuumEntityFeature) -> None:
|
||||
def __init__(
|
||||
self, unique_id: str, name: str, supported_features: VacuumEntityFeature
|
||||
) -> None:
|
||||
"""Initialize the vacuum."""
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_supported_features = supported_features
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=name,
|
||||
)
|
||||
self._attr_activity = VacuumActivity.DOCKED
|
||||
self._fan_speed = FAN_SPEEDS[1]
|
||||
self._cleaned_area: float = 0
|
||||
@@ -163,6 +183,16 @@ class StateDemoVacuum(StateVacuumEntity):
|
||||
self._attr_activity = VacuumActivity.IDLE
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_get_segments(self) -> list[Segment]:
|
||||
"""Get the list of segments."""
|
||||
return DEMO_SEGMENTS
|
||||
|
||||
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
|
||||
"""Clean the specified segments."""
|
||||
self._attr_activity = VacuumActivity.CLEANING
|
||||
self._cleaned_area += len(segment_ids) * 0.7
|
||||
self.async_write_ha_state()
|
||||
|
||||
def __set_state_to_dock(self, _: datetime) -> None:
|
||||
self._attr_activity = VacuumActivity.DOCKED
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]",
|
||||
"reconfigure_successful": "**Reconfiguration was successful**\n\nGo to the [webhook service of Dialogflow]({dialogflow_url}) and update the webhook with following settings:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) for further details.",
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
|
||||
"webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]"
|
||||
},
|
||||
@@ -9,6 +10,10 @@
|
||||
"default": "To send events to Home Assistant, you will need to set up the [webhook service of Dialogflow]({dialogflow_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) for further details."
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"description": "Are you sure you want to reconfigure Dialogflow?",
|
||||
"title": "Reconfigure Dialogflow webhook"
|
||||
},
|
||||
"user": {
|
||||
"description": "Are you sure you want to set up Dialogflow?",
|
||||
"title": "Set up the Dialogflow webhook"
|
||||
|
||||
@@ -11,8 +11,7 @@ ATTR_FILENAME = "filename"
|
||||
ATTR_SUBDIR = "subdir"
|
||||
ATTR_URL = "url"
|
||||
ATTR_OVERWRITE = "overwrite"
|
||||
|
||||
CONF_DOWNLOAD_DIR = "download_dir"
|
||||
ATTR_HEADERS = "headers"
|
||||
|
||||
DOWNLOAD_FAILED_EVENT = "download_failed"
|
||||
DOWNLOAD_COMPLETED_EVENT = "download_completed"
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
|
||||
from .const import (
|
||||
_LOGGER,
|
||||
ATTR_FILENAME,
|
||||
ATTR_HEADERS,
|
||||
ATTR_OVERWRITE,
|
||||
ATTR_SUBDIR,
|
||||
ATTR_URL,
|
||||
@@ -39,6 +40,7 @@ def download_file(service: ServiceCall) -> None:
|
||||
subdir: str | None = service.data.get(ATTR_SUBDIR)
|
||||
target_filename: str | None = service.data.get(ATTR_FILENAME)
|
||||
overwrite: bool = service.data[ATTR_OVERWRITE]
|
||||
headers: dict[str, str] = service.data[ATTR_HEADERS]
|
||||
|
||||
if subdir:
|
||||
# Check the path
|
||||
@@ -62,7 +64,7 @@ def download_file(service: ServiceCall) -> None:
|
||||
final_path = None
|
||||
filename = target_filename
|
||||
try:
|
||||
req = requests.get(url, stream=True, timeout=10)
|
||||
req = requests.get(url, stream=True, headers=headers, timeout=10)
|
||||
|
||||
if req.status_code != HTTPStatus.OK:
|
||||
_LOGGER.warning(
|
||||
@@ -162,6 +164,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
vol.Optional(ATTR_SUBDIR): cv.string,
|
||||
vol.Required(ATTR_URL): cv.url,
|
||||
vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean,
|
||||
vol.Optional(ATTR_HEADERS, default=dict): vol.Schema(
|
||||
{cv.string: cv.string}
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -17,3 +17,9 @@ download_file:
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
headers:
|
||||
default: {}
|
||||
example:
|
||||
Accept: application/json
|
||||
selector:
|
||||
object:
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
"description": "Custom name for the downloaded file.",
|
||||
"name": "Filename"
|
||||
},
|
||||
"headers": {
|
||||
"description": "Additional custom HTTP headers.",
|
||||
"name": "Headers"
|
||||
},
|
||||
"overwrite": {
|
||||
"description": "Overwrite file if it exists.",
|
||||
"name": "Overwrite"
|
||||
|
||||
@@ -38,3 +38,18 @@ def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None:
|
||||
"url": "/config/integrations/dashboard/add?domain=duckdns"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def action_called_without_config_entry(hass: HomeAssistant) -> None:
|
||||
"""Deprecate the use of action without config entry."""
|
||||
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_call_without_config_entry",
|
||||
breaks_in_ha_version="2026.9.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_call_without_config_entry",
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.helpers.selector import ConfigEntrySelector
|
||||
from .const import ATTR_CONFIG_ENTRY, ATTR_TXT, DOMAIN, SERVICE_SET_TXT
|
||||
from .coordinator import DuckDnsConfigEntry
|
||||
from .helpers import update_duckdns
|
||||
from .issue import action_called_without_config_entry
|
||||
|
||||
SERVICE_TXT_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -42,6 +43,7 @@ def get_config_entry(
|
||||
"""Return config entry or raise if not found or not loaded."""
|
||||
|
||||
if entry_id is None:
|
||||
action_called_without_config_entry(hass)
|
||||
if len(entries := hass.config_entries.async_entries(DOMAIN)) != 1:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"data_description": {
|
||||
"access_token": "[%key:component::duckdns::config::step::user::data_description::access_token%]"
|
||||
},
|
||||
"title": "Re-configure {name}"
|
||||
"title": "Reconfigure {name}"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
@@ -46,6 +46,10 @@
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_call_without_config_entry": {
|
||||
"description": "Calling the `duckdns.set_txt` action without specifying a config entry is deprecated.\n\nThe `config_entry_id` field will be required in a future release.\n\nPlease update your automations and scripts to include the `config_entry_id` parameter.",
|
||||
"title": "Detected deprecated use of action without config entry"
|
||||
},
|
||||
"deprecated_yaml_import_issue_error": {
|
||||
"description": "Configuring Duck DNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Duck DNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
|
||||
"title": "The Duck DNS YAML configuration import failed"
|
||||
|
||||
@@ -74,6 +74,6 @@ class EcoNetBinarySensor(EcoNetEntity, BinarySensorEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return getattr(self._econet, self.entity_description.key)
|
||||
|
||||
@@ -38,12 +38,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EcovacsConfigEntry) -> bool:
|
||||
"""Set up this integration using UI."""
|
||||
controller = EcovacsController(hass, entry.data)
|
||||
|
||||
entry.async_on_unload(controller.teardown)
|
||||
|
||||
await controller.initialize()
|
||||
|
||||
async def on_unload() -> None:
|
||||
await controller.teardown()
|
||||
|
||||
entry.async_on_unload(on_unload)
|
||||
entry.runtime_data = controller
|
||||
|
||||
async def _async_wait_connect(device: VacBot) -> None:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from functools import partial
|
||||
import logging
|
||||
@@ -80,11 +81,22 @@ class EcovacsController:
|
||||
try:
|
||||
devices = await self._api_client.get_devices()
|
||||
credentials = await self._authenticator.authenticate()
|
||||
for device_info in devices.mqtt:
|
||||
device = Device(device_info, self._authenticator)
|
||||
|
||||
if devices.mqtt:
|
||||
mqtt = await self._get_mqtt_client()
|
||||
await device.initialize(mqtt)
|
||||
self._devices.append(device)
|
||||
mqtt_devices = [
|
||||
Device(info, self._authenticator) for info in devices.mqtt
|
||||
]
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
|
||||
async def _init(device: Device) -> None:
|
||||
"""Initialize MQTT device."""
|
||||
await device.initialize(mqtt)
|
||||
self._devices.append(device)
|
||||
|
||||
for device in mqtt_devices:
|
||||
tg.create_task(_init(device))
|
||||
|
||||
for device_config in devices.xmpp:
|
||||
bot = VacBot(
|
||||
credentials.user_id,
|
||||
|
||||
@@ -53,25 +53,9 @@ class SmartPlugSwitch(SwitchEntity):
|
||||
def __init__(self, smartplug, name):
|
||||
"""Initialize the switch."""
|
||||
self.smartplug = smartplug
|
||||
self._name = name
|
||||
self._state = False
|
||||
self._attr_name = name
|
||||
self._attr_is_on = False
|
||||
self._info = None
|
||||
self._mac = None
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the device's MAC address."""
|
||||
return self._mac
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the Smart Plug, if any."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if switch is on."""
|
||||
return self._state
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
@@ -85,6 +69,6 @@ class SmartPlugSwitch(SwitchEntity):
|
||||
"""Update edimax switch."""
|
||||
if not self._info:
|
||||
self._info = self.smartplug.info
|
||||
self._mac = self._info["mac"]
|
||||
self._attr_unique_id = self._info["mac"]
|
||||
|
||||
self._state = self.smartplug.state == "ON"
|
||||
self._attr_is_on = self.smartplug.state == "ON"
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from egauge_async.json.models import RegisterType
|
||||
from egauge_async.json.models import RegisterInfo, RegisterType
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfEnergy, UnitOfPower
|
||||
from homeassistant.const import UnitOfElectricPotential, UnitOfEnergy, UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -27,6 +27,7 @@ class EgaugeSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
native_value_fn: Callable[[EgaugeData, str], float]
|
||||
available_fn: Callable[[EgaugeData, str], bool]
|
||||
supported_fn: Callable[[RegisterInfo], bool]
|
||||
|
||||
|
||||
SENSORS: tuple[EgaugeSensorEntityDescription, ...] = (
|
||||
@@ -37,6 +38,7 @@ SENSORS: tuple[EgaugeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
native_value_fn=lambda data, register: data.measurements[register],
|
||||
available_fn=lambda data, register: register in data.measurements,
|
||||
supported_fn=lambda register_info: register_info.type == RegisterType.POWER,
|
||||
),
|
||||
EgaugeSensorEntityDescription(
|
||||
key="energy",
|
||||
@@ -46,6 +48,16 @@ SENSORS: tuple[EgaugeSensorEntityDescription, ...] = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
native_value_fn=lambda data, register: data.counters[register],
|
||||
available_fn=lambda data, register: register in data.counters,
|
||||
supported_fn=lambda register_info: register_info.type == RegisterType.POWER,
|
||||
),
|
||||
EgaugeSensorEntityDescription(
|
||||
key="voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
native_value_fn=lambda data, register: data.measurements[register],
|
||||
available_fn=lambda data, register: register in data.measurements,
|
||||
supported_fn=lambda register_info: register_info.type == RegisterType.VOLTAGE,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -61,7 +73,7 @@ async def async_setup_entry(
|
||||
EgaugeSensor(coordinator, register_name, sensor)
|
||||
for sensor in SENSORS
|
||||
for register_name, register_info in coordinator.data.register_info.items()
|
||||
if register_info.type == RegisterType.POWER
|
||||
if sensor.supported_fn(register_info)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = "PCA 301"
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass: HomeAssistant,
|
||||
@@ -54,26 +52,9 @@ class SmartPlugSwitch(SwitchEntity):
|
||||
def __init__(self, pca, device_id):
|
||||
"""Initialize the switch."""
|
||||
self._device_id = device_id
|
||||
self._name = "PCA 301"
|
||||
self._state = None
|
||||
self._available = True
|
||||
self._attr_name = "PCA 301"
|
||||
self._pca = pca
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the Smart Plug, if any."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if switch is available."""
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if switch is on."""
|
||||
return self._state
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
self._pca.turn_on(self._device_id)
|
||||
@@ -85,10 +66,10 @@ class SmartPlugSwitch(SwitchEntity):
|
||||
def update(self) -> None:
|
||||
"""Update the PCA switch's state."""
|
||||
try:
|
||||
self._state = self._pca.get_state(self._device_id)
|
||||
self._available = True
|
||||
self._attr_is_on = self._pca.get_state(self._device_id)
|
||||
self._attr_available = True
|
||||
|
||||
except OSError as ex:
|
||||
if self._available:
|
||||
if self._attr_available:
|
||||
_LOGGER.warning("Could not read state for %s: %s", self.name, ex)
|
||||
self._available = False
|
||||
self._attr_available = False
|
||||
|
||||
@@ -173,6 +173,9 @@ class GasSourceType(TypedDict):
|
||||
|
||||
stat_energy_from: str
|
||||
|
||||
# Instantaneous flow rate: m³/h, L/min, etc.
|
||||
stat_rate: NotRequired[str]
|
||||
|
||||
# statistic_id of costs ($) incurred from the gas meter
|
||||
# If set to None and entity_energy_price or number_energy_price are configured,
|
||||
# an EnergyCostSensor will be automatically created
|
||||
@@ -190,6 +193,9 @@ class WaterSourceType(TypedDict):
|
||||
|
||||
stat_energy_from: str
|
||||
|
||||
# Instantaneous flow rate: L/min, gal/min, m³/h, etc.
|
||||
stat_rate: NotRequired[str]
|
||||
|
||||
# statistic_id of costs ($) incurred from the water meter
|
||||
# If set to None and entity_energy_price or number_energy_price are configured,
|
||||
# an EnergyCostSensor will be automatically created
|
||||
@@ -440,6 +446,7 @@ GAS_SOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("type"): "gas",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("stat_cost"): vol.Any(str, None),
|
||||
# entity_energy_from was removed in HA Core 2022.10
|
||||
vol.Remove("entity_energy_from"): vol.Any(str, None),
|
||||
@@ -451,6 +458,7 @@ WATER_SOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("type"): "water",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("stat_cost"): vol.Any(str, None),
|
||||
vol.Optional("entity_energy_price"): vol.Any(str, None),
|
||||
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
|
||||
|
||||
@@ -44,6 +44,10 @@
|
||||
"description": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::description%]",
|
||||
"title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]"
|
||||
},
|
||||
"entity_unexpected_unit_volume_flow_rate": {
|
||||
"description": "The following entities do not have an expected unit of measurement (either of {flow_rate_units}):",
|
||||
"title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]"
|
||||
},
|
||||
"entity_unexpected_unit_water": {
|
||||
"description": "The following entities do not have the expected unit of measurement (either of {water_units}):",
|
||||
"title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]"
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.const import (
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfVolume,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
||||
|
||||
@@ -28,6 +29,11 @@ POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,)
|
||||
POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = {
|
||||
sensor.SensorDeviceClass.POWER: tuple(UnitOfPower)
|
||||
}
|
||||
VOLUME_FLOW_RATE_DEVICE_CLASSES = (sensor.SensorDeviceClass.VOLUME_FLOW_RATE,)
|
||||
VOLUME_FLOW_RATE_UNITS: dict[str, tuple[UnitOfVolumeFlowRate, ...]] = {
|
||||
sensor.SensorDeviceClass.VOLUME_FLOW_RATE: tuple(UnitOfVolumeFlowRate)
|
||||
}
|
||||
VOLUME_FLOW_RATE_UNIT_ERROR = "entity_unexpected_unit_volume_flow_rate"
|
||||
|
||||
ENERGY_PRICE_UNITS = tuple(
|
||||
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
|
||||
@@ -109,6 +115,12 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] |
|
||||
return {
|
||||
"price_units": ", ".join(f"{currency}{unit}" for unit in WATER_PRICE_UNITS),
|
||||
}
|
||||
if issue_type == VOLUME_FLOW_RATE_UNIT_ERROR:
|
||||
return {
|
||||
"flow_rate_units": ", ".join(
|
||||
VOLUME_FLOW_RATE_UNITS[sensor.SensorDeviceClass.VOLUME_FLOW_RATE]
|
||||
),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
@@ -590,6 +602,21 @@ def _validate_gas_source(
|
||||
)
|
||||
)
|
||||
|
||||
if stat_rate := source.get("stat_rate"):
|
||||
wanted_statistics_metadata.add(stat_rate)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_power_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_rate,
|
||||
VOLUME_FLOW_RATE_DEVICE_CLASSES,
|
||||
VOLUME_FLOW_RATE_UNITS,
|
||||
VOLUME_FLOW_RATE_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _validate_water_source(
|
||||
hass: HomeAssistant,
|
||||
@@ -650,6 +677,21 @@ def _validate_water_source(
|
||||
)
|
||||
)
|
||||
|
||||
if stat_rate := source.get("stat_rate"):
|
||||
wanted_statistics_metadata.add(stat_rate)
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_power_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
stat_rate,
|
||||
VOLUME_FLOW_RATE_DEVICE_CLASSES,
|
||||
VOLUME_FLOW_RATE_UNITS,
|
||||
VOLUME_FLOW_RATE_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
"""Validate the energy configuration."""
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Support for EnOcean devices."""
|
||||
|
||||
from serial import SerialException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import CONF_DEVICE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -42,7 +44,10 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, config_entry: EnOceanConfigEntry
|
||||
) -> bool:
|
||||
"""Set up an EnOcean dongle for the given entry."""
|
||||
usb_dongle = EnOceanDongle(hass, config_entry.data[CONF_DEVICE])
|
||||
try:
|
||||
usb_dongle = EnOceanDongle(hass, config_entry.data[CONF_DEVICE])
|
||||
except SerialException as err:
|
||||
raise ConfigEntryNotReady(f"Failed to set up EnOcean dongle: {err}") from err
|
||||
await usb_dongle.async_setup()
|
||||
config_entry.runtime_data = usb_dongle
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
|
||||
return attr
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if sensor is on."""
|
||||
return self._info["status"]["open"]
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ class EnvisalinkSwitch(EnvisalinkEntity, SwitchEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool:
|
||||
"""Return the boolean response if the zone is bypassed."""
|
||||
return self._info["bypassed"]
|
||||
|
||||
|
||||
@@ -46,12 +46,12 @@ class EufyHomeLight(LightEntity):
|
||||
self._temp = None
|
||||
self._brightness = None
|
||||
self._hs = None
|
||||
self._state = None
|
||||
self._name = device["name"]
|
||||
self._address = device["address"]
|
||||
self._code = device["code"]
|
||||
self._attr_name = device["name"]
|
||||
self._type = device["type"]
|
||||
self._bulb = lakeside.bulb(self._address, self._code, self._type)
|
||||
self._bulb = lakeside.bulb(
|
||||
(device_address := device["address"]), device["code"], self._type
|
||||
)
|
||||
self._attr_unique_id = device_address
|
||||
self._colormode = False
|
||||
if self._type == "T1011":
|
||||
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
@@ -72,22 +72,7 @@ class EufyHomeLight(LightEntity):
|
||||
self._hs = color_util.color_RGB_to_hs(*self._bulb.colors)
|
||||
else:
|
||||
self._colormode = False
|
||||
self._state = self._bulb.power
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the ID of this light."""
|
||||
return self._address
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device if any."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if device is on."""
|
||||
return self._state
|
||||
self._attr_is_on = self._bulb.power
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
|
||||
@@ -30,33 +30,17 @@ class EufyHomeSwitch(SwitchEntity):
|
||||
def __init__(self, device):
|
||||
"""Initialize the light."""
|
||||
|
||||
self._state = None
|
||||
self._name = device["name"]
|
||||
self._address = device["address"]
|
||||
self._code = device["code"]
|
||||
self._type = device["type"]
|
||||
self._switch = lakeside.switch(self._address, self._code, self._type)
|
||||
self._attr_name = device["name"]
|
||||
self._attr_unique_id = device["address"]
|
||||
self._switch = lakeside.switch(
|
||||
device["address"], device["code"], device["type"]
|
||||
)
|
||||
self._switch.connect()
|
||||
|
||||
def update(self) -> None:
|
||||
"""Synchronise state from the switch."""
|
||||
self._switch.update()
|
||||
self._state = self._switch.power
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the ID of this light."""
|
||||
return self._address
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device if any."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if device is on."""
|
||||
return self._state
|
||||
self._attr_is_on = self._switch.power
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the specified switch on."""
|
||||
|
||||
@@ -51,7 +51,7 @@ class FloPendingAlertsBinarySensor(FloEntity, BinarySensorEntity):
|
||||
super().__init__("pending_system_alerts", device)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the Flo device has pending alerts."""
|
||||
return self._device.has_alerts
|
||||
|
||||
@@ -78,6 +78,6 @@ class FloWaterDetectedBinarySensor(FloEntity, BinarySensorEntity):
|
||||
super().__init__("water_detected", device)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the Flo device is detecting water."""
|
||||
return self._device.water_detected
|
||||
|
||||
@@ -223,7 +223,7 @@ class FluxSwitch(SwitchEntity, RestoreEntity):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if switch is on."""
|
||||
return self.unsub_tracker is not None
|
||||
|
||||
|
||||
@@ -77,12 +77,10 @@ class FutureNowLight(LightEntity):
|
||||
|
||||
def __init__(self, device):
|
||||
"""Initialize the light."""
|
||||
self._name = device["name"]
|
||||
self._attr_name = device["name"]
|
||||
self._dimmable = device["dimmable"]
|
||||
self._channel = device["channel"]
|
||||
self._brightness = None
|
||||
self._last_brightness = 255
|
||||
self._state = None
|
||||
|
||||
if device["driver"] == CONF_DRIVER_FNIP6X10AD:
|
||||
self._light = pyfnip.FNIP6x2adOutput(
|
||||
@@ -93,21 +91,6 @@ class FutureNowLight(LightEntity):
|
||||
device["host"], device["port"], self._channel
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device if any."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if device is on."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return self._brightness
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
@@ -131,11 +114,11 @@ class FutureNowLight(LightEntity):
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
self._light.turn_off()
|
||||
if self._brightness:
|
||||
self._last_brightness = self._brightness
|
||||
if self._attr_brightness:
|
||||
self._last_brightness = self._attr_brightness
|
||||
|
||||
def update(self) -> None:
|
||||
"""Fetch new state data for this light."""
|
||||
state = int(self._light.is_on())
|
||||
self._state = bool(state)
|
||||
self._brightness = to_hass_level(state)
|
||||
self._attr_is_on = bool(state)
|
||||
self._attr_brightness = to_hass_level(state)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]",
|
||||
"reconfigure_successful": "**Reconfiguration was successful**\n\nGo to the webhook feature in Geofency and update the webhook with the following settings:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details.",
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
|
||||
"webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]"
|
||||
},
|
||||
@@ -9,6 +10,10 @@
|
||||
"default": "To send events to Home Assistant, you will need to set up the webhook feature in Geofency.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details."
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"description": "Are you sure you want to reconfigure the Geofency webhook?",
|
||||
"title": "Reconfigure Geofency webhook"
|
||||
},
|
||||
"user": {
|
||||
"description": "Are you sure you want to set up the Geofency webhook?",
|
||||
"title": "Set up the Geofency webhook"
|
||||
|
||||
@@ -23,8 +23,6 @@ ATTR_CREATED = "created"
|
||||
ATTR_UPDATED = "updated"
|
||||
ATTR_REMOVED = "removed"
|
||||
|
||||
DEFAULT_ICON = "mdi:pulse"
|
||||
DEFAULT_UNIT_OF_MEASUREMENT = "quakes"
|
||||
|
||||
# An update of this entity is not making a web request, but uses internal data only.
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -45,19 +43,20 @@ async def async_setup_entry(
|
||||
class GeonetnzQuakesSensor(SensorEntity):
|
||||
"""Status sensor for the GeoNet NZ Quakes integration."""
|
||||
|
||||
_attr_icon = "mdi:pulse"
|
||||
_attr_native_unit_of_measurement = "quakes"
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, config_entry_id, config_unique_id, config_title, manager):
|
||||
"""Initialize entity."""
|
||||
self._config_entry_id = config_entry_id
|
||||
self._config_unique_id = config_unique_id
|
||||
self._config_title = config_title
|
||||
self._attr_unique_id = config_unique_id
|
||||
self._attr_name = f"GeoNet NZ Quakes ({config_title})"
|
||||
self._manager = manager
|
||||
self._status = None
|
||||
self._last_update = None
|
||||
self._last_update_successful = None
|
||||
self._last_timestamp = None
|
||||
self._total = None
|
||||
self._created = None
|
||||
self._updated = None
|
||||
self._removed = None
|
||||
@@ -106,36 +105,11 @@ class GeonetnzQuakesSensor(SensorEntity):
|
||||
else:
|
||||
self._last_update_successful = None
|
||||
self._last_timestamp = status_info.last_timestamp
|
||||
self._total = status_info.total
|
||||
self._attr_native_value = status_info.total
|
||||
self._created = status_info.created
|
||||
self._updated = status_info.updated
|
||||
self._removed = status_info.removed
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self._total
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return a unique ID containing latitude/longitude."""
|
||||
return self._config_unique_id
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
"""Return the name of the entity."""
|
||||
return f"GeoNet NZ Quakes ({self._config_title})"
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon to use in the frontend, if any."""
|
||||
return DEFAULT_ICON
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
"""Return the unit of measurement."""
|
||||
return DEFAULT_UNIT_OF_MEASUREMENT
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DISCOVERY_TIMEOUT
|
||||
from .const import DISCOVERY_TIMEOUT, DOMAIN
|
||||
from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
@@ -52,7 +52,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -
|
||||
_LOGGER.error("Start failed, errno: %d", ex.errno)
|
||||
return False
|
||||
_LOGGER.error("Port %s already in use", LISTENING_PORT)
|
||||
raise ConfigEntryNotReady from ex
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="port_in_use",
|
||||
translation_placeholders={"port": LISTENING_PORT},
|
||||
) from ex
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -61,7 +65,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -
|
||||
while not coordinator.devices:
|
||||
await asyncio.sleep(delay=1)
|
||||
except TimeoutError as ex:
|
||||
raise ConfigEntryNotReady from ex
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN, translation_key="no_devices_found"
|
||||
) from ex
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -33,5 +33,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"no_devices_found": {
|
||||
"message": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
},
|
||||
"port_in_use": {
|
||||
"message": "Port {port} is already in use"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]",
|
||||
"reconfigure_successful": "**Reconfiguration was successful**\n\nGo to the webhook feature in GPSLogger and update the webhook with the following settings:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details.",
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
|
||||
"webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]"
|
||||
},
|
||||
@@ -9,6 +10,10 @@
|
||||
"default": "To send events to Home Assistant, you will need to set up the webhook feature in GPSLogger.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details."
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"description": "Are you sure you want to reconfigure the GPSLogger webhook?",
|
||||
"title": "Reconfigure GPSLogger webhook"
|
||||
},
|
||||
"user": {
|
||||
"description": "Are you sure you want to set up the GPSLogger webhook?",
|
||||
"title": "Set up the GPSLogger webhook"
|
||||
|
||||
@@ -74,18 +74,13 @@ class GreenwaveLight(LightEntity):
|
||||
"""Initialize a Greenwave Reality Light."""
|
||||
self._did = int(light["did"])
|
||||
self._attr_name = light["name"]
|
||||
self._state = int(light["state"])
|
||||
self._attr_is_on = bool(int(light["state"]))
|
||||
self._attr_brightness = greenwave.hass_brightness(light)
|
||||
self._host = host
|
||||
self._attr_available = greenwave.check_online(light)
|
||||
self._token = token
|
||||
self._gatewaydata = gatewaydata
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if light is on."""
|
||||
return self._state
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn on."""
|
||||
temp_brightness = int((kwargs.get(ATTR_BRIGHTNESS, 255) / 255) * 100)
|
||||
@@ -101,7 +96,7 @@ class GreenwaveLight(LightEntity):
|
||||
self._gatewaydata.update()
|
||||
bulbs = self._gatewaydata.greenwave
|
||||
|
||||
self._state = int(bulbs[self._did]["state"])
|
||||
self._attr_is_on = bool(int(bulbs[self._did]["state"]))
|
||||
self._attr_brightness = greenwave.hass_brightness(bulbs[self._did])
|
||||
self._attr_available = greenwave.check_online(bulbs[self._did])
|
||||
self._attr_name = bulbs[self._did]["name"]
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import growattServer
|
||||
|
||||
from homeassistant.components.sensor import SensorStateClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -54,6 +55,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.device_type = device_type
|
||||
self.plant_id = plant_id
|
||||
self.previous_values: dict[str, Any] = {}
|
||||
self._pre_reset_values: dict[str, float] = {}
|
||||
|
||||
if self.api_version == "v1":
|
||||
self.username = None
|
||||
@@ -251,6 +253,40 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
return_value = previous_value
|
||||
|
||||
# Suppress midnight bounce for TOTAL_INCREASING "today" sensors.
|
||||
# The Growatt API sometimes delivers stale yesterday values after a midnight
|
||||
# reset (0 → stale → 0), causing TOTAL_INCREASING double-counting.
|
||||
if (
|
||||
entity_description.state_class is SensorStateClass.TOTAL_INCREASING
|
||||
and not entity_description.never_resets
|
||||
and return_value is not None
|
||||
and previous_value is not None
|
||||
):
|
||||
current_val = float(return_value)
|
||||
prev_val = float(previous_value)
|
||||
if prev_val > 0 and current_val == 0:
|
||||
# Value dropped to 0 from a positive level — track it.
|
||||
self._pre_reset_values[variable] = prev_val
|
||||
elif variable in self._pre_reset_values:
|
||||
pre_reset = self._pre_reset_values[variable]
|
||||
if current_val == pre_reset:
|
||||
# Value equals yesterday's final value — the API is
|
||||
# serving a stale cached response (bounce)
|
||||
_LOGGER.debug(
|
||||
"Suppressing midnight bounce for %s: stale value %s matches "
|
||||
"pre-reset value, keeping %s",
|
||||
variable,
|
||||
current_val,
|
||||
previous_value,
|
||||
)
|
||||
return_value = previous_value
|
||||
elif current_val > 0:
|
||||
# Genuine new-day production — clear tracking
|
||||
del self._pre_reset_values[variable]
|
||||
|
||||
# Note: previous_values stores the *output* value (after suppression),
|
||||
# not the raw API value. This is intentional — after a suppressed bounce,
|
||||
# previous_value will be 0, which is what downstream comparisons need.
|
||||
self.previous_values[variable] = return_value
|
||||
|
||||
return return_value
|
||||
|
||||
@@ -152,6 +152,8 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
self._attr_in_progress = True
|
||||
self.async_write_ha_state()
|
||||
await update_addon(
|
||||
self.hass, self._addon_slug, backup, self.title, self.installed_version
|
||||
)
|
||||
@@ -308,6 +310,8 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
self._attr_in_progress = True
|
||||
self.async_write_ha_state()
|
||||
await update_core(self.hass, version, backup)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -7,6 +7,7 @@ from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
"default": "mdi:connection"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"oled_fade": {
|
||||
"default": "mdi:cellphone-information"
|
||||
},
|
||||
"reboot_timer": {
|
||||
"default": "mdi:timer-refresh"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"opmode": {
|
||||
"default": "mdi:cogs"
|
||||
|
||||
101
homeassistant/components/hdfury/number.py
Normal file
101
homeassistant/components/hdfury/number.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Number platform for HDFury Integration."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from hdfury import HDFuryAPI, HDFuryError
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HDFuryConfigEntry
|
||||
from .entity import HDFuryEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class HDFuryNumberEntityDescription(NumberEntityDescription):
|
||||
"""Description for HDFury number entities."""
|
||||
|
||||
set_value_fn: Callable[[HDFuryAPI, str], Awaitable[None]]
|
||||
|
||||
|
||||
NUMBERS: tuple[HDFuryNumberEntityDescription, ...] = (
|
||||
HDFuryNumberEntityDescription(
|
||||
key="oledfade",
|
||||
translation_key="oled_fade",
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=1,
|
||||
native_max_value=100,
|
||||
native_step=1,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=lambda client, value: client.set_oled_fade(value),
|
||||
),
|
||||
HDFuryNumberEntityDescription(
|
||||
key="reboottimer",
|
||||
translation_key="reboot_timer",
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_step=1,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.HOURS,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=lambda client, value: client.set_reboot_timer(value),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HDFuryConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up numbers using the platform schema."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
HDFuryNumber(coordinator, description)
|
||||
for description in NUMBERS
|
||||
if description.key in coordinator.data.config
|
||||
)
|
||||
|
||||
|
||||
class HDFuryNumber(HDFuryEntity, NumberEntity):
|
||||
"""Base HDFury Number Class."""
|
||||
|
||||
entity_description: HDFuryNumberEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Return the current number value."""
|
||||
|
||||
return float(self.coordinator.data.config[self.entity_description.key])
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set Number Value Event."""
|
||||
|
||||
try:
|
||||
await self.entity_description.set_value_fn(
|
||||
self.coordinator.client, str(int(value))
|
||||
)
|
||||
except HDFuryError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
) from error
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -40,6 +40,14 @@
|
||||
"name": "Issue hotplug"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"oled_fade": {
|
||||
"name": "OLED fade timer"
|
||||
},
|
||||
"reboot_timer": {
|
||||
"name": "Restart timer"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"opmode": {
|
||||
"name": "Operation mode",
|
||||
|
||||
@@ -19,8 +19,6 @@ from homeassistant.const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_USERNAME,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -79,19 +77,9 @@ class HikvisionMotionSwitch(SwitchEntity):
|
||||
|
||||
def __init__(self, name, hikvision_cam):
|
||||
"""Initialize the switch."""
|
||||
self._name = name
|
||||
self._attr_name = name
|
||||
self._hikvision_cam = hikvision_cam
|
||||
self._state = STATE_OFF
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device if any."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if device is on."""
|
||||
return self._state == STATE_ON
|
||||
self._attr_is_on = False
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the device on."""
|
||||
@@ -105,7 +93,5 @@ class HikvisionMotionSwitch(SwitchEntity):
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update Motion Detection state."""
|
||||
enabled = self._hikvision_cam.is_motion_detection_enabled()
|
||||
_LOGGING.info("enabled: %s", enabled)
|
||||
|
||||
self._state = STATE_ON if enabled else STATE_OFF
|
||||
self._attr_is_on = self._hikvision_cam.is_motion_detection_enabled()
|
||||
_LOGGING.info("enabled: %s", self._attr_is_on)
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_STATE
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -105,6 +106,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, options=options, minor_version=2
|
||||
)
|
||||
if config_entry.minor_version < 3:
|
||||
# Set the state class to measurement for backward compatibility
|
||||
options[CONF_STATE_CLASS] = SensorStateClass.MEASUREMENT
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, options=options, minor_version=3
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to version %s.%s successful",
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any, cast
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -39,6 +40,7 @@ from .const import (
|
||||
CONF_PERIOD_KEYS,
|
||||
CONF_START,
|
||||
CONF_TYPE_KEYS,
|
||||
CONF_TYPE_RATIO,
|
||||
CONF_TYPE_TIME,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
@@ -101,10 +103,19 @@ async def get_state_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
|
||||
async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
|
||||
"""Return schema for options step."""
|
||||
entity_id = handler.options[CONF_ENTITY_ID]
|
||||
return _get_options_schema_with_entity_id(entity_id)
|
||||
conf_type = handler.options[CONF_TYPE]
|
||||
return _get_options_schema_with_entity_id(entity_id, conf_type)
|
||||
|
||||
|
||||
def _get_options_schema_with_entity_id(entity_id: str) -> vol.Schema:
|
||||
def _get_options_schema_with_entity_id(entity_id: str, type: str) -> vol.Schema:
|
||||
state_class_options = (
|
||||
[SensorStateClass.MEASUREMENT]
|
||||
if type == CONF_TYPE_RATIO
|
||||
else [
|
||||
SensorStateClass.MEASUREMENT,
|
||||
SensorStateClass.TOTAL_INCREASING,
|
||||
]
|
||||
)
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_ENTITY_ID): EntitySelector(
|
||||
@@ -130,6 +141,13 @@ def _get_options_schema_with_entity_id(entity_id: str) -> vol.Schema:
|
||||
vol.Optional(CONF_DURATION): DurationSelector(
|
||||
DurationSelectorConfig(enable_day=True, allow_negative=False)
|
||||
),
|
||||
vol.Optional(CONF_STATE_CLASS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=state_class_options,
|
||||
translation_key=CONF_STATE_CLASS,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -158,7 +176,7 @@ OPTIONS_FLOW = {
|
||||
class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||
"""Handle a config flow for History stats."""
|
||||
|
||||
MINOR_VERSION = 2
|
||||
MINOR_VERSION = 3
|
||||
|
||||
config_flow = CONFIG_FLOW
|
||||
options_flow = OPTIONS_FLOW
|
||||
@@ -201,6 +219,7 @@ async def ws_start_preview(
|
||||
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
|
||||
entity_id = options[CONF_ENTITY_ID]
|
||||
name = options[CONF_NAME]
|
||||
conf_type = options[CONF_TYPE]
|
||||
else:
|
||||
flow_status = hass.config_entries.options.async_get(msg["flow_id"])
|
||||
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
|
||||
@@ -208,6 +227,7 @@ async def ws_start_preview(
|
||||
raise HomeAssistantError("Config entry not found")
|
||||
entity_id = config_entry.options[CONF_ENTITY_ID]
|
||||
name = config_entry.options[CONF_NAME]
|
||||
conf_type = config_entry.options[CONF_TYPE]
|
||||
|
||||
@callback
|
||||
def async_preview_updated(
|
||||
@@ -233,7 +253,7 @@ async def ws_start_preview(
|
||||
|
||||
validated_data: Any = None
|
||||
try:
|
||||
validated_data = (_get_options_schema_with_entity_id(entity_id))(
|
||||
validated_data = (_get_options_schema_with_entity_id(entity_id, conf_type))(
|
||||
msg["user_input"]
|
||||
)
|
||||
except vol.Invalid as ex:
|
||||
@@ -255,6 +275,7 @@ async def ws_start_preview(
|
||||
start = validated_data.get(CONF_START)
|
||||
end = validated_data.get(CONF_END)
|
||||
duration = validated_data.get(CONF_DURATION)
|
||||
state_class = validated_data.get(CONF_STATE_CLASS)
|
||||
|
||||
history_stats = HistoryStats(
|
||||
hass,
|
||||
@@ -274,6 +295,7 @@ async def ws_start_preview(
|
||||
name=name,
|
||||
unique_id=None,
|
||||
source_entity_id=entity_id,
|
||||
state_class=state_class,
|
||||
)
|
||||
preview_entity.hass = hass
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
CONF_STATE_CLASS,
|
||||
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -72,6 +73,16 @@ def exactly_two_period_keys[_T: dict[str, Any]](conf: _T) -> _T:
|
||||
return conf
|
||||
|
||||
|
||||
def no_ratio_total[_T: dict[str, Any]](conf: _T) -> _T:
|
||||
"""Ensure state_class:total_increasing not used with type:ratio."""
|
||||
if (
|
||||
conf.get(CONF_TYPE) == CONF_TYPE_RATIO
|
||||
and conf.get(CONF_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING
|
||||
):
|
||||
raise vol.Invalid("State class total_increasing not to be used with type ratio")
|
||||
return conf
|
||||
|
||||
|
||||
PLATFORM_SCHEMA = vol.All(
|
||||
SENSOR_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
@@ -83,9 +94,15 @@ PLATFORM_SCHEMA = vol.All(
|
||||
vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
vol.Optional(
|
||||
CONF_STATE_CLASS, default=SensorStateClass.MEASUREMENT
|
||||
): vol.In(
|
||||
[None, SensorStateClass.MEASUREMENT, SensorStateClass.TOTAL_INCREASING]
|
||||
),
|
||||
}
|
||||
),
|
||||
exactly_two_period_keys,
|
||||
no_ratio_total,
|
||||
)
|
||||
|
||||
|
||||
@@ -106,6 +123,9 @@ async def async_setup_platform(
|
||||
sensor_type: str = config[CONF_TYPE]
|
||||
name: str = config[CONF_NAME]
|
||||
unique_id: str | None = config.get(CONF_UNIQUE_ID)
|
||||
state_class: SensorStateClass | None = config.get(
|
||||
CONF_STATE_CLASS, SensorStateClass.MEASUREMENT
|
||||
)
|
||||
|
||||
history_stats = HistoryStats(hass, entity_id, entity_states, start, end, duration)
|
||||
coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name)
|
||||
@@ -121,6 +141,7 @@ async def async_setup_platform(
|
||||
name=name,
|
||||
unique_id=unique_id,
|
||||
source_entity_id=entity_id,
|
||||
state_class=state_class,
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -136,6 +157,7 @@ async def async_setup_entry(
|
||||
sensor_type: str = entry.options[CONF_TYPE]
|
||||
coordinator = entry.runtime_data
|
||||
entity_id: str = entry.options[CONF_ENTITY_ID]
|
||||
state_class: SensorStateClass | None = entry.options.get(CONF_STATE_CLASS)
|
||||
async_add_entities(
|
||||
[
|
||||
HistoryStatsSensor(
|
||||
@@ -145,6 +167,7 @@ async def async_setup_entry(
|
||||
name=entry.title,
|
||||
unique_id=entry.entry_id,
|
||||
source_entity_id=entity_id,
|
||||
state_class=state_class,
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -185,8 +208,6 @@ class HistoryStatsSensorBase(
|
||||
class HistoryStatsSensor(HistoryStatsSensorBase):
|
||||
"""A HistoryStats sensor."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -196,6 +217,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
|
||||
name: str,
|
||||
unique_id: str | None,
|
||||
source_entity_id: str,
|
||||
state_class: SensorStateClass | None,
|
||||
) -> None:
|
||||
"""Initialize the HistoryStats sensor."""
|
||||
super().__init__(coordinator, name)
|
||||
@@ -204,6 +226,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
|
||||
) = None
|
||||
self._attr_native_unit_of_measurement = UNITS[sensor_type]
|
||||
self._type = sensor_type
|
||||
self._attr_state_class = state_class
|
||||
self._attr_unique_id = unique_id
|
||||
if source_entity_id: # Guard against empty source_entity_id in preview mode
|
||||
self.device_entry = async_entity_id_to_device(
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"entity_id": "[%key:component::history_stats::config::step::user::data::entity_id%]",
|
||||
"start": "Start",
|
||||
"state": "[%key:component::history_stats::config::step::user::data::state%]",
|
||||
"state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]",
|
||||
"type": "[%key:component::history_stats::config::step::user::data::type%]"
|
||||
},
|
||||
"data_description": {
|
||||
@@ -22,6 +23,7 @@
|
||||
"entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]",
|
||||
"start": "When to start the measure (timestamp or datetime). Can be a template.",
|
||||
"state": "[%key:component::history_stats::config::step::user::data_description::state%]",
|
||||
"state_class": "The state class for statistics calculation.",
|
||||
"type": "[%key:component::history_stats::config::step::user::data_description::type%]"
|
||||
},
|
||||
"description": "Read the documentation for further details on how to configure the history stats sensor using these options."
|
||||
@@ -68,6 +70,7 @@
|
||||
"entity_id": "[%key:component::history_stats::config::step::user::data::entity_id%]",
|
||||
"start": "[%key:component::history_stats::config::step::options::data::start%]",
|
||||
"state": "[%key:component::history_stats::config::step::user::data::state%]",
|
||||
"state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]",
|
||||
"type": "[%key:component::history_stats::config::step::user::data::type%]"
|
||||
},
|
||||
"data_description": {
|
||||
@@ -76,6 +79,7 @@
|
||||
"entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]",
|
||||
"start": "[%key:component::history_stats::config::step::options::data_description::start%]",
|
||||
"state": "[%key:component::history_stats::config::step::user::data_description::state%]",
|
||||
"state_class": "The state class for statistics calculation. Changing the state class will require statistics to be reset.",
|
||||
"type": "[%key:component::history_stats::config::step::user::data_description::type%]"
|
||||
},
|
||||
"description": "[%key:component::history_stats::config::step::options::description%]"
|
||||
@@ -83,6 +87,12 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"state_class": {
|
||||
"options": {
|
||||
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
|
||||
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"options": {
|
||||
"count": "Count",
|
||||
|
||||
@@ -119,7 +119,7 @@ class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
for item in data[API_TEMPERATURE][API_DATA]
|
||||
if item[API_PLACE] == self.location
|
||||
),
|
||||
0,
|
||||
None,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,18 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohomeconnect.client import Client as HomeConnectClient
|
||||
from aiohomeconnect.model import EventKey
|
||||
import aiohttp
|
||||
import jwt
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
@@ -23,7 +28,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .api import AsyncConfigEntryAuth
|
||||
from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP
|
||||
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
|
||||
from .coordinator import HomeConnectConfigEntry, HomeConnectRuntimeData
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -71,19 +76,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry)
|
||||
|
||||
home_connect_client = HomeConnectClient(config_entry_auth)
|
||||
|
||||
coordinator = HomeConnectCoordinator(hass, entry, home_connect_client)
|
||||
await coordinator.async_setup()
|
||||
entry.runtime_data = coordinator
|
||||
runtime_data = HomeConnectRuntimeData(hass, entry, home_connect_client)
|
||||
await runtime_data.setup_appliance_coordinators()
|
||||
entry.runtime_data = runtime_data
|
||||
|
||||
appliances_identifiers = {
|
||||
(entry.domain, ha_id) for ha_id in entry.runtime_data.appliance_coordinators
|
||||
}
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry_id=entry.entry_id
|
||||
)
|
||||
|
||||
for device in device_entries:
|
||||
if not device.identifiers.intersection(appliances_identifiers):
|
||||
device_registry.async_update_device(
|
||||
device.id, remove_config_entry_id=entry.entry_id
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
for listener, context in runtime_data.global_listeners.values():
|
||||
# We call the PAIRED event listener to start adding entities
|
||||
# from the appliances we already found above
|
||||
assert isinstance(context, tuple)
|
||||
if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context:
|
||||
listener()
|
||||
|
||||
entry.runtime_data.start_event_listener()
|
||||
|
||||
entry.async_create_background_task(
|
||||
hass,
|
||||
coordinator.async_refresh(),
|
||||
f"home_connect-initial-full-refresh-{entry.entry_id}",
|
||||
)
|
||||
for (
|
||||
appliance_id,
|
||||
appliance_coordinator,
|
||||
) in entry.runtime_data.appliance_coordinators.items():
|
||||
# We refresh each appliance coordinator in the background.
|
||||
# to ensure that setup time is not impacted by this refresh.
|
||||
entry.async_create_background_task(
|
||||
hass,
|
||||
appliance_coordinator.async_refresh(),
|
||||
f"home_connect-initial-full-refresh-{entry.entry_id}-{appliance_id}",
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -104,6 +136,9 @@ async def async_unload_entry(
|
||||
]
|
||||
for issue_id in issues_to_delete:
|
||||
issue_registry.async_delete(DOMAIN, issue_id)
|
||||
|
||||
for coordinator in entry.runtime_data.appliance_coordinators.values():
|
||||
await coordinator.async_shutdown()
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -145,19 +145,18 @@ CONNECTED_BINARY_ENTITY_DESCRIPTION = BinarySensorEntityDescription(
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
entities: list[HomeConnectEntity] = [
|
||||
HomeConnectConnectivityBinarySensor(
|
||||
entry.runtime_data, appliance, CONNECTED_BINARY_ENTITY_DESCRIPTION
|
||||
appliance_coordinator, CONNECTED_BINARY_ENTITY_DESCRIPTION
|
||||
)
|
||||
]
|
||||
entities.extend(
|
||||
HomeConnectBinarySensor(entry.runtime_data, appliance, description)
|
||||
HomeConnectBinarySensor(appliance_coordinator, description)
|
||||
for description in BINARY_SENSORS
|
||||
if description.key in appliance.status
|
||||
if description.key in appliance_coordinator.data.status
|
||||
)
|
||||
return entities
|
||||
|
||||
|
||||
@@ -10,11 +10,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
|
||||
from .coordinator import (
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
HomeConnectCoordinator,
|
||||
)
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
@@ -48,20 +44,18 @@ COMMAND_BUTTONS = (
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
entities: list[HomeConnectEntity] = []
|
||||
appliance_data = appliance_coordinator.data
|
||||
entities.extend(
|
||||
HomeConnectCommandButtonEntity(entry.runtime_data, appliance, description)
|
||||
HomeConnectCommandButtonEntity(appliance_coordinator, description)
|
||||
for description in COMMAND_BUTTONS
|
||||
if description.key in appliance.commands
|
||||
if description.key in appliance_data.commands
|
||||
)
|
||||
if appliance.info.type in APPLIANCES_WITH_PROGRAMS:
|
||||
entities.append(
|
||||
HomeConnectStopProgramButtonEntity(entry.runtime_data, appliance)
|
||||
)
|
||||
if appliance_data.info.type in APPLIANCES_WITH_PROGRAMS:
|
||||
entities.append(HomeConnectStopProgramButtonEntity(appliance_coordinator))
|
||||
|
||||
return entities
|
||||
|
||||
@@ -87,17 +81,11 @@ class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
desc: ButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
desc,
|
||||
(appliance.info.ha_id,),
|
||||
)
|
||||
super().__init__(appliance_coordinator, desc, context_override=True)
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the value of the entity."""
|
||||
@@ -130,15 +118,10 @@ class HomeConnectCommandButtonEntity(HomeConnectButtonEntity):
|
||||
class HomeConnectStopProgramButtonEntity(HomeConnectButtonEntity):
|
||||
"""Button entity for stopping a program."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
) -> None:
|
||||
def __init__(self, appliance_coordinator: HomeConnectApplianceCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
appliance_coordinator,
|
||||
ButtonEntityDescription(
|
||||
key="StopProgram",
|
||||
translation_key="stop_program",
|
||||
|
||||
@@ -14,7 +14,11 @@ from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .coordinator import (
|
||||
HomeConnectApplianceCoordinator,
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
)
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
|
||||
|
||||
@@ -40,11 +44,10 @@ def should_add_option_entity(
|
||||
|
||||
def _create_option_entities(
|
||||
entity_registry: er.EntityRegistry,
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
known_entity_unique_ids: dict[str, str],
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
|
||||
[HomeConnectApplianceCoordinator, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
@@ -53,13 +56,13 @@ def _create_option_entities(
|
||||
option_entities_to_add = [
|
||||
entity
|
||||
for entity in get_option_entities_for_appliance(
|
||||
entry, appliance, entity_registry
|
||||
appliance_coordinator, entity_registry
|
||||
)
|
||||
if entity.unique_id not in known_entity_unique_ids
|
||||
]
|
||||
known_entity_unique_ids.update(
|
||||
{
|
||||
cast(str, entity.unique_id): appliance.info.ha_id
|
||||
cast(str, entity.unique_id): appliance_coordinator.data.info.ha_id
|
||||
for entity in option_entities_to_add
|
||||
}
|
||||
)
|
||||
@@ -71,10 +74,10 @@ def _handle_paired_or_connected_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
known_entity_unique_ids: dict[str, str],
|
||||
get_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
|
||||
[HomeConnectApplianceCoordinator], list[HomeConnectEntity]
|
||||
],
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
|
||||
[HomeConnectApplianceCoordinator, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
]
|
||||
| None,
|
||||
@@ -90,17 +93,18 @@ def _handle_paired_or_connected_appliance(
|
||||
"""
|
||||
entities: list[HomeConnectEntity] = []
|
||||
entity_registry = er.async_get(hass)
|
||||
for appliance in entry.runtime_data.data.values():
|
||||
for appliance_coordinator in entry.runtime_data.appliance_coordinators.values():
|
||||
appliance_ha_id = appliance_coordinator.data.info.ha_id
|
||||
entities_to_add = [
|
||||
entity
|
||||
for entity in get_entities_for_appliance(entry, appliance)
|
||||
for entity in get_entities_for_appliance(appliance_coordinator)
|
||||
if entity.unique_id not in known_entity_unique_ids
|
||||
]
|
||||
if get_option_entities_for_appliance:
|
||||
entities_to_add.extend(
|
||||
entity
|
||||
for entity in get_option_entities_for_appliance(
|
||||
entry, appliance, entity_registry
|
||||
appliance_coordinator, entity_registry
|
||||
)
|
||||
if entity.unique_id not in known_entity_unique_ids
|
||||
)
|
||||
@@ -109,28 +113,24 @@ def _handle_paired_or_connected_appliance(
|
||||
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
):
|
||||
changed_options_listener_remove_callback = (
|
||||
entry.runtime_data.async_add_listener(
|
||||
appliance_coordinator.async_add_listener(
|
||||
partial(
|
||||
_create_option_entities,
|
||||
entity_registry,
|
||||
entry,
|
||||
appliance,
|
||||
appliance_coordinator,
|
||||
known_entity_unique_ids,
|
||||
get_option_entities_for_appliance,
|
||||
async_add_entities,
|
||||
),
|
||||
(appliance.info.ha_id, event_key),
|
||||
event_key,
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(changed_options_listener_remove_callback)
|
||||
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
|
||||
changed_options_listener_remove_callbacks[appliance_ha_id].append(
|
||||
changed_options_listener_remove_callback
|
||||
)
|
||||
known_entity_unique_ids.update(
|
||||
{
|
||||
cast(str, entity.unique_id): appliance.info.ha_id
|
||||
for entity in entities_to_add
|
||||
}
|
||||
{cast(str, entity.unique_id): appliance_ha_id for entity in entities_to_add}
|
||||
)
|
||||
entities.extend(entities_to_add)
|
||||
async_add_entities(entities)
|
||||
@@ -143,7 +143,7 @@ def _handle_depaired_appliance(
|
||||
) -> None:
|
||||
"""Handle a removed appliance."""
|
||||
for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items():
|
||||
if appliance_id not in entry.runtime_data.data:
|
||||
if appliance_id not in entry.runtime_data.appliance_coordinators:
|
||||
known_entity_unique_ids.pop(entity_unique_id, None)
|
||||
if appliance_id in changed_options_listener_remove_callbacks:
|
||||
for listener in changed_options_listener_remove_callbacks.pop(
|
||||
@@ -156,11 +156,11 @@ def setup_home_connect_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
get_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
|
||||
[HomeConnectApplianceCoordinator], list[HomeConnectEntity]
|
||||
],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
|
||||
[HomeConnectApplianceCoordinator, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
]
|
||||
| None = None,
|
||||
@@ -172,7 +172,7 @@ def setup_home_connect_entry(
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
entry.runtime_data.async_add_special_listener(
|
||||
entry.runtime_data.async_add_global_listener(
|
||||
partial(
|
||||
_handle_paired_or_connected_appliance,
|
||||
hass,
|
||||
@@ -190,7 +190,7 @@ def setup_home_connect_entry(
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(
|
||||
entry.runtime_data.async_add_special_listener(
|
||||
entry.runtime_data.async_add_global_listener(
|
||||
partial(
|
||||
_handle_depaired_appliance,
|
||||
entry,
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import sleep as asyncio_sleep
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohomeconnect.client import Client as HomeConnectClient
|
||||
from aiohomeconnect.model import (
|
||||
@@ -33,7 +31,6 @@ from aiohomeconnect.model.error import (
|
||||
UnauthorizedError,
|
||||
)
|
||||
from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption
|
||||
from propcache.api import cached_property
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
@@ -54,7 +51,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
MAX_EXECUTIONS_TIME_WINDOW = 60 * 60 # 1 hour
|
||||
MAX_EXECUTIONS = 8
|
||||
|
||||
type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator]
|
||||
type HomeConnectConfigEntry = ConfigEntry[HomeConnectRuntimeData]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -96,12 +93,14 @@ class HomeConnectApplianceData:
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectCoordinator(
|
||||
DataUpdateCoordinator[dict[str, HomeConnectApplianceData]]
|
||||
):
|
||||
"""Class to manage fetching Home Connect data."""
|
||||
class HomeConnectRuntimeData:
|
||||
"""Class to manage Home Connect's integration runtime data.
|
||||
|
||||
It also handles the API server-sent events.
|
||||
"""
|
||||
|
||||
config_entry: HomeConnectConfigEntry
|
||||
appliance_coordinators: dict[str, HomeConnectApplianceCoordinator]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -110,64 +109,14 @@ class HomeConnectCoordinator(
|
||||
client: HomeConnectClient,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=config_entry.entry_id,
|
||||
)
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
self.client = client
|
||||
self._special_listeners: dict[
|
||||
self.global_listeners: dict[
|
||||
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
|
||||
] = {}
|
||||
self.device_registry = dr.async_get(self.hass)
|
||||
self.data = {}
|
||||
self._execution_tracker: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
@cached_property
|
||||
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
|
||||
"""Return a dict of all listeners registered for a given context."""
|
||||
listeners: dict[tuple[str, EventKey], list[CALLBACK_TYPE]] = defaultdict(list)
|
||||
for listener, context in list(self._listeners.values()):
|
||||
assert isinstance(context, tuple)
|
||||
listeners[context].append(listener)
|
||||
return listeners
|
||||
|
||||
@callback
|
||||
def async_add_listener(
|
||||
self, update_callback: CALLBACK_TYPE, context: Any = None
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for data updates."""
|
||||
remove_listener = super().async_add_listener(update_callback, context)
|
||||
self.__dict__.pop("context_listeners", None)
|
||||
|
||||
def remove_listener_and_invalidate_context_listeners() -> None:
|
||||
remove_listener()
|
||||
self.__dict__.pop("context_listeners", None)
|
||||
|
||||
return remove_listener_and_invalidate_context_listeners
|
||||
|
||||
@callback
|
||||
def async_add_special_listener(
|
||||
self,
|
||||
update_callback: CALLBACK_TYPE,
|
||||
context: tuple[EventKey, ...],
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for special data updates.
|
||||
|
||||
These listeners will not be called on refresh.
|
||||
"""
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove update listener."""
|
||||
self._special_listeners.pop(remove_listener)
|
||||
if not self._special_listeners:
|
||||
self._unschedule_refresh()
|
||||
|
||||
self._special_listeners[remove_listener] = (update_callback, context)
|
||||
|
||||
return remove_listener
|
||||
self.appliance_coordinators = {}
|
||||
|
||||
@callback
|
||||
def start_event_listener(self) -> None:
|
||||
@@ -178,7 +127,7 @@ class HomeConnectCoordinator(
|
||||
f"home_connect-events_listener_task-{self.config_entry.entry_id}",
|
||||
)
|
||||
|
||||
async def _event_listener(self) -> None: # noqa: C901
|
||||
async def _event_listener(self) -> None:
|
||||
"""Match event with listener for event type."""
|
||||
retry_time = 10
|
||||
while True:
|
||||
@@ -186,129 +135,37 @@ class HomeConnectCoordinator(
|
||||
async for event_message in self.client.stream_all_events():
|
||||
retry_time = 10
|
||||
event_message_ha_id = event_message.ha_id
|
||||
if (
|
||||
event_message_ha_id in self.data
|
||||
and not self.data[event_message_ha_id].info.connected
|
||||
):
|
||||
self.data[event_message_ha_id].info.connected = True
|
||||
self._call_all_event_listeners_for_appliance(
|
||||
event_message_ha_id
|
||||
if event_message_ha_id in self.appliance_coordinators:
|
||||
if event_message.type == EventType.DEPAIRED:
|
||||
appliance_coordinator = self.appliance_coordinators.pop(
|
||||
event_message.ha_id
|
||||
)
|
||||
await appliance_coordinator.async_shutdown()
|
||||
else:
|
||||
appliance_coordinator = self.appliance_coordinators[
|
||||
event_message.ha_id
|
||||
]
|
||||
if not appliance_coordinator.data.info.connected:
|
||||
appliance_coordinator.data.info.connected = True
|
||||
appliance_coordinator.call_all_event_listeners()
|
||||
|
||||
elif event_message.type == EventType.PAIRED:
|
||||
appliance_coordinator = HomeConnectApplianceCoordinator(
|
||||
self.hass,
|
||||
self.config_entry,
|
||||
self.client,
|
||||
self.global_listeners,
|
||||
await self.client.get_specific_appliance(
|
||||
event_message_ha_id
|
||||
),
|
||||
)
|
||||
await appliance_coordinator.async_register_shutdown()
|
||||
self.appliance_coordinators[event_message.ha_id] = (
|
||||
appliance_coordinator
|
||||
)
|
||||
match event_message.type:
|
||||
case EventType.STATUS:
|
||||
statuses = self.data[event_message_ha_id].status
|
||||
for event in event_message.data.items:
|
||||
status_key = StatusKey(event.key)
|
||||
if status_key in statuses:
|
||||
statuses[status_key].value = event.value
|
||||
else:
|
||||
statuses[status_key] = Status(
|
||||
key=status_key,
|
||||
raw_key=status_key.value,
|
||||
value=event.value,
|
||||
)
|
||||
if (
|
||||
status_key == StatusKey.BSH_COMMON_OPERATION_STATE
|
||||
and event.value == BSH_OPERATION_STATE_PAUSE
|
||||
and CommandKey.BSH_COMMON_RESUME_PROGRAM
|
||||
not in (
|
||||
commands := self.data[
|
||||
event_message_ha_id
|
||||
].commands
|
||||
)
|
||||
):
|
||||
# All the appliances that can be paused
|
||||
# should have the resume command available.
|
||||
commands.add(CommandKey.BSH_COMMON_RESUME_PROGRAM)
|
||||
for (
|
||||
listener,
|
||||
context,
|
||||
) in self._special_listeners.values():
|
||||
if (
|
||||
EventKey.BSH_COMMON_APPLIANCE_DEPAIRED
|
||||
not in context
|
||||
):
|
||||
listener()
|
||||
self._call_event_listener(event_message)
|
||||
|
||||
case EventType.NOTIFY:
|
||||
settings = self.data[event_message_ha_id].settings
|
||||
events = self.data[event_message_ha_id].events
|
||||
for event in event_message.data.items:
|
||||
event_key = event.key
|
||||
if event_key in SettingKey.__members__.values(): # type: ignore[comparison-overlap]
|
||||
setting_key = SettingKey(event_key)
|
||||
if setting_key in settings:
|
||||
settings[setting_key].value = event.value
|
||||
else:
|
||||
settings[setting_key] = GetSetting(
|
||||
key=setting_key,
|
||||
raw_key=setting_key.value,
|
||||
value=event.value,
|
||||
)
|
||||
else:
|
||||
event_value = event.value
|
||||
if event_key in (
|
||||
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
) and isinstance(event_value, str):
|
||||
await self.update_options(
|
||||
event_message_ha_id,
|
||||
event_key,
|
||||
ProgramKey(event_value),
|
||||
)
|
||||
events[event_key] = event
|
||||
self._call_event_listener(event_message)
|
||||
|
||||
case EventType.EVENT:
|
||||
events = self.data[event_message_ha_id].events
|
||||
for event in event_message.data.items:
|
||||
events[event.key] = event
|
||||
self._call_event_listener(event_message)
|
||||
|
||||
case EventType.CONNECTED | EventType.PAIRED:
|
||||
if self.refreshed_too_often_recently(event_message_ha_id):
|
||||
continue
|
||||
|
||||
appliance_info = await self.client.get_specific_appliance(
|
||||
event_message_ha_id
|
||||
)
|
||||
|
||||
appliance_data = await self._get_appliance_data(
|
||||
appliance_info, self.data.get(appliance_info.ha_id)
|
||||
)
|
||||
if event_message_ha_id not in self.data:
|
||||
self.data[event_message_ha_id] = appliance_data
|
||||
for listener, context in self._special_listeners.values():
|
||||
if (
|
||||
EventKey.BSH_COMMON_APPLIANCE_DEPAIRED
|
||||
not in context
|
||||
):
|
||||
listener()
|
||||
self._call_all_event_listeners_for_appliance(
|
||||
event_message_ha_id
|
||||
)
|
||||
|
||||
case EventType.DISCONNECTED:
|
||||
self.data[event_message_ha_id].info.connected = False
|
||||
self._call_all_event_listeners_for_appliance(
|
||||
event_message_ha_id
|
||||
)
|
||||
|
||||
case EventType.DEPAIRED:
|
||||
device = self.device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, event_message_ha_id)}
|
||||
)
|
||||
if device:
|
||||
self.device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
self.data.pop(event_message_ha_id, None)
|
||||
for listener, context in self._special_listeners.values():
|
||||
assert isinstance(context, tuple)
|
||||
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
|
||||
listener()
|
||||
assert appliance_coordinator
|
||||
await appliance_coordinator.event_listener(event_message)
|
||||
|
||||
except (EventStreamInterruptedError, HomeConnectRequestError) as error:
|
||||
_LOGGER.debug(
|
||||
@@ -327,58 +184,27 @@ class HomeConnectCoordinator(
|
||||
break
|
||||
|
||||
@callback
|
||||
def _call_event_listener(self, event_message: EventMessage) -> None:
|
||||
"""Call listener for event."""
|
||||
for event in event_message.data.items:
|
||||
for listener in self.context_listeners.get(
|
||||
(event_message.ha_id, event.key), []
|
||||
):
|
||||
listener()
|
||||
def async_add_global_listener(
|
||||
self,
|
||||
update_callback: CALLBACK_TYPE,
|
||||
context: tuple[EventKey, ...],
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for special data updates.
|
||||
|
||||
@callback
|
||||
def _call_all_event_listeners_for_appliance(self, ha_id: str) -> None:
|
||||
for listener, context in self._listeners.values():
|
||||
if isinstance(context, tuple) and context[0] == ha_id:
|
||||
listener()
|
||||
These listeners will not be called on refresh.
|
||||
"""
|
||||
|
||||
async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]:
|
||||
"""Fetch data from Home Connect."""
|
||||
await self._async_setup()
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove update listener."""
|
||||
self.global_listeners.pop(remove_listener)
|
||||
|
||||
for appliance_data in self.data.values():
|
||||
appliance = appliance_data.info
|
||||
ha_id = appliance.ha_id
|
||||
while True:
|
||||
try:
|
||||
self.data[ha_id] = await self._get_appliance_data(
|
||||
appliance, self.data.get(ha_id)
|
||||
)
|
||||
except TooManyRequestsError as err:
|
||||
_LOGGER.debug(
|
||||
"Rate limit exceeded on initial fetch: %s",
|
||||
err,
|
||||
)
|
||||
await asyncio_sleep(err.retry_after or API_DEFAULT_RETRY_AFTER)
|
||||
else:
|
||||
break
|
||||
self.global_listeners[remove_listener] = (update_callback, context)
|
||||
|
||||
for listener, context in self._special_listeners.values():
|
||||
assert isinstance(context, tuple)
|
||||
if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context:
|
||||
listener()
|
||||
return remove_listener
|
||||
|
||||
return self.data
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the devices."""
|
||||
try:
|
||||
await self._async_setup()
|
||||
except UpdateFailed as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the devices."""
|
||||
old_appliances = set(self.data.keys())
|
||||
async def setup_appliance_coordinators(self) -> None:
|
||||
"""Set up the coordinators for each appliance."""
|
||||
try:
|
||||
appliances = await self.client.get_home_appliances()
|
||||
except UnauthorizedError as error:
|
||||
@@ -388,9 +214,7 @@ class HomeConnectCoordinator(
|
||||
translation_placeholders=get_dict_from_home_connect_error(error),
|
||||
) from error
|
||||
except HomeConnectError as error:
|
||||
for appliance_data in self.data.values():
|
||||
appliance_data.info.connected = False
|
||||
raise UpdateFailed(
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_api_error",
|
||||
translation_placeholders=get_dict_from_home_connect_error(error),
|
||||
@@ -404,52 +228,237 @@ class HomeConnectCoordinator(
|
||||
name=appliance.name,
|
||||
model=appliance.vib,
|
||||
)
|
||||
if appliance.ha_id not in self.data:
|
||||
self.data[appliance.ha_id] = HomeConnectApplianceData.empty(appliance)
|
||||
else:
|
||||
self.data[appliance.ha_id].info.connected = appliance.connected
|
||||
old_appliances.remove(appliance.ha_id)
|
||||
|
||||
for ha_id in old_appliances:
|
||||
self.data.pop(ha_id, None)
|
||||
device = self.device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, ha_id)}
|
||||
new_coordinator = HomeConnectApplianceCoordinator(
|
||||
self.hass,
|
||||
self.config_entry,
|
||||
self.client,
|
||||
self.global_listeners,
|
||||
appliance,
|
||||
)
|
||||
if device:
|
||||
self.device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
await new_coordinator.async_register_shutdown()
|
||||
self.appliance_coordinators[appliance.ha_id] = new_coordinator
|
||||
|
||||
# Trigger to delete the possible depaired device entities
|
||||
# from known_entities variable at common.py
|
||||
for listener, context in self._special_listeners.values():
|
||||
assert isinstance(context, tuple)
|
||||
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
|
||||
|
||||
class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectApplianceData]):
|
||||
"""Class to manage fetching Home Connect appliance data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomeConnectConfigEntry,
|
||||
client: HomeConnectClient,
|
||||
global_listeners: dict[
|
||||
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
|
||||
],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
# Don't set config_entry attribute to avoid default behavior.
|
||||
# HomeConnectApplianceCoordinator doesn't follow the
|
||||
# config entry lifecycle so we can't use the default behavior.
|
||||
self._config_entry = config_entry
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=None,
|
||||
name=f"{self._config_entry.entry_id}-{appliance.ha_id}",
|
||||
)
|
||||
self.client = client
|
||||
self.device_registry = dr.async_get(self.hass)
|
||||
self.global_listeners = global_listeners
|
||||
self.data = HomeConnectApplianceData.empty(appliance)
|
||||
self._execution_tracker: list[float] = []
|
||||
|
||||
def _get_listeners_for_event_key(self, event_key: EventKey) -> list[CALLBACK_TYPE]:
|
||||
return [
|
||||
listener
|
||||
for listener, context in list(self._listeners.values())
|
||||
if context == event_key
|
||||
]
|
||||
|
||||
async def event_listener(self, event_message: EventMessage) -> None:
|
||||
"""Match event with listener for event type."""
|
||||
|
||||
match event_message.type:
|
||||
case EventType.STATUS:
|
||||
statuses = self.data.status
|
||||
for event in event_message.data.items:
|
||||
status_key = StatusKey(event.key)
|
||||
if status_key in statuses:
|
||||
statuses[status_key].value = event.value
|
||||
else:
|
||||
statuses[status_key] = Status(
|
||||
key=status_key,
|
||||
raw_key=status_key.value,
|
||||
value=event.value,
|
||||
)
|
||||
if (
|
||||
status_key == StatusKey.BSH_COMMON_OPERATION_STATE
|
||||
and event.value == BSH_OPERATION_STATE_PAUSE
|
||||
and CommandKey.BSH_COMMON_RESUME_PROGRAM
|
||||
not in (commands := self.data.commands)
|
||||
):
|
||||
# All the appliances that can be paused
|
||||
# should have the resume command available.
|
||||
commands.add(CommandKey.BSH_COMMON_RESUME_PROGRAM)
|
||||
for (
|
||||
listener,
|
||||
context,
|
||||
) in self.global_listeners.values():
|
||||
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context:
|
||||
listener()
|
||||
self._call_event_listener(event_message)
|
||||
|
||||
case EventType.NOTIFY:
|
||||
settings = self.data.settings
|
||||
events = self.data.events
|
||||
for event in event_message.data.items:
|
||||
event_key = event.key
|
||||
if event_key in SettingKey.__members__.values(): # type: ignore[comparison-overlap]
|
||||
setting_key = SettingKey(event_key)
|
||||
if setting_key in settings:
|
||||
settings[setting_key].value = event.value
|
||||
else:
|
||||
settings[setting_key] = GetSetting(
|
||||
key=setting_key,
|
||||
raw_key=setting_key.value,
|
||||
value=event.value,
|
||||
)
|
||||
else:
|
||||
event_value = event.value
|
||||
if event_key in (
|
||||
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
) and isinstance(event_value, str):
|
||||
await self.update_options(
|
||||
event_key,
|
||||
ProgramKey(event_value),
|
||||
)
|
||||
events[event_key] = event
|
||||
self._call_event_listener(event_message)
|
||||
|
||||
case EventType.EVENT:
|
||||
events = self.data.events
|
||||
for event in event_message.data.items:
|
||||
events[event.key] = event
|
||||
self._call_event_listener(event_message)
|
||||
|
||||
case EventType.CONNECTED | EventType.PAIRED:
|
||||
if self.refreshed_too_often_recently():
|
||||
return
|
||||
|
||||
await self.async_refresh()
|
||||
for (
|
||||
listener,
|
||||
context,
|
||||
) in self.global_listeners.values():
|
||||
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context:
|
||||
listener()
|
||||
self.call_all_event_listeners()
|
||||
|
||||
case EventType.DISCONNECTED:
|
||||
self.data.info.connected = False
|
||||
self.call_all_event_listeners()
|
||||
|
||||
case EventType.DEPAIRED:
|
||||
device = self.device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, self.data.info.ha_id)}
|
||||
)
|
||||
if device:
|
||||
self.device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self._config_entry.entry_id,
|
||||
)
|
||||
for (
|
||||
listener,
|
||||
context,
|
||||
) in self.global_listeners.values():
|
||||
assert isinstance(context, tuple)
|
||||
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
|
||||
listener()
|
||||
|
||||
@callback
|
||||
def _call_event_listener(self, event_message: EventMessage) -> None:
|
||||
"""Call listener for event."""
|
||||
for event in event_message.data.items:
|
||||
for listener in self._get_listeners_for_event_key(event.key):
|
||||
listener()
|
||||
|
||||
async def _get_appliance_data(
|
||||
self,
|
||||
appliance: HomeAppliance,
|
||||
appliance_data_to_update: HomeConnectApplianceData | None = None,
|
||||
) -> HomeConnectApplianceData:
|
||||
@callback
|
||||
def call_all_event_listeners(self) -> None:
|
||||
"""Call all listeners."""
|
||||
for listener, _ in self._listeners.values():
|
||||
listener()
|
||||
|
||||
async def _async_update_data(self) -> HomeConnectApplianceData:
|
||||
"""Fetch data from Home Connect."""
|
||||
while True:
|
||||
try:
|
||||
try:
|
||||
self.data.info.connected = (
|
||||
await self.client.get_specific_appliance(self.data.info.ha_id)
|
||||
).connected
|
||||
except HomeConnectError:
|
||||
self.data.info.connected = False
|
||||
raise
|
||||
|
||||
await self.get_appliance_data()
|
||||
except TooManyRequestsError as err:
|
||||
delay = err.retry_after or API_DEFAULT_RETRY_AFTER
|
||||
_LOGGER.warning(
|
||||
"Rate limit exceeded, retrying in %s seconds: %s",
|
||||
delay,
|
||||
err,
|
||||
)
|
||||
await asyncio_sleep(delay)
|
||||
except UnauthorizedError as error:
|
||||
# Reauth flow need to be started explicitly as
|
||||
# we don't use the default config entry coordinator.
|
||||
self._config_entry.async_start_reauth(self.hass)
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_error",
|
||||
translation_placeholders=get_dict_from_home_connect_error(error),
|
||||
) from error
|
||||
except HomeConnectError as error:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_api_error",
|
||||
translation_placeholders=get_dict_from_home_connect_error(error),
|
||||
) from error
|
||||
else:
|
||||
break
|
||||
|
||||
for (
|
||||
listener,
|
||||
context,
|
||||
) in self.global_listeners.values():
|
||||
assert isinstance(context, tuple)
|
||||
if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context:
|
||||
listener()
|
||||
|
||||
return self.data
|
||||
|
||||
async def get_appliance_data(self) -> None:
|
||||
"""Get appliance data."""
|
||||
appliance = self.data.info
|
||||
self.device_registry.async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
config_entry_id=self._config_entry.entry_id,
|
||||
identifiers={(DOMAIN, appliance.ha_id)},
|
||||
manufacturer=appliance.brand,
|
||||
name=appliance.name,
|
||||
model=appliance.vib,
|
||||
)
|
||||
if not appliance.connected:
|
||||
_LOGGER.debug(
|
||||
"Appliance %s is not connected, skipping data fetch",
|
||||
appliance.ha_id,
|
||||
self.data.update(HomeConnectApplianceData.empty(appliance))
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="appliance_disconnected",
|
||||
translation_placeholders={
|
||||
"appliance_name": appliance.name,
|
||||
"ha_id": appliance.ha_id,
|
||||
},
|
||||
)
|
||||
if appliance_data_to_update:
|
||||
appliance_data_to_update.info.connected = False
|
||||
return appliance_data_to_update
|
||||
return HomeConnectApplianceData.empty(appliance)
|
||||
try:
|
||||
settings = {
|
||||
setting.key: setting
|
||||
@@ -521,9 +530,7 @@ class HomeConnectCoordinator(
|
||||
current_program_key = program.key
|
||||
program_options = program.options
|
||||
if current_program_key:
|
||||
options = await self.get_options_definitions(
|
||||
appliance.ha_id, current_program_key
|
||||
)
|
||||
options = await self.get_options_definitions(current_program_key)
|
||||
for option in program_options or []:
|
||||
option_event_key = EventKey(option.key)
|
||||
events[option_event_key] = Event(
|
||||
@@ -550,23 +557,20 @@ class HomeConnectCoordinator(
|
||||
except HomeConnectError:
|
||||
commands = set()
|
||||
|
||||
appliance_data = HomeConnectApplianceData(
|
||||
commands=commands,
|
||||
events=events,
|
||||
info=appliance,
|
||||
options=options,
|
||||
programs=programs,
|
||||
settings=settings,
|
||||
status=status,
|
||||
self.data.update(
|
||||
HomeConnectApplianceData(
|
||||
commands=commands,
|
||||
events=events,
|
||||
info=appliance,
|
||||
options=options,
|
||||
programs=programs,
|
||||
settings=settings,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
if appliance_data_to_update:
|
||||
appliance_data_to_update.update(appliance_data)
|
||||
appliance_data = appliance_data_to_update
|
||||
|
||||
return appliance_data
|
||||
|
||||
async def get_options_definitions(
|
||||
self, ha_id: str, program_key: ProgramKey
|
||||
self, program_key: ProgramKey
|
||||
) -> dict[OptionKey, ProgramDefinitionOption]:
|
||||
"""Get options with constraints for appliance."""
|
||||
if program_key is ProgramKey.UNKNOWN:
|
||||
@@ -576,7 +580,7 @@ class HomeConnectCoordinator(
|
||||
option.key: option
|
||||
for option in (
|
||||
await self.client.get_available_program(
|
||||
ha_id, program_key=program_key
|
||||
self.data.info.ha_id, program_key=program_key
|
||||
)
|
||||
).options
|
||||
or []
|
||||
@@ -586,20 +590,20 @@ class HomeConnectCoordinator(
|
||||
except HomeConnectError as error:
|
||||
_LOGGER.debug(
|
||||
"Error fetching options for %s: %s",
|
||||
ha_id,
|
||||
self.data.info.ha_id,
|
||||
error,
|
||||
)
|
||||
return {}
|
||||
|
||||
async def update_options(
|
||||
self, ha_id: str, event_key: EventKey, program_key: ProgramKey
|
||||
self, event_key: EventKey, program_key: ProgramKey
|
||||
) -> None:
|
||||
"""Update options for appliance."""
|
||||
options = self.data[ha_id].options
|
||||
events = self.data[ha_id].events
|
||||
options = self.data.options
|
||||
events = self.data.events
|
||||
options_to_notify = options.copy()
|
||||
options.clear()
|
||||
options.update(await self.get_options_definitions(ha_id, program_key))
|
||||
options.update(await self.get_options_definitions(program_key))
|
||||
|
||||
for option in options.values():
|
||||
option_value = option.constraints.default if option.constraints else None
|
||||
@@ -617,21 +621,18 @@ class HomeConnectCoordinator(
|
||||
)
|
||||
options_to_notify.update(options)
|
||||
for option_key in options_to_notify:
|
||||
for listener in self.context_listeners.get(
|
||||
(ha_id, EventKey(option_key)),
|
||||
[],
|
||||
):
|
||||
for listener in self._get_listeners_for_event_key(EventKey(option_key)):
|
||||
listener()
|
||||
|
||||
def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool:
|
||||
def refreshed_too_often_recently(self) -> bool:
|
||||
"""Check if the appliance data hasn't been refreshed too often recently."""
|
||||
|
||||
now = self.hass.loop.time()
|
||||
|
||||
execution_tracker = self._execution_tracker[appliance_ha_id]
|
||||
execution_tracker = self._execution_tracker
|
||||
initial_len = len(execution_tracker)
|
||||
|
||||
execution_tracker = self._execution_tracker[appliance_ha_id] = [
|
||||
execution_tracker = self._execution_tracker = [
|
||||
timestamp
|
||||
for timestamp in execution_tracker
|
||||
if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW
|
||||
@@ -647,7 +648,7 @@ class HomeConnectCoordinator(
|
||||
"and they will be enabled again whenever the connection stabilizes. "
|
||||
"Consider trying to unplug the appliance "
|
||||
"for a while to perform a soft reset",
|
||||
self.data[appliance_ha_id].info.name,
|
||||
self.data.info.name,
|
||||
MAX_EXECUTIONS,
|
||||
MAX_EXECUTIONS_TIME_WINDOW // 60,
|
||||
)
|
||||
@@ -656,7 +657,7 @@ class HomeConnectCoordinator(
|
||||
_LOGGER.info(
|
||||
'Connected/paired events from the appliance "%s" have stabilized,'
|
||||
" updates have been re-enabled",
|
||||
self.data[appliance_ha_id].info.name,
|
||||
self.data.info.name,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
@@ -47,8 +47,10 @@ async def async_get_config_entry_diagnostics(
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {
|
||||
appliance.info.ha_id: await _generate_appliance_diagnostics(appliance)
|
||||
for appliance in entry.runtime_data.data.values()
|
||||
appliance_coordinator.data.info.ha_id: await _generate_appliance_diagnostics(
|
||||
appliance_coordinator.data
|
||||
)
|
||||
for appliance_coordinator in entry.runtime_data.appliance_coordinators.values()
|
||||
}
|
||||
|
||||
|
||||
@@ -59,4 +61,6 @@ async def async_get_device_diagnostics(
|
||||
ha_id = next(
|
||||
(identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN),
|
||||
)
|
||||
return await _generate_appliance_diagnostics(entry.runtime_data.data[ha_id])
|
||||
return await _generate_appliance_diagnostics(
|
||||
entry.runtime_data.appliance_coordinators[ha_id].data
|
||||
)
|
||||
|
||||
@@ -22,34 +22,34 @@ from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import API_DEFAULT_RETRY_AFTER, DOMAIN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator
|
||||
from .coordinator import HomeConnectApplianceCoordinator
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
|
||||
class HomeConnectEntity(CoordinatorEntity[HomeConnectApplianceCoordinator]):
|
||||
"""Generic Home Connect entity (base class)."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
desc: EntityDescription,
|
||||
context_override: Any | None = None,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
context = (appliance.info.ha_id, EventKey(desc.key))
|
||||
appliance_ha_id = appliance_coordinator.data.info.ha_id
|
||||
context = EventKey(desc.key)
|
||||
if context_override is not None:
|
||||
context = context_override
|
||||
super().__init__(coordinator, context)
|
||||
self.appliance = appliance
|
||||
super().__init__(appliance_coordinator, context)
|
||||
self.appliance = appliance_coordinator.data
|
||||
self.entity_description = desc
|
||||
self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}"
|
||||
self._attr_unique_id = f"{appliance_ha_id}-{desc.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, appliance.info.ha_id)},
|
||||
identifiers={(DOMAIN, appliance_ha_id)},
|
||||
)
|
||||
self.update_native_value()
|
||||
|
||||
|
||||
@@ -22,11 +22,7 @@ from homeassistant.util import color as color_util
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, DOMAIN
|
||||
from .coordinator import (
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
HomeConnectCoordinator,
|
||||
)
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
@@ -78,14 +74,13 @@ LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = (
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return [
|
||||
HomeConnectLight(entry.runtime_data, appliance, description)
|
||||
HomeConnectLight(appliance_coordinator, description)
|
||||
for description in LIGHTS
|
||||
if description.key in appliance.settings
|
||||
if description.key in appliance_coordinator.data.settings
|
||||
]
|
||||
|
||||
|
||||
@@ -110,8 +105,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
desc: HomeConnectLightEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
@@ -119,7 +113,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
def get_setting_key_if_setting_exists(
|
||||
setting_key: SettingKey | None,
|
||||
) -> SettingKey | None:
|
||||
if setting_key and setting_key in appliance.settings:
|
||||
if setting_key and setting_key in appliance_coordinator.data.settings:
|
||||
return setting_key
|
||||
return None
|
||||
|
||||
@@ -134,7 +128,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
)
|
||||
self._brightness_scale = desc.brightness_scale
|
||||
|
||||
super().__init__(coordinator, appliance, desc)
|
||||
super().__init__(appliance_coordinator, desc)
|
||||
|
||||
match (self._brightness_key, self._custom_color_key):
|
||||
case (None, None):
|
||||
@@ -287,10 +281,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update,
|
||||
(
|
||||
self.appliance.info.ha_id,
|
||||
EventKey(key),
|
||||
),
|
||||
EventKey(key),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry, should_add_option_entity
|
||||
from .const import DOMAIN, UNIT_MAP
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
@@ -123,28 +123,26 @@ NUMBER_OPTIONS = (
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return [
|
||||
HomeConnectNumberEntity(entry.runtime_data, appliance, description)
|
||||
HomeConnectNumberEntity(appliance_coordinator, description)
|
||||
for description in NUMBERS
|
||||
if description.key in appliance.settings
|
||||
if description.key in appliance_coordinator.data.settings
|
||||
]
|
||||
|
||||
|
||||
def _get_option_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of currently available option entities."""
|
||||
return [
|
||||
HomeConnectOptionNumberEntity(entry.runtime_data, appliance, description)
|
||||
HomeConnectOptionNumberEntity(appliance_coordinator, description)
|
||||
for description in NUMBER_OPTIONS
|
||||
if should_add_option_entity(
|
||||
description, appliance, entity_registry, Platform.NUMBER
|
||||
description, appliance_coordinator.data, entity_registry, Platform.NUMBER
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -41,11 +41,7 @@ from .const import (
|
||||
VENTING_LEVEL_OPTIONS,
|
||||
WARMING_LEVEL_OPTIONS,
|
||||
)
|
||||
from .coordinator import (
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
HomeConnectCoordinator,
|
||||
)
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
|
||||
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
|
||||
|
||||
@@ -336,37 +332,37 @@ PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return [
|
||||
*(
|
||||
[
|
||||
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
|
||||
HomeConnectProgramSelectEntity(appliance_coordinator, desc)
|
||||
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
|
||||
]
|
||||
if appliance.programs
|
||||
if appliance_coordinator.data.programs
|
||||
else []
|
||||
),
|
||||
*[
|
||||
HomeConnectSelectEntity(entry.runtime_data, appliance, desc)
|
||||
HomeConnectSelectEntity(appliance_coordinator, desc)
|
||||
for desc in SELECT_ENTITY_DESCRIPTIONS
|
||||
if desc.key in appliance.settings
|
||||
if desc.key in appliance_coordinator.data.settings
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def _get_option_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of entities."""
|
||||
return [
|
||||
HomeConnectSelectOptionEntity(entry.runtime_data, appliance, desc)
|
||||
HomeConnectSelectOptionEntity(appliance_coordinator, desc)
|
||||
for desc in PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS
|
||||
if should_add_option_entity(desc, appliance, entity_registry, Platform.SELECT)
|
||||
if should_add_option_entity(
|
||||
desc, appliance_coordinator.data, entity_registry, Platform.SELECT
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -392,14 +388,12 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
desc: HomeConnectProgramSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
appliance_coordinator,
|
||||
desc,
|
||||
)
|
||||
self.set_options()
|
||||
@@ -429,7 +423,7 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self.refresh_options,
|
||||
(self.appliance.info.ha_id, EventKey.BSH_COMMON_APPLIANCE_CONNECTED),
|
||||
EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -470,15 +464,13 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
desc: HomeConnectSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._original_option_keys = set(desc.values_translation_key)
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
appliance_coordinator,
|
||||
desc,
|
||||
)
|
||||
|
||||
@@ -547,15 +539,13 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
desc: HomeConnectSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._original_option_keys = set(desc.values_translation_key)
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
appliance_coordinator,
|
||||
desc,
|
||||
)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from .const import (
|
||||
BSH_OPERATION_STATE_RUN,
|
||||
UNIT_MAP,
|
||||
)
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity, constraint_fetcher
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -508,26 +508,26 @@ EVENT_SENSORS = (
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return [
|
||||
*[
|
||||
HomeConnectEventSensor(entry.runtime_data, appliance, description)
|
||||
HomeConnectEventSensor(appliance_coordinator, description)
|
||||
for description in EVENT_SENSORS
|
||||
if description.appliance_types
|
||||
and appliance.info.type in description.appliance_types
|
||||
and appliance_coordinator.data.info.type in description.appliance_types
|
||||
],
|
||||
*[
|
||||
HomeConnectProgramSensor(entry.runtime_data, appliance, desc)
|
||||
HomeConnectProgramSensor(appliance_coordinator, desc)
|
||||
for desc in BSH_PROGRAM_SENSORS
|
||||
if desc.appliance_types and appliance.info.type in desc.appliance_types
|
||||
if desc.appliance_types
|
||||
and appliance_coordinator.data.info.type in desc.appliance_types
|
||||
],
|
||||
*[
|
||||
HomeConnectSensor(entry.runtime_data, appliance, description)
|
||||
HomeConnectSensor(appliance_coordinator, description)
|
||||
for description in SENSORS
|
||||
if description.key in appliance.status
|
||||
if description.key in appliance_coordinator.data.status
|
||||
],
|
||||
]
|
||||
|
||||
@@ -607,7 +607,7 @@ class HomeConnectProgramSensor(HomeConnectSensor):
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_operation_state_event,
|
||||
(self.appliance.info.ha_id, EventKey.BSH_COMMON_STATUS_OPERATION_STATE),
|
||||
EventKey.BSH_COMMON_STATUS_OPERATION_STATE,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1290,6 +1290,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"appliance_disconnected": {
|
||||
"message": "Appliance {appliance_name} ({ha_id}) is disconnected"
|
||||
},
|
||||
"appliance_not_found": {
|
||||
"message": "Appliance for device ID {device_id} not found"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from .common import setup_home_connect_entry, should_add_option_entity
|
||||
from .const import BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
@@ -170,36 +170,32 @@ SWITCH_OPTIONS = (
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
entities: list[HomeConnectEntity] = []
|
||||
if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings:
|
||||
if SettingKey.BSH_COMMON_POWER_STATE in appliance_coordinator.data.settings:
|
||||
entities.append(
|
||||
HomeConnectPowerSwitch(
|
||||
entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION
|
||||
)
|
||||
HomeConnectPowerSwitch(appliance_coordinator, POWER_SWITCH_DESCRIPTION)
|
||||
)
|
||||
entities.extend(
|
||||
HomeConnectSwitch(entry.runtime_data, appliance, description)
|
||||
HomeConnectSwitch(appliance_coordinator, description)
|
||||
for description in SWITCHES
|
||||
if description.key in appliance.settings
|
||||
if description.key in appliance_coordinator.data.settings
|
||||
)
|
||||
return entities
|
||||
|
||||
|
||||
def _get_option_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of currently available option entities."""
|
||||
return [
|
||||
HomeConnectSwitchOptionEntity(entry.runtime_data, appliance, description)
|
||||
HomeConnectSwitchOptionEntity(appliance_coordinator, description)
|
||||
for description in SWITCH_OPTIONS
|
||||
if should_add_option_entity(
|
||||
description, appliance, entity_registry, Platform.SWITCH
|
||||
description, appliance_coordinator.data, entity_registry, Platform.SWITCH
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -27,6 +27,15 @@
|
||||
"multiple_integration_config_errors": {
|
||||
"message": "Failed to process config for integration {domain} due to multiple ({errors}) errors. Check the logs for more information."
|
||||
},
|
||||
"oauth2_helper_reauth_required": {
|
||||
"message": "Credentials are invalid, re-authentication required"
|
||||
},
|
||||
"oauth2_helper_refresh_failed": {
|
||||
"message": "OAuth2 token refresh failed for {domain}"
|
||||
},
|
||||
"oauth2_helper_refresh_transient": {
|
||||
"message": "Temporary error refreshing credentials for {domain}, try again later"
|
||||
},
|
||||
"platform_component_load_err": {
|
||||
"message": "Platform error: {domain} - {error}."
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ from homematicip.device import (
|
||||
PluggableDimmer,
|
||||
SwitchMeasuring,
|
||||
WiredDimmer3,
|
||||
WiredPushButton,
|
||||
)
|
||||
from packaging.version import Version
|
||||
|
||||
@@ -93,6 +94,20 @@ async def async_setup_entry(
|
||||
(Dimmer, PluggableDimmer, BrandDimmer, FullFlushDimmer),
|
||||
):
|
||||
entities.append(HomematicipDimmer(hap, device))
|
||||
elif isinstance(device, WiredPushButton):
|
||||
optical_channels = sorted(
|
||||
(
|
||||
ch
|
||||
for ch in device.functionalChannels
|
||||
if ch.functionalChannelType
|
||||
== FunctionalChannelType.OPTICAL_SIGNAL_CHANNEL
|
||||
),
|
||||
key=lambda ch: ch.index,
|
||||
)
|
||||
for led_number, ch in enumerate(optical_channels, start=1):
|
||||
entities.append(
|
||||
HomematicipOpticalSignalLight(hap, device, ch.index, led_number)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -421,3 +436,129 @@ def _convert_color(color: tuple) -> RGBColorState:
|
||||
if 270 < hue <= 330:
|
||||
return RGBColorState.PURPLE
|
||||
return RGBColorState.RED
|
||||
|
||||
|
||||
class HomematicipOpticalSignalLight(HomematicipGenericEntity, LightEntity):
|
||||
"""Representation of HomematicIP WiredPushButton LED light."""
|
||||
|
||||
_attr_color_mode = ColorMode.HS
|
||||
_attr_supported_color_modes = {ColorMode.HS}
|
||||
_attr_supported_features = LightEntityFeature.EFFECT
|
||||
_attr_translation_key = "optical_signal_light"
|
||||
|
||||
_effect_to_behaviour: dict[str, OpticalSignalBehaviour] = {
|
||||
"on": OpticalSignalBehaviour.ON,
|
||||
"blinking": OpticalSignalBehaviour.BLINKING_MIDDLE,
|
||||
"flash": OpticalSignalBehaviour.FLASH_MIDDLE,
|
||||
"billow": OpticalSignalBehaviour.BILLOW_MIDDLE,
|
||||
}
|
||||
_behaviour_to_effect: dict[OpticalSignalBehaviour, str] = {
|
||||
v: k for k, v in _effect_to_behaviour.items()
|
||||
}
|
||||
|
||||
_attr_effect_list = list(_effect_to_behaviour)
|
||||
|
||||
_color_switcher: dict[str, tuple[float, float]] = {
|
||||
RGBColorState.WHITE: (0.0, 0.0),
|
||||
RGBColorState.RED: (0.0, 100.0),
|
||||
RGBColorState.YELLOW: (60.0, 100.0),
|
||||
RGBColorState.GREEN: (120.0, 100.0),
|
||||
RGBColorState.TURQUOISE: (180.0, 100.0),
|
||||
RGBColorState.BLUE: (240.0, 100.0),
|
||||
RGBColorState.PURPLE: (300.0, 100.0),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hap: HomematicipHAP,
|
||||
device: WiredPushButton,
|
||||
channel_index: int,
|
||||
led_number: int,
|
||||
) -> None:
|
||||
"""Initialize the optical signal light entity."""
|
||||
super().__init__(
|
||||
hap,
|
||||
device,
|
||||
post=f"LED {led_number}",
|
||||
channel=channel_index,
|
||||
is_multi_channel=True,
|
||||
channel_real_index=channel_index,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if light is on."""
|
||||
channel = self.get_channel_or_raise()
|
||||
return channel.on is True
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
channel = self.get_channel_or_raise()
|
||||
return int((channel.dimLevel or 0.0) * 255)
|
||||
|
||||
@property
|
||||
def hs_color(self) -> tuple[float, float]:
|
||||
"""Return the hue and saturation color value [float, float]."""
|
||||
channel = self.get_channel_or_raise()
|
||||
simple_rgb_color = channel.simpleRGBColorState
|
||||
return self._color_switcher.get(simple_rgb_color, (0.0, 0.0))
|
||||
|
||||
@property
|
||||
def effect(self) -> str | None:
|
||||
"""Return the current effect."""
|
||||
channel = self.get_channel_or_raise()
|
||||
return self._behaviour_to_effect.get(channel.opticalSignalBehaviour)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes of the optical signal light."""
|
||||
state_attr = super().extra_state_attributes
|
||||
channel = self.get_channel_or_raise()
|
||||
|
||||
if self.is_on:
|
||||
state_attr[ATTR_COLOR_NAME] = channel.simpleRGBColorState
|
||||
|
||||
return state_attr
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
# Use hs_color from kwargs, if not applicable use current hs_color.
|
||||
hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color)
|
||||
simple_rgb_color = _convert_color(hs_color)
|
||||
|
||||
# If no kwargs, use default value.
|
||||
brightness = 255
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
|
||||
# Minimum brightness is 10, otherwise the LED is disabled
|
||||
brightness = max(10, brightness)
|
||||
dim_level = round(brightness / 255.0, 2)
|
||||
|
||||
effect = self.effect
|
||||
if ATTR_EFFECT in kwargs:
|
||||
effect = kwargs[ATTR_EFFECT]
|
||||
elif effect is None:
|
||||
effect = "on"
|
||||
|
||||
behaviour = self._effect_to_behaviour.get(effect, OpticalSignalBehaviour.ON)
|
||||
|
||||
await self._device.set_optical_signal_async(
|
||||
channelIndex=self._channel,
|
||||
opticalSignalBehaviour=behaviour,
|
||||
rgb=simple_rgb_color,
|
||||
dimLevel=dim_level,
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
channel = self.get_channel_or_raise()
|
||||
simple_rgb_color = channel.simpleRGBColorState
|
||||
|
||||
await self._device.set_optical_signal_async(
|
||||
channelIndex=self._channel,
|
||||
opticalSignalBehaviour=OpticalSignalBehaviour.OFF,
|
||||
rgb=simple_rgb_color,
|
||||
dimLevel=0.0,
|
||||
)
|
||||
|
||||
@@ -28,6 +28,20 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"light": {
|
||||
"optical_signal_light": {
|
||||
"state_attributes": {
|
||||
"effect": {
|
||||
"state": {
|
||||
"billow": "Billow",
|
||||
"blinking": "Blinking",
|
||||
"flash": "Flash",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"smoke_detector_alarm_counter": {
|
||||
"name": "Alarm counter"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user