mirror of
https://github.com/home-assistant/core.git
synced 2026-05-05 12:24:48 +02:00
Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f25e98999 | |||
| d1a4e73ffd | |||
| 65f2db860b | |||
| 8217b3981f | |||
| 3076ead727 | |||
| 094623e528 | |||
| cf528c5234 | |||
| 35cc2bf803 | |||
| 71bf4ad134 | |||
| 7523044d65 | |||
| d1110102c7 | |||
| 86c08d80c9 | |||
| a66d63e439 | |||
| 68f3f8db1e | |||
| 9d235618ff | |||
| d10c5f459f | |||
| 9b3d44c255 | |||
| d10716ff55 | |||
| 79dae30b19 | |||
| 2a515953ea | |||
| b19dc8bc37 | |||
| 0b4bfcc941 | |||
| 4f0c20cf33 | |||
| 050bddb9fe | |||
| ef9419f001 | |||
| 20a1bc710e | |||
| 5e0ea9fd24 | |||
| 1f0c13f259 | |||
| a1fc223914 | |||
| 836d8a6fca | |||
| 520c3411dd | |||
| d90c107b1b | |||
| 5a2bc8e493 | |||
| b8f8b30b9b | |||
| e80f4e03a4 | |||
| 7ec369d8ef | |||
| dcc08a0aac | |||
| 4802e4e33f | |||
| eb4b041d45 | |||
| bfd8579566 | |||
| beb5a992e6 | |||
| 7c925778eb | |||
| 7db161868e | |||
| 5f2a2280c5 | |||
| b327628b6e | |||
| 311ebd4a96 | |||
| dc4659b167 | |||
| c1d0fe9eae | |||
| 3fde6bfd73 | |||
| 4efa3b634e | |||
| cd65aaee60 | |||
| 2395c753fe | |||
| bc2949ef31 | |||
| f665c4e588 | |||
| 57b7b28d60 | |||
| 9a0f42f9a7 | |||
| 895dcaf690 | |||
| 4e96ff78b5 | |||
| a87ed13a04 | |||
| 735deff45e | |||
| 22867acaf8 | |||
| c507c72350 | |||
| 7fc36c4fe0 | |||
| 5196a770cc | |||
| 54d7380f4d | |||
| c445e93d45 | |||
| 614529d7c3 | |||
| 9361c9ef60 | |||
| 19a0644b50 | |||
| 82173f477c | |||
| b4af32624d | |||
| 78f40bd4bf | |||
| d92ad76ed9 | |||
| e44d50e1b1 | |||
| 95c0eeecfb | |||
| ec263840ba | |||
| 8047134c88 | |||
| cb89688873 | |||
| c73319e162 | |||
| 499cc2e51d | |||
| 5a03fffc20 | |||
| 6d8d472f0f | |||
| ac2897fc67 | |||
| e7e20533bd | |||
| 2772bae2e1 | |||
| 86622794e0 | |||
| 686f6768fc | |||
| f271fea07c | |||
| 77b1df5902 | |||
| 1faa111222 | |||
| b513301363 | |||
| 32bdcdd663 | |||
| 40f76d4ed9 | |||
| 34568aad89 | |||
| ffe84e8ece | |||
| 8cbd89282b | |||
| 1467668c94 | |||
| bbef38964d | |||
| 03b88af032 | |||
| 0626bc8b4f | |||
| 37ecbc53a7 | |||
| 52c96654a4 | |||
| 791c2f4b8a | |||
| ed041d5b7c | |||
| 1833ab96dc | |||
| ff2e2656b3 | |||
| 599c20c76e | |||
| 5df747276f | |||
| 77b06bc158 | |||
| db6d176658 | |||
| 973eb4f6d4 | |||
| 14401aa840 | |||
| b82ddb77bc | |||
| 22530f72f3 | |||
| a16bf358aa | |||
| 0924874d4b | |||
| a3ff783bc1 | |||
| e7d06e3f6a | |||
| a2fc870266 | |||
| ffcb107716 |
@@ -131,7 +131,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2021.11.4
|
||||
uses: home-assistant/builder@2021.12.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -170,6 +170,17 @@ jobs:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v2.4.0
|
||||
|
||||
- name: Set build additional args
|
||||
run: |
|
||||
# Create general tags
|
||||
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
|
||||
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
|
||||
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
|
||||
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
|
||||
else
|
||||
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1.10.0
|
||||
with:
|
||||
@@ -184,7 +195,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2021.11.4
|
||||
uses: home-assistant/builder@2021.12.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
|
||||
@@ -155,10 +155,15 @@ jobs:
|
||||
key: >-
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
steps.generate-python-key.outputs.key }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_test.txt') }}-
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements.txt') }}-
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}-
|
||||
# Temporary disabling the restore of environments when bumping
|
||||
# a dependency. It seems that we are experiencing issues with
|
||||
# restoring environments in GitHub Actions, although unclear why.
|
||||
# First attempt: https://github.com/home-assistant/core/pull/62383
|
||||
#
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_test.txt') }}-
|
||||
# ${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements.txt') }}-
|
||||
# ${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}-
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -517,10 +522,15 @@ jobs:
|
||||
key: >-
|
||||
${{ runner.os }}-${{ matrix.python-version }}-${{
|
||||
steps.generate-python-key.outputs.key }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements_test.txt') }}-${{ hashFiles('requirements_all.txt') }}-
|
||||
${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements_test.txt') }}-
|
||||
${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}-
|
||||
# Temporary disabling the restore of environments when bumping
|
||||
# a dependency. It seems that we are experiencing issues with
|
||||
# restoring environments in GitHub Actions, although unclear why.
|
||||
# First attempt: https://github.com/home-assistant/core/pull/62383
|
||||
#
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements_test.txt') }}-${{ hashFiles('requirements_all.txt') }}-
|
||||
# ${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements_test.txt') }}-
|
||||
# ${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}-
|
||||
- name: Create full Python ${{ matrix.python-version }} virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
|
||||
+1
-1
@@ -432,7 +432,7 @@ homeassistant/components/recollect_waste/* @bachya
|
||||
homeassistant/components/recorder/* @home-assistant/core
|
||||
homeassistant/components/rejseplanen/* @DarkFox
|
||||
homeassistant/components/renault/* @epenet
|
||||
homeassistant/components/repetier/* @MTrab
|
||||
homeassistant/components/repetier/* @MTrab @ShadowBr0ther
|
||||
homeassistant/components/rflink/* @javicalle
|
||||
homeassistant/components/rfxtrx/* @danielhiversen @elupus @RobBie1221
|
||||
homeassistant/components/ridwell/* @bachya
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "bmw_connected_drive",
|
||||
"name": "BMW Connected Drive",
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"requirements": ["bimmer_connected==0.8.5"],
|
||||
"requirements": ["bimmer_connected==0.8.7"],
|
||||
"codeowners": ["@gerard33", "@rikroe"],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
|
||||
@@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
try:
|
||||
async with async_timeout.timeout(10):
|
||||
things = await bapi.async_get_things(force=True)
|
||||
return {thing.SERIAL: thing for thing in things}
|
||||
return {thing.serial: thing for thing in things}
|
||||
except ServerDisconnectedError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
except ClientResponseError as err:
|
||||
|
||||
@@ -100,7 +100,7 @@ class BruntDevice(CoordinatorEntity, CoverEntity):
|
||||
|
||||
self._remove_update_listener = None
|
||||
|
||||
self._attr_name = self._thing.NAME
|
||||
self._attr_name = self._thing.name
|
||||
self._attr_device_class = DEVICE_CLASS_SHADE
|
||||
self._attr_supported_features = COVER_FEATURES
|
||||
self._attr_attribution = ATTRIBUTION
|
||||
@@ -109,8 +109,8 @@ class BruntDevice(CoordinatorEntity, CoverEntity):
|
||||
name=self._attr_name,
|
||||
via_device=(DOMAIN, self._entry_id),
|
||||
manufacturer="Brunt",
|
||||
sw_version=self._thing.FW_VERSION,
|
||||
model=self._thing.MODEL,
|
||||
sw_version=self._thing.fw_version,
|
||||
model=self._thing.model,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
@@ -127,8 +127,7 @@ class BruntDevice(CoordinatorEntity, CoverEntity):
|
||||
|
||||
None is unknown, 0 is closed, 100 is fully open.
|
||||
"""
|
||||
pos = self.coordinator.data[self.unique_id].currentPosition
|
||||
return int(pos) if pos is not None else None
|
||||
return self.coordinator.data[self.unique_id].current_position
|
||||
|
||||
@property
|
||||
def request_cover_position(self) -> int | None:
|
||||
@@ -139,8 +138,7 @@ class BruntDevice(CoordinatorEntity, CoverEntity):
|
||||
to Brunt, at times there is a diff of 1 to current
|
||||
None is unknown, 0 is closed, 100 is fully open.
|
||||
"""
|
||||
pos = self.coordinator.data[self.unique_id].requestPosition
|
||||
return int(pos) if pos is not None else None
|
||||
return self.coordinator.data[self.unique_id].request_position
|
||||
|
||||
@property
|
||||
def move_state(self) -> int | None:
|
||||
@@ -149,8 +147,7 @@ class BruntDevice(CoordinatorEntity, CoverEntity):
|
||||
|
||||
None is unknown, 0 when stopped, 1 when opening, 2 when closing
|
||||
"""
|
||||
mov = self.coordinator.data[self.unique_id].moveState
|
||||
return int(mov) if mov is not None else None
|
||||
return self.coordinator.data[self.unique_id].move_state
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool:
|
||||
@@ -190,11 +187,11 @@ class BruntDevice(CoordinatorEntity, CoverEntity):
|
||||
"""Set the cover to the new position and wait for the update to be reflected."""
|
||||
try:
|
||||
await self._bapi.async_change_request_position(
|
||||
position, thingUri=self._thing.thingUri
|
||||
position, thing_uri=self._thing.thing_uri
|
||||
)
|
||||
except ClientResponseError as exc:
|
||||
raise HomeAssistantError(
|
||||
f"Unable to reposition {self._thing.NAME}"
|
||||
f"Unable to reposition {self._thing.name}"
|
||||
) from exc
|
||||
self.coordinator.update_interval = FAST_INTERVAL
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -204,7 +201,7 @@ class BruntDevice(CoordinatorEntity, CoverEntity):
|
||||
"""Update the update interval after each refresh."""
|
||||
if (
|
||||
self.request_cover_position
|
||||
== self._bapi.last_requested_positions[self._thing.thingUri]
|
||||
== self._bapi.last_requested_positions[self._thing.thing_uri]
|
||||
and self.move_state == 0
|
||||
):
|
||||
self.coordinator.update_interval = REGULAR_INTERVAL
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Brunt Blind Engine",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/brunt",
|
||||
"requirements": ["brunt==1.0.0"],
|
||||
"requirements": ["brunt==1.1.0"],
|
||||
"codeowners": ["@eavanvalkenburg"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -161,6 +161,9 @@ class WebDavCalendarData:
|
||||
)
|
||||
event_list = []
|
||||
for event in vevent_list:
|
||||
if not hasattr(event.instance, "vevent"):
|
||||
_LOGGER.warning("Skipped event with missing 'vevent' property")
|
||||
continue
|
||||
vevent = event.instance.vevent
|
||||
if not self.is_matching(vevent, self.search):
|
||||
continue
|
||||
@@ -198,6 +201,9 @@ class WebDavCalendarData:
|
||||
# and they would not be properly parsed using their original start/end dates.
|
||||
new_events = []
|
||||
for event in results:
|
||||
if not hasattr(event.instance, "vevent"):
|
||||
_LOGGER.warning("Skipped event with missing 'vevent' property")
|
||||
continue
|
||||
vevent = event.instance.vevent
|
||||
for start_dt in vevent.getrruleset() or []:
|
||||
_start_of_today = start_of_today
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Google Cast",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/cast",
|
||||
"requirements": ["pychromecast==10.1.1"],
|
||||
"requirements": ["pychromecast==10.2.2"],
|
||||
"after_dependencies": [
|
||||
"cloud",
|
||||
"http",
|
||||
|
||||
@@ -47,7 +47,6 @@ from homeassistant.components.plex.const import PLEX_URI_SCHEME
|
||||
from homeassistant.components.plex.services import lookup_plex_media
|
||||
from homeassistant.const import (
|
||||
CAST_APP_ID_HOMEASSISTANT_LOVELACE,
|
||||
CAST_APP_ID_HOMEASSISTANT_MEDIA,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
STATE_IDLE,
|
||||
STATE_OFF,
|
||||
@@ -77,6 +76,8 @@ from .helpers import CastStatusListener, ChromecastInfo, ChromeCastZeroconf
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
APP_IDS_UNRELIABLE_MEDIA_INFO = ("Netflix",)
|
||||
|
||||
CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png"
|
||||
|
||||
SUPPORT_CAST = SUPPORT_PLAY_MEDIA | SUPPORT_TURN_OFF
|
||||
@@ -230,7 +231,6 @@ class CastDevice(MediaPlayerEntity):
|
||||
self._cast_info.cast_info,
|
||||
ChromeCastZeroconf.get_zeroconf(),
|
||||
)
|
||||
chromecast.media_controller.app_id = CAST_APP_ID_HOMEASSISTANT_MEDIA
|
||||
self._chromecast = chromecast
|
||||
|
||||
if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
|
||||
@@ -397,11 +397,14 @@ class CastDevice(MediaPlayerEntity):
|
||||
return
|
||||
|
||||
if self._chromecast.app_id is not None:
|
||||
# Quit the previous app before starting splash screen
|
||||
# Quit the previous app before starting splash screen or media player
|
||||
self._chromecast.quit_app()
|
||||
|
||||
# The only way we can turn the Chromecast is on is by launching an app
|
||||
self._chromecast.play_media(CAST_SPLASH, pychromecast.STREAM_TYPE_BUFFERED)
|
||||
if self._chromecast.cast_type == pychromecast.const.CAST_TYPE_CHROMECAST:
|
||||
self._chromecast.play_media(CAST_SPLASH, pychromecast.STREAM_TYPE_BUFFERED)
|
||||
else:
|
||||
self._chromecast.start_app(pychromecast.config.APP_MEDIA_RECEIVER)
|
||||
|
||||
def turn_off(self):
|
||||
"""Turn off the cast device."""
|
||||
@@ -527,9 +530,8 @@ class CastDevice(MediaPlayerEntity):
|
||||
self._chromecast.register_handler(controller)
|
||||
controller.play_media(media)
|
||||
else:
|
||||
self._chromecast.media_controller.play_media(
|
||||
media_id, media_type, **kwargs.get(ATTR_MEDIA_EXTRA, {})
|
||||
)
|
||||
app_data = {"media_id": media_id, "media_type": media_type, **extra}
|
||||
quick_play(self._chromecast, "default_media_receiver", app_data)
|
||||
|
||||
def _media_status(self):
|
||||
"""
|
||||
@@ -564,7 +566,10 @@ class CastDevice(MediaPlayerEntity):
|
||||
if media_status.player_is_idle:
|
||||
return STATE_IDLE
|
||||
if self.app_id is not None and self.app_id != pychromecast.IDLE_APP_ID:
|
||||
return STATE_PLAYING
|
||||
if self.app_id in APP_IDS_UNRELIABLE_MEDIA_INFO:
|
||||
# Some apps don't report media status, show the player as playing
|
||||
return STATE_PLAYING
|
||||
return STATE_IDLE
|
||||
if self._chromecast is not None and self._chromecast.is_idle:
|
||||
return STATE_OFF
|
||||
return None
|
||||
@@ -677,9 +682,9 @@ class CastDevice(MediaPlayerEntity):
|
||||
support = SUPPORT_CAST
|
||||
media_status = self._media_status()[0]
|
||||
|
||||
if (
|
||||
self._chromecast
|
||||
and self._chromecast.cast_type == pychromecast.const.CAST_TYPE_CHROMECAST
|
||||
if self._chromecast and self._chromecast.cast_type in (
|
||||
pychromecast.const.CAST_TYPE_CHROMECAST,
|
||||
pychromecast.const.CAST_TYPE_AUDIO,
|
||||
):
|
||||
support |= SUPPORT_TURN_ON
|
||||
|
||||
@@ -820,7 +825,6 @@ class DynamicCastGroup:
|
||||
self._cast_info.cast_info,
|
||||
ChromeCastZeroconf.get_zeroconf(),
|
||||
)
|
||||
chromecast.media_controller.app_id = CAST_APP_ID_HOMEASSISTANT_MEDIA
|
||||
self._chromecast = chromecast
|
||||
|
||||
if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
|
||||
|
||||
@@ -199,7 +199,7 @@ class DeconzBaseLight(DeconzDevice, LightEntity):
|
||||
"""Turn on light."""
|
||||
data: dict[str, bool | float | int | str | tuple[float, float]] = {"on": True}
|
||||
|
||||
if attr_brightness := kwargs.get(ATTR_BRIGHTNESS):
|
||||
if (attr_brightness := kwargs.get(ATTR_BRIGHTNESS)) is not None:
|
||||
data["brightness"] = attr_brightness
|
||||
|
||||
if attr_color_temp := kwargs.get(ATTR_COLOR_TEMP):
|
||||
@@ -215,16 +215,16 @@ class DeconzBaseLight(DeconzDevice, LightEntity):
|
||||
if ATTR_XY_COLOR in kwargs:
|
||||
data["xy"] = kwargs[ATTR_XY_COLOR]
|
||||
|
||||
if attr_transition := kwargs.get(ATTR_TRANSITION):
|
||||
if (attr_transition := kwargs.get(ATTR_TRANSITION)) is not None:
|
||||
data["transition_time"] = int(attr_transition * 10)
|
||||
elif "IKEA" in self._device.manufacturer:
|
||||
data["transition_time"] = 0
|
||||
|
||||
if (alert := FLASH_TO_DECONZ.get(kwargs.get(ATTR_FLASH, ""))) is not None:
|
||||
if (alert := FLASH_TO_DECONZ.get(kwargs.get(ATTR_FLASH))) is not None:
|
||||
data["alert"] = alert
|
||||
del data["on"]
|
||||
|
||||
if (effect := EFFECT_TO_DECONZ.get(kwargs.get(ATTR_EFFECT, ""))) is not None:
|
||||
if (effect := EFFECT_TO_DECONZ.get(kwargs.get(ATTR_EFFECT))) is not None:
|
||||
data["effect"] = effect
|
||||
|
||||
await self._device.set_state(**data)
|
||||
@@ -236,11 +236,11 @@ class DeconzBaseLight(DeconzDevice, LightEntity):
|
||||
|
||||
data: dict[str, bool | int | str] = {"on": False}
|
||||
|
||||
if ATTR_TRANSITION in kwargs:
|
||||
if (attr_transition := kwargs.get(ATTR_TRANSITION)) is not None:
|
||||
data["brightness"] = 0
|
||||
data["transition_time"] = int(kwargs[ATTR_TRANSITION] * 10)
|
||||
data["transition_time"] = int(attr_transition * 10)
|
||||
|
||||
if (alert := FLASH_TO_DECONZ.get(kwargs.get(ATTR_FLASH, ""))) is not None:
|
||||
if (alert := FLASH_TO_DECONZ.get(kwargs.get(ATTR_FLASH))) is not None:
|
||||
data["alert"] = alert
|
||||
del data["on"]
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Dexcom",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/dexcom",
|
||||
"requirements": ["pydexcom==0.2.1"],
|
||||
"requirements": ["pydexcom==0.2.2"],
|
||||
"codeowners": ["@gagebenne"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
"domain": "dht",
|
||||
"name": "DHT Sensor",
|
||||
"documentation": "https://www.home-assistant.io/integrations/dht",
|
||||
"requirements": ["adafruit-circuitpython-dht==3.6.0"],
|
||||
"codeowners": ["@thegardenmonkey"],
|
||||
"requirements": [
|
||||
"adafruit-circuitpython-dht==3.7.0",
|
||||
"RPi.GPIO==0.7.1a4"
|
||||
],
|
||||
"codeowners": [
|
||||
"@thegardenmonkey"
|
||||
],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "DLNA Digital Media Renderer",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||
"requirements": ["async-upnp-client==0.22.12"],
|
||||
"requirements": ["async-upnp-client==0.23.1"],
|
||||
"dependencies": ["ssdp"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "ebusd",
|
||||
"name": "ebusd",
|
||||
"documentation": "https://www.home-assistant.io/integrations/ebusd",
|
||||
"requirements": ["ebusdpy==0.0.16"],
|
||||
"requirements": ["ebusdpy==0.0.17"],
|
||||
"codeowners": [],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "environment_canada",
|
||||
"name": "Environment Canada",
|
||||
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
|
||||
"requirements": ["env_canada==0.5.18"],
|
||||
"requirements": ["env_canada==0.5.20"],
|
||||
"codeowners": ["@gwww", "@michaeldavie"],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
|
||||
@@ -112,10 +112,11 @@ def request_app_setup(
|
||||
Then come back here and hit the below button.
|
||||
"""
|
||||
except NoURLAvailableError:
|
||||
error_msg = """Could not find a SSL enabled URL for your Home Assistant instance.
|
||||
Fitbit requires that your Home Assistant instance is accessible via HTTPS.
|
||||
"""
|
||||
configurator.notify_errors(_CONFIGURING["fitbit"], error_msg)
|
||||
_LOGGER.error(
|
||||
"Could not find an SSL enabled URL for your Home Assistant instance. "
|
||||
"Fitbit requires that your Home Assistant instance is accessible via HTTPS"
|
||||
)
|
||||
return
|
||||
|
||||
submit = "I have saved my Client ID and Client Secret into fitbit.conf."
|
||||
|
||||
|
||||
@@ -94,6 +94,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
firmware_date=None,
|
||||
model_info=None,
|
||||
model_description=None,
|
||||
remote_access_enabled=None,
|
||||
remote_access_host=None,
|
||||
remote_access_port=None,
|
||||
)
|
||||
return await self._async_handle_discovery()
|
||||
|
||||
@@ -261,6 +264,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
firmware_date=None,
|
||||
model_info=None,
|
||||
model_description=bulb.model_data.description,
|
||||
remote_access_enabled=None,
|
||||
remote_access_host=None,
|
||||
remote_access_port=None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Flux LED/MagicHome",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/flux_led",
|
||||
"requirements": ["flux_led==0.26.7"],
|
||||
"requirements": ["flux_led==0.27.13"],
|
||||
"quality_scale": "platinum",
|
||||
"codeowners": ["@icemanch"],
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
|
||||
from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError
|
||||
from fritzconnection.core.logger import fritzlogger
|
||||
from requests import exceptions
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -45,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await fritz_tools.async_start(entry.options)
|
||||
except FritzSecurityError as ex:
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
except FritzConnectionException as ex:
|
||||
except (FritzConnectionException, exceptions.ConnectionError) as ex:
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
@@ -117,7 +117,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._port = ssdp_location.port
|
||||
self._name = (
|
||||
discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
|
||||
or self.fritz_tools.model
|
||||
or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME]
|
||||
)
|
||||
self.context[CONF_HOST] = self._host
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from abc import ABC, abstractmethod
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Any, Dict, TypeVar
|
||||
|
||||
from pyfronius import FroniusError
|
||||
from pyfronius import BadStatusError, FroniusError
|
||||
|
||||
from homeassistant.components.sensor import SensorEntityDescription
|
||||
from homeassistant.core import callback
|
||||
@@ -43,6 +43,8 @@ class FroniusCoordinatorBase(
|
||||
error_interval: timedelta
|
||||
valid_descriptions: list[SensorEntityDescription]
|
||||
|
||||
MAX_FAILED_UPDATES = 3
|
||||
|
||||
def __init__(self, *args: Any, solar_net: FroniusSolarNet, **kwargs: Any) -> None:
|
||||
"""Set up the FroniusCoordinatorBase class."""
|
||||
self._failed_update_count = 0
|
||||
@@ -62,7 +64,7 @@ class FroniusCoordinatorBase(
|
||||
data = await self._update_method()
|
||||
except FroniusError as err:
|
||||
self._failed_update_count += 1
|
||||
if self._failed_update_count == 3:
|
||||
if self._failed_update_count == self.MAX_FAILED_UPDATES:
|
||||
self.update_interval = self.error_interval
|
||||
raise UpdateFailed(err) from err
|
||||
|
||||
@@ -116,6 +118,8 @@ class FroniusInverterUpdateCoordinator(FroniusCoordinatorBase):
|
||||
error_interval = timedelta(minutes=10)
|
||||
valid_descriptions = INVERTER_ENTITY_DESCRIPTIONS
|
||||
|
||||
SILENT_RETRIES = 3
|
||||
|
||||
def __init__(
|
||||
self, *args: Any, inverter_info: FroniusDeviceInfo, **kwargs: Any
|
||||
) -> None:
|
||||
@@ -125,9 +129,19 @@ class FroniusInverterUpdateCoordinator(FroniusCoordinatorBase):
|
||||
|
||||
async def _update_method(self) -> dict[SolarNetId, Any]:
|
||||
"""Return data per solar net id from pyfronius."""
|
||||
data = await self.solar_net.fronius.current_inverter_data(
|
||||
self.inverter_info.solar_net_id
|
||||
)
|
||||
# almost 1% of `current_inverter_data` requests on Symo devices result in
|
||||
# `BadStatusError Code: 8 - LNRequestTimeout` due to flaky internal
|
||||
# communication between the logger and the inverter.
|
||||
for silent_retry in range(self.SILENT_RETRIES):
|
||||
try:
|
||||
data = await self.solar_net.fronius.current_inverter_data(
|
||||
self.inverter_info.solar_net_id
|
||||
)
|
||||
except BadStatusError as err:
|
||||
if silent_retry == (self.SILENT_RETRIES - 1):
|
||||
raise err
|
||||
continue
|
||||
break
|
||||
# wrap a single devices data in a dict with solar_net_id key for
|
||||
# FroniusCoordinatorBase _async_update_data and add_entities_for_seen_keys
|
||||
return {self.inverter_info.solar_net_id: data}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"domain": "frontend",
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": ["home-assistant-frontend==20211211.0"],
|
||||
"requirements": [
|
||||
"home-assistant-frontend==20211220.0"
|
||||
],
|
||||
"dependencies": [
|
||||
"api",
|
||||
"auth",
|
||||
@@ -15,6 +17,8 @@
|
||||
"system_log",
|
||||
"websocket_api"
|
||||
],
|
||||
"codeowners": ["@home-assistant/frontend"],
|
||||
"codeowners": [
|
||||
"@home-assistant/frontend"
|
||||
],
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
}
|
||||
@@ -249,14 +249,17 @@ class OpeningDeviceBase(HomeAccessory):
|
||||
def async_update_state(self, new_state):
|
||||
"""Update cover position and tilt after state changed."""
|
||||
# update tilt
|
||||
if not self._supports_tilt:
|
||||
return
|
||||
current_tilt = new_state.attributes.get(ATTR_CURRENT_TILT_POSITION)
|
||||
if isinstance(current_tilt, (float, int)):
|
||||
# HomeKit sends values between -90 and 90.
|
||||
# We'll have to normalize to [0,100]
|
||||
current_tilt = (current_tilt / 100.0 * 180.0) - 90.0
|
||||
current_tilt = int(current_tilt)
|
||||
self.char_current_tilt.set_value(current_tilt)
|
||||
self.char_target_tilt.set_value(current_tilt)
|
||||
if not isinstance(current_tilt, (float, int)):
|
||||
return
|
||||
# HomeKit sends values between -90 and 90.
|
||||
# We'll have to normalize to [0,100]
|
||||
current_tilt = (current_tilt / 100.0 * 180.0) - 90.0
|
||||
current_tilt = int(current_tilt)
|
||||
self.char_current_tilt.set_value(current_tilt)
|
||||
self.char_target_tilt.set_value(current_tilt)
|
||||
|
||||
|
||||
class OpeningDevice(OpeningDeviceBase, HomeAccessory):
|
||||
|
||||
@@ -120,10 +120,11 @@ class Light(HomeAccessory):
|
||||
if self._event_timer:
|
||||
self._event_timer()
|
||||
self._event_timer = async_call_later(
|
||||
self.hass, CHANGE_COALESCE_TIME_WINDOW, self._send_events
|
||||
self.hass, CHANGE_COALESCE_TIME_WINDOW, self._async_send_events
|
||||
)
|
||||
|
||||
def _send_events(self, *_):
|
||||
@callback
|
||||
def _async_send_events(self, *_):
|
||||
"""Process all changes at once."""
|
||||
_LOGGER.debug("Coalesced _set_chars: %s", self._pending_events)
|
||||
char_values = self._pending_events
|
||||
|
||||
@@ -30,14 +30,13 @@ async def async_setup_entry(hass, config):
|
||||
loc_id = config.data.get(CONF_LOC_ID)
|
||||
dev_id = config.data.get(CONF_DEV_ID)
|
||||
|
||||
devices = []
|
||||
devices = {}
|
||||
|
||||
for location in client.locations_by_id.values():
|
||||
for device in location.devices_by_id.values():
|
||||
if (not loc_id or location.locationid == loc_id) and (
|
||||
not dev_id or device.deviceid == dev_id
|
||||
):
|
||||
devices.append(device)
|
||||
if not loc_id or location.locationid == loc_id:
|
||||
for device in location.devices_by_id.values():
|
||||
if not dev_id or device.deviceid == dev_id:
|
||||
devices[device.deviceid] = device
|
||||
|
||||
if len(devices) == 0:
|
||||
_LOGGER.debug("No devices found")
|
||||
@@ -107,23 +106,30 @@ class HoneywellData:
|
||||
if self._client is None:
|
||||
return False
|
||||
|
||||
devices = [
|
||||
refreshed_devices = [
|
||||
device
|
||||
for location in self._client.locations_by_id.values()
|
||||
for device in location.devices_by_id.values()
|
||||
]
|
||||
|
||||
if len(devices) == 0:
|
||||
_LOGGER.error("Failed to find any devices")
|
||||
if len(refreshed_devices) == 0:
|
||||
_LOGGER.error("Failed to find any devices after retry")
|
||||
return False
|
||||
|
||||
self.devices = devices
|
||||
for updated_device in refreshed_devices:
|
||||
if updated_device.deviceid in self.devices:
|
||||
self.devices[updated_device.deviceid] = updated_device
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"New device with ID %s detected, reload the honeywell integration if you want to access it in Home Assistant"
|
||||
)
|
||||
|
||||
await self._hass.config_entries.async_reload(self._config.entry_id)
|
||||
return True
|
||||
|
||||
async def _refresh_devices(self):
|
||||
"""Refresh each enabled device."""
|
||||
for device in self.devices:
|
||||
for device in self.devices.values():
|
||||
await self._hass.async_add_executor_job(device.refresh)
|
||||
await asyncio.sleep(UPDATE_LOOP_SLEEP_TIME)
|
||||
|
||||
@@ -143,11 +149,16 @@ class HoneywellData:
|
||||
) as exp:
|
||||
retries -= 1
|
||||
if retries == 0:
|
||||
_LOGGER.error(
|
||||
"Ran out of retry attempts (3 attempts allocated). Error: %s",
|
||||
exp,
|
||||
)
|
||||
raise exp
|
||||
|
||||
result = await self._retry()
|
||||
|
||||
if not result:
|
||||
_LOGGER.error("Retry result was empty. Error: %s", exp)
|
||||
raise exp
|
||||
|
||||
_LOGGER.error("SomeComfort update failed, Retrying - Error: %s", exp)
|
||||
_LOGGER.info("SomeComfort update failed, retrying. Error: %s", exp)
|
||||
|
||||
@@ -122,7 +122,7 @@ async def async_setup_entry(hass, config, async_add_entities, discovery_info=Non
|
||||
async_add_entities(
|
||||
[
|
||||
HoneywellUSThermostat(data, device, cool_away_temp, heat_away_temp)
|
||||
for device in data.devices
|
||||
for device in data.devices.values()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -3,13 +3,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import client_exceptions
|
||||
from aiohue import HueBridgeV1, HueBridgeV2, LinkButtonNotPressed, Unauthorized
|
||||
from aiohue.errors import AiohueException
|
||||
from aiohue.errors import AiohueException, BridgeBusy
|
||||
import async_timeout
|
||||
|
||||
from homeassistant import core
|
||||
@@ -38,9 +37,6 @@ class HueBridge:
|
||||
self.config_entry = config_entry
|
||||
self.hass = hass
|
||||
self.authorized = False
|
||||
self.parallel_updates_semaphore = asyncio.Semaphore(
|
||||
3 if self.api_version == 1 else 10
|
||||
)
|
||||
# Jobs to be executed when API is reset.
|
||||
self.reset_jobs: list[core.CALLBACK_TYPE] = []
|
||||
self.sensor_manager: SensorManager | None = None
|
||||
@@ -83,6 +79,7 @@ class HueBridge:
|
||||
client_exceptions.ClientOSError,
|
||||
client_exceptions.ServerDisconnectedError,
|
||||
client_exceptions.ContentTypeError,
|
||||
BridgeBusy,
|
||||
) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Error connecting to the Hue bridge at {self.host}"
|
||||
@@ -115,50 +112,19 @@ class HueBridge:
|
||||
async def async_request_call(
|
||||
self, task: Callable, *args, allowed_errors: list[str] | None = None, **kwargs
|
||||
) -> Any:
|
||||
"""Limit parallel requests to Hue hub.
|
||||
|
||||
The Hue hub can only handle a certain amount of parallel requests, total.
|
||||
Although we limit our parallel requests, we still will run into issues because
|
||||
other products are hitting up Hue.
|
||||
|
||||
ClientOSError means hub closed the socket on us.
|
||||
ContentResponseError means hub raised an error.
|
||||
Since we don't make bad requests, this is on them.
|
||||
"""
|
||||
max_tries = 5
|
||||
async with self.parallel_updates_semaphore:
|
||||
for tries in range(max_tries):
|
||||
try:
|
||||
return await task(*args, **kwargs)
|
||||
except AiohueException as err:
|
||||
# The new V2 api is a bit more fanatic with throwing errors
|
||||
# some of which we accept in certain conditions
|
||||
# handle that here. Note that these errors are strings and do not have
|
||||
# an identifier or something.
|
||||
if allowed_errors is not None and str(err) in allowed_errors:
|
||||
# log only
|
||||
self.logger.debug(
|
||||
"Ignored error/warning from Hue API: %s", str(err)
|
||||
)
|
||||
return None
|
||||
raise err
|
||||
except (
|
||||
client_exceptions.ClientOSError,
|
||||
client_exceptions.ClientResponseError,
|
||||
client_exceptions.ServerDisconnectedError,
|
||||
) as err:
|
||||
if tries == max_tries:
|
||||
self.logger.error("Request failed %s times, giving up", tries)
|
||||
raise
|
||||
|
||||
# We only retry if it's a server error. So raise on all 4XX errors.
|
||||
if (
|
||||
isinstance(err, client_exceptions.ClientResponseError)
|
||||
and err.status < HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
):
|
||||
raise
|
||||
|
||||
await asyncio.sleep(HUB_BUSY_SLEEP * tries)
|
||||
"""Send request to the Hue bridge, optionally omitting error(s)."""
|
||||
try:
|
||||
return await task(*args, **kwargs)
|
||||
except AiohueException as err:
|
||||
# The (new) Hue api can be a bit fanatic with throwing errors
|
||||
# some of which we accept in certain conditions
|
||||
# handle that here. Note that these errors are strings and do not have
|
||||
# an identifier or something.
|
||||
if allowed_errors is not None and str(err) in allowed_errors:
|
||||
# log only
|
||||
self.logger.debug("Ignored error/warning from Hue API: %s", str(err))
|
||||
return None
|
||||
raise err
|
||||
|
||||
async def async_reset(self) -> bool:
|
||||
"""Reset this bridge to default state.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Philips Hue",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/hue",
|
||||
"requirements": ["aiohue==3.0.2"],
|
||||
"requirements": ["aiohue==3.0.10"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Royal Philips Electronics",
|
||||
|
||||
@@ -8,6 +8,7 @@ from aiohue.v2.controllers.events import EventType
|
||||
from aiohue.v2.controllers.scenes import ScenesController
|
||||
from aiohue.v2.models.scene import Scene as HueScene
|
||||
|
||||
from homeassistant.components.light import ATTR_TRANSITION
|
||||
from homeassistant.components.scene import Scene as SceneEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -16,6 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from .bridge import HueBridge
|
||||
from .const import DOMAIN
|
||||
from .v2.entity import HueBaseEntity
|
||||
from .v2.helpers import normalize_hue_transition
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -94,11 +96,9 @@ class HueSceneEntity(HueBaseEntity, SceneEntity):
|
||||
|
||||
async def async_activate(self, **kwargs: Any) -> None:
|
||||
"""Activate Hue scene."""
|
||||
transition = kwargs.get("transition")
|
||||
if transition is not None:
|
||||
# hue transition duration is in steps of 100 ms
|
||||
transition = int(transition * 100)
|
||||
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
|
||||
dynamic = kwargs.get("dynamic", self.is_dynamic)
|
||||
|
||||
await self.bridge.async_request_call(
|
||||
self.controller.recall,
|
||||
self.resource.id,
|
||||
|
||||
@@ -146,8 +146,10 @@ async def hue_activate_scene_v2(
|
||||
continue
|
||||
# found match!
|
||||
if transition:
|
||||
transition = transition * 100 # in steps of 100ms
|
||||
await api.scenes.recall(scene.id, dynamic=dynamic, duration=transition)
|
||||
transition = transition * 1000 # transition is in ms
|
||||
await bridge.async_request_call(
|
||||
api.scenes.recall, scene.id, dynamic=dynamic, duration=transition
|
||||
)
|
||||
return True
|
||||
LOGGER.debug(
|
||||
"Unable to find scene %s for group %s on bridge %s",
|
||||
|
||||
@@ -7,6 +7,7 @@ from aiohue.v2.models.button import ButtonEvent
|
||||
from aiohue.v2.models.resource import ResourceTypes
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
|
||||
from homeassistant.components.homeassistant.triggers import event as event_trigger
|
||||
from homeassistant.const import (
|
||||
@@ -35,7 +36,7 @@ if TYPE_CHECKING:
|
||||
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TYPE): str,
|
||||
vol.Required(CONF_SUBTYPE): int,
|
||||
vol.Required(CONF_SUBTYPE): vol.Union(int, str),
|
||||
vol.Optional(CONF_UNIQUE_ID): str,
|
||||
}
|
||||
)
|
||||
@@ -54,6 +55,33 @@ DEVICE_SPECIFIC_EVENT_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
def check_invalid_device_trigger(
|
||||
bridge: HueBridge,
|
||||
config: ConfigType,
|
||||
device_entry: DeviceEntry,
|
||||
automation_info: AutomationTriggerInfo | None = None,
|
||||
):
|
||||
"""Check automation config for deprecated format."""
|
||||
# NOTE: Remove this check after 2022.6
|
||||
if isinstance(config["subtype"], int):
|
||||
return
|
||||
# found deprecated V1 style trigger, notify the user that it should be adjusted
|
||||
msg = (
|
||||
f"Incompatible device trigger detected for "
|
||||
f"[{device_entry.name}](/config/devices/device/{device_entry.id}) "
|
||||
"Please manually fix the outdated automation(s) once to fix this issue."
|
||||
)
|
||||
if automation_info:
|
||||
automation_id = automation_info["variables"]["this"]["attributes"]["id"] # type: ignore
|
||||
msg += f"\n\n[Check it out](/config/automation/edit/{automation_id})."
|
||||
persistent_notification.async_create(
|
||||
bridge.hass,
|
||||
msg,
|
||||
title="Outdated device trigger found",
|
||||
notification_id=f"hue_trigger_{device_entry.id}",
|
||||
)
|
||||
|
||||
|
||||
async def async_validate_trigger_config(
|
||||
bridge: "HueBridge",
|
||||
device_entry: DeviceEntry,
|
||||
@@ -61,6 +89,7 @@ async def async_validate_trigger_config(
|
||||
):
|
||||
"""Validate config."""
|
||||
config = TRIGGER_SCHEMA(config)
|
||||
check_invalid_device_trigger(bridge, config, device_entry)
|
||||
return config
|
||||
|
||||
|
||||
@@ -84,6 +113,7 @@ async def async_attach_trigger(
|
||||
},
|
||||
}
|
||||
)
|
||||
check_invalid_device_trigger(bridge, config, device_entry, automation_info)
|
||||
return await event_trigger.async_attach_trigger(
|
||||
hass, event_config, action, automation_info, platform_type="device"
|
||||
)
|
||||
|
||||
@@ -47,6 +47,9 @@ class HueBaseEntity(Entity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.device.id)},
|
||||
)
|
||||
# used for availability workaround
|
||||
self._ignore_availability = None
|
||||
self._last_state = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -68,6 +71,7 @@ class HueBaseEntity(Entity):
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added."""
|
||||
self._check_availability_workaround()
|
||||
# Add value_changed callbacks.
|
||||
self.async_on_remove(
|
||||
self.controller.subscribe(
|
||||
@@ -98,11 +102,13 @@ class HueBaseEntity(Entity):
|
||||
def available(self) -> bool:
|
||||
"""Return entity availability."""
|
||||
if self.device is None:
|
||||
# devices without a device attached should be always available
|
||||
# entities without a device attached should be always available
|
||||
return True
|
||||
if self.resource.type == ResourceTypes.ZIGBEE_CONNECTIVITY:
|
||||
# the zigbee connectivity sensor itself should be always available
|
||||
return True
|
||||
if self._ignore_availability:
|
||||
return True
|
||||
if zigbee := self.bridge.api.devices.get_zigbee_connectivity(self.device.id):
|
||||
# all device-attached entities get availability from the zigbee connectivity
|
||||
return zigbee.status == ConnectivityServiceStatus.CONNECTED
|
||||
@@ -124,5 +130,53 @@ class HueBaseEntity(Entity):
|
||||
ent_reg.async_remove(self.entity_id)
|
||||
else:
|
||||
self.logger.debug("Received status update for %s", self.entity_id)
|
||||
self._check_availability_workaround()
|
||||
self.on_update()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _check_availability_workaround(self):
|
||||
"""Check availability of the device."""
|
||||
if self.resource.type != ResourceTypes.LIGHT:
|
||||
return
|
||||
if self._ignore_availability is not None:
|
||||
# already processed
|
||||
return
|
||||
if self.device.product_data.certified:
|
||||
# certified products report their state correctly
|
||||
self._ignore_availability = False
|
||||
# some (3th party) Hue lights report their connection status incorrectly
|
||||
# causing the zigbee availability to report as disconnected while in fact
|
||||
# it can be controlled. Although this is in fact something the device manufacturer
|
||||
# should fix, we work around it here. If the light is reported unavailable
|
||||
# by the zigbee connectivity but the state changes its considered as a
|
||||
# malfunctioning device and we report it.
|
||||
# while the user should actually fix this issue instead of ignoring it, we
|
||||
# ignore the availability for this light from this point.
|
||||
cur_state = self.resource.on.on
|
||||
if self._last_state is None:
|
||||
self._last_state = cur_state
|
||||
return
|
||||
if zigbee := self.bridge.api.devices.get_zigbee_connectivity(self.device.id):
|
||||
if (
|
||||
self._last_state != cur_state
|
||||
and zigbee.status != ConnectivityServiceStatus.CONNECTED
|
||||
):
|
||||
# the device state changed from on->off or off->on
|
||||
# while it was reported as not connected!
|
||||
self.logger.warning(
|
||||
"Light %s changed state while reported as disconnected. "
|
||||
"This might be an indicator that routing is not working for this device. "
|
||||
"Home Assistant will ignore availability for this light from now on. "
|
||||
"Device details: %s - %s (%s) fw: %s",
|
||||
self.name,
|
||||
self.device.product_data.manufacturer_name,
|
||||
self.device.product_data.product_name,
|
||||
self.device.product_data.model_id,
|
||||
self.device.product_data.software_version,
|
||||
)
|
||||
# do we want to store this in some persistent storage?
|
||||
self._ignore_availability = True
|
||||
else:
|
||||
self._ignore_availability = False
|
||||
self._last_state = cur_state
|
||||
|
||||
@@ -6,16 +6,19 @@ from typing import Any
|
||||
from aiohue.v2 import HueBridgeV2
|
||||
from aiohue.v2.controllers.events import EventType
|
||||
from aiohue.v2.controllers.groups import GroupedLight, Room, Zone
|
||||
from aiohue.v2.models.feature import AlertEffectType
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP,
|
||||
ATTR_FLASH,
|
||||
ATTR_TRANSITION,
|
||||
ATTR_XY_COLOR,
|
||||
COLOR_MODE_BRIGHTNESS,
|
||||
COLOR_MODE_COLOR_TEMP,
|
||||
COLOR_MODE_ONOFF,
|
||||
COLOR_MODE_XY,
|
||||
SUPPORT_FLASH,
|
||||
SUPPORT_TRANSITION,
|
||||
LightEntity,
|
||||
)
|
||||
@@ -24,14 +27,16 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from ..bridge import HueBridge
|
||||
from ..const import DOMAIN
|
||||
from ..const import CONF_ALLOW_HUE_GROUPS, DOMAIN
|
||||
from .entity import HueBaseEntity
|
||||
from .helpers import normalize_hue_brightness, normalize_hue_transition
|
||||
|
||||
ALLOWED_ERRORS = [
|
||||
"device (groupedLight) has communication issues, command (on) may not have effect",
|
||||
'device (groupedLight) is "soft off", command (on) may not have effect',
|
||||
"device (light) has communication issues, command (on) may not have effect",
|
||||
'device (light) is "soft off", command (on) may not have effect',
|
||||
"attribute (supportedAlertActions) cannot be written",
|
||||
]
|
||||
|
||||
|
||||
@@ -76,8 +81,6 @@ async def async_setup_entry(
|
||||
class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
"""Representation of a Grouped Hue light."""
|
||||
|
||||
# Entities for Hue groups are disabled by default
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_attr_icon = "mdi:lightbulb-group"
|
||||
|
||||
def __init__(
|
||||
@@ -90,8 +93,15 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
self.group = group
|
||||
self.controller = controller
|
||||
self.api: HueBridgeV2 = bridge.api
|
||||
self._attr_supported_features |= SUPPORT_FLASH
|
||||
self._attr_supported_features |= SUPPORT_TRANSITION
|
||||
|
||||
# Entities for Hue groups are disabled by default
|
||||
# unless they were enabled in old version (legacy option)
|
||||
self._attr_entity_registry_enabled_default = bridge.config_entry.data.get(
|
||||
CONF_ALLOW_HUE_GROUPS, False
|
||||
)
|
||||
|
||||
self._update_values()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
@@ -138,16 +148,11 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
transition = kwargs.get(ATTR_TRANSITION)
|
||||
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
|
||||
xy_color = kwargs.get(ATTR_XY_COLOR)
|
||||
color_temp = kwargs.get(ATTR_COLOR_TEMP)
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
||||
if brightness is not None:
|
||||
# Hue uses a range of [0, 100] to control brightness.
|
||||
brightness = float((brightness / 255) * 100)
|
||||
if transition is not None:
|
||||
# hue transition duration is in steps of 100 ms
|
||||
transition = int(transition * 100)
|
||||
brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS))
|
||||
flash = kwargs.get(ATTR_FLASH)
|
||||
|
||||
# NOTE: a grouped_light can only handle turn on/off
|
||||
# To set other features, you'll have to control the attached lights
|
||||
@@ -156,6 +161,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
and xy_color is None
|
||||
and color_temp is None
|
||||
and transition is None
|
||||
and flash is None
|
||||
):
|
||||
await self.bridge.async_request_call(
|
||||
self.controller.set_state,
|
||||
@@ -176,17 +182,34 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
color_xy=xy_color if light.supports_color else None,
|
||||
color_temp=color_temp if light.supports_color_temperature else None,
|
||||
transition_time=transition,
|
||||
alert=AlertEffectType.BREATHE if flash is not None else None,
|
||||
allowed_errors=ALLOWED_ERRORS,
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
await self.bridge.async_request_call(
|
||||
self.controller.set_state,
|
||||
id=self.resource.id,
|
||||
on=False,
|
||||
allowed_errors=ALLOWED_ERRORS,
|
||||
)
|
||||
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
|
||||
|
||||
# NOTE: a grouped_light can only handle turn on/off
|
||||
# To set other features, you'll have to control the attached lights
|
||||
if transition is None:
|
||||
await self.bridge.async_request_call(
|
||||
self.controller.set_state,
|
||||
id=self.resource.id,
|
||||
on=False,
|
||||
allowed_errors=ALLOWED_ERRORS,
|
||||
)
|
||||
return
|
||||
|
||||
# redirect all other feature commands to underlying lights
|
||||
for light in self.controller.get_lights(self.resource.id):
|
||||
await self.bridge.async_request_call(
|
||||
self.api.lights.set_state,
|
||||
light.id,
|
||||
on=False,
|
||||
transition_time=transition,
|
||||
allowed_errors=ALLOWED_ERRORS,
|
||||
)
|
||||
|
||||
@callback
|
||||
def on_update(self) -> None:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Helper functions for Philips Hue v2."""
|
||||
|
||||
|
||||
def normalize_hue_brightness(brightness):
|
||||
"""Return calculated brightness values."""
|
||||
if brightness is not None:
|
||||
# Hue uses a range of [0, 100] to control brightness.
|
||||
brightness = float((brightness / 255) * 100)
|
||||
|
||||
return brightness
|
||||
|
||||
|
||||
def normalize_hue_transition(transition):
|
||||
"""Return rounded transition values."""
|
||||
if transition is not None:
|
||||
# hue transition duration is in milliseconds and round them to 100ms
|
||||
transition = int(round(transition, 1) * 1000)
|
||||
|
||||
return transition
|
||||
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from aiohue.v2 import HueBridgeV2
|
||||
from aiohue.v2.controllers.events import EventType
|
||||
from aiohue.v2.models.button import Button
|
||||
from aiohue.v2.models.button import Button, ButtonEvent
|
||||
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_ID, CONF_TYPE, CONF_UNIQUE_ID
|
||||
from homeassistant.core import callback
|
||||
@@ -27,6 +27,11 @@ async def async_setup_hue_events(bridge: "HueBridge"):
|
||||
api: HueBridgeV2 = bridge.api # to satisfy typing
|
||||
conf_entry = bridge.config_entry
|
||||
dev_reg = device_registry.async_get(hass)
|
||||
last_state = {
|
||||
x.id: x.button.last_event
|
||||
for x in api.sensors.button.items
|
||||
if x.button is not None
|
||||
}
|
||||
|
||||
# at this time the `button` resource is the only source of hue events
|
||||
btn_controller = api.sensors.button
|
||||
@@ -35,6 +40,21 @@ async def async_setup_hue_events(bridge: "HueBridge"):
|
||||
def handle_button_event(evt_type: EventType, hue_resource: Button) -> None:
|
||||
"""Handle event from Hue devices controller."""
|
||||
LOGGER.debug("Received button event: %s", hue_resource)
|
||||
|
||||
# guard for missing button object on the resource
|
||||
if hue_resource.button is None:
|
||||
return
|
||||
|
||||
cur_event = hue_resource.button.last_event
|
||||
last_event = last_state.get(hue_resource.id)
|
||||
# ignore the event if the last_event value is exactly the same
|
||||
# this may happen if some other metadata of the button resource is adjusted
|
||||
if cur_event == last_event:
|
||||
return
|
||||
if cur_event != ButtonEvent.REPEAT:
|
||||
# do not store repeat event
|
||||
last_state[hue_resource.id] = cur_event
|
||||
|
||||
hue_device = btn_controller.get_device(hue_resource.id)
|
||||
device = dev_reg.async_get_device({(DOMAIN, hue_device.id)})
|
||||
|
||||
@@ -44,7 +64,7 @@ async def async_setup_hue_events(bridge: "HueBridge"):
|
||||
CONF_ID: slugify(f"{hue_device.metadata.name}: Button"),
|
||||
CONF_DEVICE_ID: device.id, # type: ignore
|
||||
CONF_UNIQUE_ID: hue_resource.id,
|
||||
CONF_TYPE: hue_resource.button.last_event.value,
|
||||
CONF_TYPE: cur_event.value,
|
||||
CONF_SUBTYPE: hue_resource.metadata.control_id,
|
||||
}
|
||||
hass.bus.async_fire(ATTR_HUE_EVENT, data)
|
||||
|
||||
@@ -6,17 +6,20 @@ from typing import Any
|
||||
from aiohue import HueBridgeV2
|
||||
from aiohue.v2.controllers.events import EventType
|
||||
from aiohue.v2.controllers.lights import LightsController
|
||||
from aiohue.v2.models.feature import AlertEffectType
|
||||
from aiohue.v2.models.light import Light
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP,
|
||||
ATTR_FLASH,
|
||||
ATTR_TRANSITION,
|
||||
ATTR_XY_COLOR,
|
||||
COLOR_MODE_BRIGHTNESS,
|
||||
COLOR_MODE_COLOR_TEMP,
|
||||
COLOR_MODE_ONOFF,
|
||||
COLOR_MODE_XY,
|
||||
SUPPORT_FLASH,
|
||||
SUPPORT_TRANSITION,
|
||||
LightEntity,
|
||||
)
|
||||
@@ -27,10 +30,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from ..bridge import HueBridge
|
||||
from ..const import DOMAIN
|
||||
from .entity import HueBaseEntity
|
||||
from .helpers import normalize_hue_brightness, normalize_hue_transition
|
||||
|
||||
ALLOWED_ERRORS = [
|
||||
"device (light) has communication issues, command (on) may not have effect",
|
||||
'device (light) is "soft off", command (on) may not have effect',
|
||||
"attribute (supportedAlertActions) cannot be written",
|
||||
]
|
||||
|
||||
|
||||
@@ -68,6 +73,7 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
) -> None:
|
||||
"""Initialize the light."""
|
||||
super().__init__(bridge, controller, resource)
|
||||
self._attr_supported_features |= SUPPORT_FLASH
|
||||
self.resource = resource
|
||||
self.controller = controller
|
||||
self._supported_color_modes = set()
|
||||
@@ -150,16 +156,11 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the device on."""
|
||||
transition = kwargs.get(ATTR_TRANSITION)
|
||||
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
|
||||
xy_color = kwargs.get(ATTR_XY_COLOR)
|
||||
color_temp = kwargs.get(ATTR_COLOR_TEMP)
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
||||
if brightness is not None:
|
||||
# Hue uses a range of [0, 100] to control brightness.
|
||||
brightness = float((brightness / 255) * 100)
|
||||
if transition is not None:
|
||||
# hue transition duration is in steps of 100 ms
|
||||
transition = int(transition * 100)
|
||||
brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS))
|
||||
flash = kwargs.get(ATTR_FLASH)
|
||||
|
||||
await self.bridge.async_request_call(
|
||||
self.controller.set_state,
|
||||
@@ -169,19 +170,20 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
color_xy=xy_color,
|
||||
color_temp=color_temp,
|
||||
transition_time=transition,
|
||||
alert=AlertEffectType.BREATHE if flash is not None else None,
|
||||
allowed_errors=ALLOWED_ERRORS,
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
transition = kwargs.get(ATTR_TRANSITION)
|
||||
if transition is not None:
|
||||
# hue transition duration is in steps of 100 ms
|
||||
transition = int(transition * 100)
|
||||
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
|
||||
flash = kwargs.get(ATTR_FLASH)
|
||||
|
||||
await self.bridge.async_request_call(
|
||||
self.controller.set_state,
|
||||
id=self.resource.id,
|
||||
on=False,
|
||||
transition_time=transition,
|
||||
alert=AlertEffectType.BREATHE if flash is not None else None,
|
||||
allowed_errors=ALLOWED_ERRORS,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "hunterdouglas_powerview",
|
||||
"name": "Hunter Douglas PowerView",
|
||||
"documentation": "https://www.home-assistant.io/integrations/hunterdouglas_powerview",
|
||||
"requirements": ["aiopvapi==1.6.14"],
|
||||
"requirements": ["aiopvapi==1.6.19"],
|
||||
"codeowners": ["@bdraco"],
|
||||
"config_flow": true,
|
||||
"homekit": {
|
||||
|
||||
@@ -29,6 +29,7 @@ from homeassistant.const import (
|
||||
CONF_PORT,
|
||||
CONF_TYPE,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
@@ -44,6 +45,7 @@ from .const import (
|
||||
CONF_KNX_INDIVIDUAL_ADDRESS,
|
||||
CONF_KNX_ROUTING,
|
||||
CONF_KNX_TUNNELING,
|
||||
DATA_HASS_CONFIG,
|
||||
DATA_KNX_CONFIG,
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
@@ -195,6 +197,7 @@ SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA = vol.Any(
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Start the KNX integration."""
|
||||
hass.data[DATA_HASS_CONFIG] = config
|
||||
conf: ConfigType | None = config.get(DOMAIN)
|
||||
|
||||
if conf is None:
|
||||
@@ -251,15 +254,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
|
||||
hass.config_entries.async_setup_platforms(
|
||||
entry, [platform for platform in SUPPORTED_PLATFORMS if platform in config]
|
||||
entry,
|
||||
[
|
||||
platform
|
||||
for platform in SUPPORTED_PLATFORMS
|
||||
if platform in config and platform is not Platform.NOTIFY
|
||||
],
|
||||
)
|
||||
|
||||
# set up notify platform, no entry support for notify component yet,
|
||||
# have to use discovery to load platform.
|
||||
if NotifySchema.PLATFORM in conf:
|
||||
# set up notify platform, no entry support for notify component yet
|
||||
if NotifySchema.PLATFORM in config:
|
||||
hass.async_create_task(
|
||||
discovery.async_load_platform(
|
||||
hass, "notify", DOMAIN, conf[NotifySchema.PLATFORM], config
|
||||
hass, "notify", DOMAIN, {}, hass.data[DATA_HASS_CONFIG]
|
||||
)
|
||||
)
|
||||
|
||||
@@ -312,6 +319,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
platform
|
||||
for platform in SUPPORTED_PLATFORMS
|
||||
if platform in hass.data[DATA_KNX_CONFIG]
|
||||
and platform is not Platform.NOTIFY
|
||||
],
|
||||
)
|
||||
if unload_ok:
|
||||
@@ -383,6 +391,7 @@ class KNXModule:
|
||||
if _conn_type == CONF_KNX_ROUTING:
|
||||
return ConnectionConfig(
|
||||
connection_type=ConnectionType.ROUTING,
|
||||
local_ip=self.config.get(ConnectionSchema.CONF_KNX_LOCAL_IP),
|
||||
auto_reconnect=True,
|
||||
)
|
||||
if _conn_type == CONF_KNX_TUNNELING:
|
||||
|
||||
@@ -137,9 +137,11 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
vol.Required(
|
||||
ConnectionSchema.CONF_KNX_ROUTE_BACK, default=False
|
||||
): vol.Coerce(bool),
|
||||
vol.Optional(ConnectionSchema.CONF_KNX_LOCAL_IP): str,
|
||||
}
|
||||
|
||||
if self.show_advanced_options:
|
||||
fields[vol.Optional(ConnectionSchema.CONF_KNX_LOCAL_IP)] = str
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="manual_tunnel", data_schema=vol.Schema(fields), errors=errors
|
||||
)
|
||||
@@ -195,6 +197,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
CONF_KNX_INDIVIDUAL_ADDRESS: user_input[
|
||||
CONF_KNX_INDIVIDUAL_ADDRESS
|
||||
],
|
||||
ConnectionSchema.CONF_KNX_LOCAL_IP: user_input.get(
|
||||
ConnectionSchema.CONF_KNX_LOCAL_IP
|
||||
),
|
||||
CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING,
|
||||
},
|
||||
)
|
||||
@@ -211,6 +216,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
): cv.port,
|
||||
}
|
||||
|
||||
if self.show_advanced_options:
|
||||
fields[vol.Optional(ConnectionSchema.CONF_KNX_LOCAL_IP)] = str
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="routing", data_schema=vol.Schema(fields), errors=errors
|
||||
)
|
||||
@@ -306,7 +314,6 @@ class KNXOptionsFlowHandler(OptionsFlow):
|
||||
vol.Required(
|
||||
CONF_PORT, default=self.current_config.get(CONF_PORT, 3671)
|
||||
): cv.port,
|
||||
vol.Optional(ConnectionSchema.CONF_KNX_LOCAL_IP): str,
|
||||
vol.Required(
|
||||
ConnectionSchema.CONF_KNX_ROUTE_BACK,
|
||||
default=self.current_config.get(
|
||||
@@ -381,6 +388,14 @@ class KNXOptionsFlowHandler(OptionsFlow):
|
||||
}
|
||||
|
||||
if self.show_advanced_options:
|
||||
data_schema[
|
||||
vol.Optional(
|
||||
ConnectionSchema.CONF_KNX_LOCAL_IP,
|
||||
default=self.current_config.get(
|
||||
ConnectionSchema.CONF_KNX_LOCAL_IP,
|
||||
),
|
||||
)
|
||||
] = str
|
||||
data_schema[
|
||||
vol.Required(
|
||||
ConnectionSchema.CONF_KNX_STATE_UPDATER,
|
||||
|
||||
@@ -42,7 +42,10 @@ CONF_STATE_ADDRESS: Final = "state_address"
|
||||
CONF_SYNC_STATE: Final = "sync_state"
|
||||
CONF_KNX_INITIAL_CONNECTION_TYPES: Final = [CONF_KNX_TUNNELING, CONF_KNX_ROUTING]
|
||||
|
||||
# yaml config merged with config entry data
|
||||
DATA_KNX_CONFIG: Final = "knx_config"
|
||||
# original hass yaml config
|
||||
DATA_HASS_CONFIG: Final = "knx_hass_config"
|
||||
|
||||
ATTR_COUNTER: Final = "counter"
|
||||
ATTR_SOURCE: Final = "source"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/knx",
|
||||
"requirements": [
|
||||
"xknx==0.18.13"
|
||||
"xknx==0.18.15"
|
||||
],
|
||||
"codeowners": [
|
||||
"@Julius2342",
|
||||
|
||||
@@ -11,7 +11,8 @@ from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import DOMAIN, KNX_ADDRESS
|
||||
from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
|
||||
from .schema import NotifySchema
|
||||
|
||||
|
||||
async def async_get_service(
|
||||
@@ -20,24 +21,28 @@ async def async_get_service(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> KNXNotificationService | None:
|
||||
"""Get the KNX notification service."""
|
||||
if not discovery_info:
|
||||
if discovery_info is None:
|
||||
return None
|
||||
|
||||
platform_config: dict = discovery_info
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
if platform_config := hass.data[DATA_KNX_CONFIG].get(NotifySchema.PLATFORM):
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
|
||||
notification_devices = []
|
||||
for device_config in platform_config:
|
||||
notification_devices.append(
|
||||
XknxNotification(
|
||||
xknx,
|
||||
name=device_config[CONF_NAME],
|
||||
group_address=device_config[KNX_ADDRESS],
|
||||
notification_devices = []
|
||||
for device_config in platform_config:
|
||||
notification_devices.append(
|
||||
XknxNotification(
|
||||
xknx,
|
||||
name=device_config[CONF_NAME],
|
||||
group_address=device_config[KNX_ADDRESS],
|
||||
)
|
||||
)
|
||||
return (
|
||||
KNXNotificationService(notification_devices)
|
||||
if notification_devices
|
||||
else None
|
||||
)
|
||||
return (
|
||||
KNXNotificationService(notification_devices) if notification_devices else None
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class KNXNotificationService(BaseNotificationService):
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"data": {
|
||||
"individual_address": "Individual address for the routing connection",
|
||||
"multicast_group": "The multicast group used for routing",
|
||||
"multicast_port": "The multicast port used for routing"
|
||||
"multicast_port": "The multicast port used for routing",
|
||||
"local_ip": "Local IP (leave empty if unsure)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -48,6 +49,7 @@
|
||||
"individual_address": "Default individual address",
|
||||
"multicast_group": "Multicast group used for routing and discovery",
|
||||
"multicast_port": "Multicast port used for routing and discovery",
|
||||
"local_ip": "Local IP (leave empty if unsure)",
|
||||
"state_updater": "Globally enable reading states from the KNX Bus",
|
||||
"rate_limit": "Maximum outgoing telegrams per second"
|
||||
}
|
||||
@@ -56,8 +58,7 @@
|
||||
"data": {
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"route_back": "Route Back / NAT Mode",
|
||||
"local_ip": "Local IP (leave empty if unsure)"
|
||||
"route_back": "Route Back / NAT Mode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"data": {
|
||||
"individual_address": "Individual address for the routing connection",
|
||||
"multicast_group": "The multicast group used for routing",
|
||||
"multicast_port": "The multicast port used for routing"
|
||||
"multicast_port": "The multicast port used for routing",
|
||||
"local_ip": "Local IP (leave empty if unsure)"
|
||||
},
|
||||
"description": "Please configure the routing options."
|
||||
},
|
||||
@@ -48,6 +49,7 @@
|
||||
"individual_address": "Default individual address",
|
||||
"multicast_group": "Multicast group used for routing and discovery",
|
||||
"multicast_port": "Multicast port used for routing and discovery",
|
||||
"local_ip": "Local IP (leave empty if unsure)",
|
||||
"rate_limit": "Maximum outgoing telegrams per second",
|
||||
"state_updater": "Globally enable reading states from the KNX Bus"
|
||||
}
|
||||
@@ -55,7 +57,6 @@
|
||||
"tunnel": {
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"local_ip": "Local IP (leave empty if unsure)",
|
||||
"port": "Port",
|
||||
"route_back": "Route Back / NAT Mode"
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ def log_entry(hass, name, message, domain=None, entity_id=None, context=None):
|
||||
hass.add_job(async_log_entry, hass, name, message, domain, entity_id, context)
|
||||
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def async_log_entry(hass, name, message, domain=None, entity_id=None, context=None):
|
||||
"""Add an entry to the logbook."""
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"disabled": "Library has incompatible requirements.",
|
||||
"domain": "lupusec",
|
||||
"name": "Lupus Electronics LUPUSEC",
|
||||
"documentation": "https://www.home-assistant.io/integrations/lupusec",
|
||||
"requirements": ["lupupy==0.0.21"],
|
||||
"requirements": ["lupupy==0.0.24"],
|
||||
"codeowners": ["@majuss"],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
@@ -30,7 +31,11 @@ from homeassistant.helpers.update_coordinator import (
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .api import ConfigEntryLyricClient, LyricLocalOAuth2Implementation
|
||||
from .api import (
|
||||
ConfigEntryLyricClient,
|
||||
LyricLocalOAuth2Implementation,
|
||||
OAuth2SessionLyric,
|
||||
)
|
||||
from .config_flow import OAuth2FlowHandler
|
||||
from .const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||
|
||||
@@ -84,21 +89,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
|
||||
session = aiohttp_client.async_get_clientsession(hass)
|
||||
oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
oauth_session = OAuth2SessionLyric(hass, entry, implementation)
|
||||
|
||||
client = ConfigEntryLyricClient(session, oauth_session)
|
||||
|
||||
client_id = hass.data[DOMAIN][CONF_CLIENT_ID]
|
||||
lyric = Lyric(client, client_id)
|
||||
|
||||
async def async_update_data() -> Lyric:
|
||||
async def async_update_data(force_refresh_token: bool = False) -> Lyric:
|
||||
"""Fetch data from Lyric."""
|
||||
await oauth_session.async_ensure_token_valid()
|
||||
try:
|
||||
if not force_refresh_token:
|
||||
await oauth_session.async_ensure_token_valid()
|
||||
else:
|
||||
await oauth_session.force_refresh_token()
|
||||
except ClientResponseError as exception:
|
||||
if exception.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
|
||||
raise ConfigEntryAuthFailed from exception
|
||||
raise UpdateFailed(exception) from exception
|
||||
|
||||
try:
|
||||
async with async_timeout.timeout(60):
|
||||
await lyric.get_locations()
|
||||
return lyric
|
||||
except LyricAuthenticationException as exception:
|
||||
# Attempt to refresh the token before failing.
|
||||
# Honeywell appear to have issues keeping tokens saved.
|
||||
_LOGGER.debug("Authentication failed. Attempting to refresh token")
|
||||
if not force_refresh_token:
|
||||
return await async_update_data(force_refresh_token=True)
|
||||
raise ConfigEntryAuthFailed from exception
|
||||
except (LyricException, ClientResponseError) as exception:
|
||||
raise UpdateFailed(exception) from exception
|
||||
|
||||
@@ -8,6 +8,18 @@ from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
|
||||
class OAuth2SessionLyric(config_entry_oauth2_flow.OAuth2Session):
|
||||
"""OAuth2Session for Lyric."""
|
||||
|
||||
async def force_refresh_token(self) -> None:
|
||||
"""Force a token refresh."""
|
||||
new_token = await self.implementation.async_refresh_token(self.token)
|
||||
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry, data={**self.config_entry.data, "token": new_token}
|
||||
)
|
||||
|
||||
|
||||
class ConfigEntryLyricClient(LyricClient):
|
||||
"""Provide Honeywell Lyric authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "MELCloud",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/melcloud",
|
||||
"requirements": ["pymelcloud==2.5.5"],
|
||||
"requirements": ["pymelcloud==2.5.6"],
|
||||
"codeowners": ["@vilppuvuorinen"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["ffmpeg", "http", "media_source"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/nest",
|
||||
"requirements": ["python-nest==4.1.0", "google-nest-sdm==0.4.5"],
|
||||
"requirements": ["python-nest==4.1.0", "google-nest-sdm==0.4.9"],
|
||||
"codeowners": ["@allenporter"],
|
||||
"quality_scale": "platinum",
|
||||
"dhcp": [
|
||||
|
||||
@@ -24,7 +24,7 @@ import logging
|
||||
|
||||
from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait
|
||||
from google_nest_sdm.device import Device
|
||||
from google_nest_sdm.event import ImageEventBase
|
||||
from google_nest_sdm.event import EventImageType, ImageEventBase
|
||||
|
||||
from homeassistant.components.media_player.const import (
|
||||
MEDIA_CLASS_DIRECTORY,
|
||||
@@ -63,6 +63,9 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
|
||||
|
||||
async def get_media_source_devices(hass: HomeAssistant) -> Mapping[str, Device]:
|
||||
"""Return a mapping of device id to eligible Nest event media devices."""
|
||||
if DATA_SUBSCRIBER not in hass.data[DOMAIN]:
|
||||
# Integration unloaded, or is legacy nest integration
|
||||
return {}
|
||||
subscriber = hass.data[DOMAIN][DATA_SUBSCRIBER]
|
||||
device_manager = await subscriber.async_get_device_manager()
|
||||
device_registry = await hass.helpers.device_registry.async_get_registry()
|
||||
@@ -253,7 +256,7 @@ def _browse_event(
|
||||
event_name=MEDIA_SOURCE_EVENT_TITLE_MAP.get(event.event_type, "Event"),
|
||||
event_time=dt_util.as_local(event.timestamp).strftime(DATE_STR_FORMAT),
|
||||
),
|
||||
can_play=True,
|
||||
can_play=(event.event_image_type == EventImageType.CLIP_PREVIEW),
|
||||
can_expand=False,
|
||||
thumbnail=None,
|
||||
children=[],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "netgear",
|
||||
"name": "NETGEAR",
|
||||
"documentation": "https://www.home-assistant.io/integrations/netgear",
|
||||
"requirements": ["pynetgear==0.7.0"],
|
||||
"requirements": ["pynetgear==0.8.0"],
|
||||
"codeowners": ["@hacf-fr", "@Quentame", "@starkillerOG"],
|
||||
"iot_class": "local_polling",
|
||||
"config_flow": true,
|
||||
|
||||
@@ -11,6 +11,7 @@ from nexia.const import (
|
||||
SYSTEM_STATUS_IDLE,
|
||||
UNIT_FAHRENHEIT,
|
||||
)
|
||||
from nexia.util import find_humidity_setpoint
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import ClimateEntity
|
||||
@@ -58,6 +59,8 @@ from .coordinator import NexiaDataUpdateCoordinator
|
||||
from .entity import NexiaThermostatZoneEntity
|
||||
from .util import percent_conv
|
||||
|
||||
PARALLEL_UPDATES = 1 # keep data in sync with only one connection at a time
|
||||
|
||||
SERVICE_SET_AIRCLEANER_MODE = "set_aircleaner_mode"
|
||||
SERVICE_SET_HUMIDIFY_SETPOINT = "set_humidify_setpoint"
|
||||
SERVICE_SET_HVAC_RUN_MODE = "set_hvac_run_mode"
|
||||
@@ -231,9 +234,9 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity):
|
||||
def set_humidity(self, humidity):
|
||||
"""Dehumidify target."""
|
||||
if self._thermostat.has_dehumidify_support():
|
||||
self._thermostat.set_dehumidify_setpoint(humidity / 100.0)
|
||||
self.set_dehumidify_setpoint(humidity)
|
||||
else:
|
||||
self._thermostat.set_humidify_setpoint(humidity / 100.0)
|
||||
self.set_humidify_setpoint(humidity)
|
||||
self._signal_thermostat_update()
|
||||
|
||||
@property
|
||||
@@ -453,7 +456,22 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity):
|
||||
|
||||
def set_humidify_setpoint(self, humidity):
|
||||
"""Set the humidify setpoint."""
|
||||
self._thermostat.set_humidify_setpoint(humidity / 100.0)
|
||||
target_humidity = find_humidity_setpoint(humidity / 100.0)
|
||||
if self._thermostat.get_humidify_setpoint() == target_humidity:
|
||||
# Trying to set the humidify setpoint to the
|
||||
# same value will cause the api to timeout
|
||||
return
|
||||
self._thermostat.set_humidify_setpoint(target_humidity)
|
||||
self._signal_thermostat_update()
|
||||
|
||||
def set_dehumidify_setpoint(self, humidity):
|
||||
"""Set the dehumidify setpoint."""
|
||||
target_humidity = find_humidity_setpoint(humidity / 100.0)
|
||||
if self._thermostat.get_dehumidify_setpoint() == target_humidity:
|
||||
# Trying to set the dehumidify setpoint to the
|
||||
# same value will cause the api to timeout
|
||||
return
|
||||
self._thermostat.set_dehumidify_setpoint(target_humidity)
|
||||
self._signal_thermostat_update()
|
||||
|
||||
def _signal_thermostat_update(self):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "nexia",
|
||||
"name": "Nexia/American Standard/Trane",
|
||||
"requirements": ["nexia==0.9.11"],
|
||||
"requirements": ["nexia==0.9.12"],
|
||||
"codeowners": ["@bdraco"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/nexia",
|
||||
"config_flow": true,
|
||||
|
||||
@@ -214,7 +214,7 @@ class NextBusDepartureSensor(SensorEntity):
|
||||
|
||||
# Generate list of upcoming times
|
||||
self._attributes["upcoming"] = ", ".join(
|
||||
sorted(p["minutes"] for p in predictions)
|
||||
sorted((p["minutes"] for p in predictions), key=int)
|
||||
)
|
||||
|
||||
latest_prediction = maybe_first(predictions)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""The 1-Wire component."""
|
||||
import logging
|
||||
|
||||
from pyownet import protocol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -18,7 +20,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
onewirehub = OneWireHub(hass)
|
||||
try:
|
||||
await onewirehub.initialize(entry)
|
||||
except CannotConnect as exc:
|
||||
except (
|
||||
CannotConnect, # Failed to connect to the server
|
||||
protocol.OwnetError, # Connected to server, but failed to list the devices
|
||||
) as exc:
|
||||
raise ConfigEntryNotReady() from exc
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = onewirehub
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""The pvpc_hourly_pricing integration to collect Spain official electric prices."""
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Mapping
|
||||
|
||||
from aiopvpc import DEFAULT_POWER_KW, TARIFFS
|
||||
from aiopvpc import DEFAULT_POWER_KW, TARIFFS, PVPCData
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_registry import (
|
||||
EntityRegistry,
|
||||
@@ -14,6 +17,8 @@ from homeassistant.helpers.entity_registry import (
|
||||
async_migrate_entries,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
ATTR_POWER,
|
||||
@@ -99,6 +104,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await hass.config_entries.async_remove(entry.entry_id)
|
||||
return False
|
||||
|
||||
coordinator = ElecPricesDataUpdateCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
return True
|
||||
@@ -119,4 +128,39 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
|
||||
|
||||
class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[Mapping[datetime, float]]):
|
||||
"""Class to manage fetching Electricity prices data from API."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Initialize."""
|
||||
self.api = PVPCData(
|
||||
session=async_get_clientsession(hass),
|
||||
tariff=entry.data[ATTR_TARIFF],
|
||||
local_timezone=hass.config.time_zone,
|
||||
power=entry.data[ATTR_POWER],
|
||||
power_valley=entry.data[ATTR_POWER_P3],
|
||||
)
|
||||
super().__init__(
|
||||
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30)
|
||||
)
|
||||
self._entry = entry
|
||||
|
||||
@property
|
||||
def entry_id(self) -> str:
|
||||
"""Return entry ID."""
|
||||
return self._entry.entry_id
|
||||
|
||||
async def _async_update_data(self) -> Mapping[datetime, float]:
|
||||
"""Update electricity prices from the ESIOS API."""
|
||||
prices = await self.api.async_update_prices(dt_util.utcnow())
|
||||
self.api.process_state_and_attributes(dt_util.utcnow())
|
||||
if not prices:
|
||||
raise UpdateFailed
|
||||
|
||||
return prices
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Spain electricity hourly pricing (PVPC)",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/pvpc_hourly_pricing",
|
||||
"requirements": ["aiopvpc==2.2.4"],
|
||||
"requirements": ["aiopvpc==3.0.0"],
|
||||
"codeowners": ["@azogue"],
|
||||
"quality_scale": "platinum",
|
||||
"iot_class": "cloud_polling"
|
||||
|
||||
@@ -1,73 +1,160 @@
|
||||
"""Sensor to collect the reference daily prices of electricity ('PVPC') in Spain."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from random import randint
|
||||
from typing import Any
|
||||
|
||||
from aiopvpc import PVPCData
|
||||
|
||||
from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, CURRENCY_EURO, ENERGY_KILO_WATT_HOUR
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later, async_track_time_change
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.helpers.event import async_track_time_change
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF
|
||||
from . import ElecPricesDataUpdateCoordinator
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_PRICE = "price"
|
||||
ICON = "mdi:currency-eur"
|
||||
UNIT = f"{CURRENCY_EURO}/{ENERGY_KILO_WATT_HOUR}"
|
||||
|
||||
_DEFAULT_TIMEOUT = 10
|
||||
PARALLEL_UPDATES = 1
|
||||
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="PVPC",
|
||||
icon="mdi:currency-eur",
|
||||
native_unit_of_measurement=f"{CURRENCY_EURO}/{ENERGY_KILO_WATT_HOUR}",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
)
|
||||
_PRICE_SENSOR_ATTRIBUTES_MAP = {
|
||||
"tariff": "tariff",
|
||||
"period": "period",
|
||||
"available_power": "available_power",
|
||||
"next_period": "next_period",
|
||||
"hours_to_next_period": "hours_to_next_period",
|
||||
"next_better_price": "next_better_price",
|
||||
"hours_to_better_price": "hours_to_better_price",
|
||||
"num_better_prices_ahead": "num_better_prices_ahead",
|
||||
"price_position": "price_position",
|
||||
"price_ratio": "price_ratio",
|
||||
"max_price": "max_price",
|
||||
"max_price_at": "max_price_at",
|
||||
"min_price": "min_price",
|
||||
"min_price_at": "min_price_at",
|
||||
"next_best_at": "next_best_at",
|
||||
"price_00h": "price_00h",
|
||||
"price_01h": "price_01h",
|
||||
"price_02h": "price_02h",
|
||||
"price_02h_d": "price_02h_d", # only on DST day change with 25h
|
||||
"price_03h": "price_03h",
|
||||
"price_04h": "price_04h",
|
||||
"price_05h": "price_05h",
|
||||
"price_06h": "price_06h",
|
||||
"price_07h": "price_07h",
|
||||
"price_08h": "price_08h",
|
||||
"price_09h": "price_09h",
|
||||
"price_10h": "price_10h",
|
||||
"price_11h": "price_11h",
|
||||
"price_12h": "price_12h",
|
||||
"price_13h": "price_13h",
|
||||
"price_14h": "price_14h",
|
||||
"price_15h": "price_15h",
|
||||
"price_16h": "price_16h",
|
||||
"price_17h": "price_17h",
|
||||
"price_18h": "price_18h",
|
||||
"price_19h": "price_19h",
|
||||
"price_20h": "price_20h",
|
||||
"price_21h": "price_21h",
|
||||
"price_22h": "price_22h",
|
||||
"price_23h": "price_23h",
|
||||
# only seen in the evening
|
||||
"next_better_price (next day)": "next_better_price (next day)",
|
||||
"hours_to_better_price (next day)": "hours_to_better_price (next day)",
|
||||
"num_better_prices_ahead (next day)": "num_better_prices_ahead (next day)",
|
||||
"price_position (next day)": "price_position (next day)",
|
||||
"price_ratio (next day)": "price_ratio (next day)",
|
||||
"max_price (next day)": "max_price (next day)",
|
||||
"max_price_at (next day)": "max_price_at (next day)",
|
||||
"min_price (next day)": "min_price (next day)",
|
||||
"min_price_at (next day)": "min_price_at (next day)",
|
||||
"next_best_at (next day)": "next_best_at (next day)",
|
||||
"price_next_day_00h": "price_next_day_00h",
|
||||
"price_next_day_01h": "price_next_day_01h",
|
||||
"price_next_day_02h": "price_next_day_02h",
|
||||
"price_next_day_02h_d": "price_next_day_02h_d",
|
||||
"price_next_day_03h": "price_next_day_03h",
|
||||
"price_next_day_04h": "price_next_day_04h",
|
||||
"price_next_day_05h": "price_next_day_05h",
|
||||
"price_next_day_06h": "price_next_day_06h",
|
||||
"price_next_day_07h": "price_next_day_07h",
|
||||
"price_next_day_08h": "price_next_day_08h",
|
||||
"price_next_day_09h": "price_next_day_09h",
|
||||
"price_next_day_10h": "price_next_day_10h",
|
||||
"price_next_day_11h": "price_next_day_11h",
|
||||
"price_next_day_12h": "price_next_day_12h",
|
||||
"price_next_day_13h": "price_next_day_13h",
|
||||
"price_next_day_14h": "price_next_day_14h",
|
||||
"price_next_day_15h": "price_next_day_15h",
|
||||
"price_next_day_16h": "price_next_day_16h",
|
||||
"price_next_day_17h": "price_next_day_17h",
|
||||
"price_next_day_18h": "price_next_day_18h",
|
||||
"price_next_day_19h": "price_next_day_19h",
|
||||
"price_next_day_20h": "price_next_day_20h",
|
||||
"price_next_day_21h": "price_next_day_21h",
|
||||
"price_next_day_22h": "price_next_day_22h",
|
||||
"price_next_day_23h": "price_next_day_23h",
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up the electricity price sensor from config_entry."""
|
||||
name = config_entry.data[CONF_NAME]
|
||||
pvpc_data_handler = PVPCData(
|
||||
tariff=config_entry.data[ATTR_TARIFF],
|
||||
power=config_entry.data[ATTR_POWER],
|
||||
power_valley=config_entry.data[ATTR_POWER_P3],
|
||||
local_timezone=hass.config.time_zone,
|
||||
websession=async_get_clientsession(hass),
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
)
|
||||
coordinator: ElecPricesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
name = entry.data[CONF_NAME]
|
||||
async_add_entities(
|
||||
[ElecPriceSensor(name, config_entry.unique_id, pvpc_data_handler)], False
|
||||
[ElecPriceSensor(coordinator, SENSOR_TYPES[0], entry.unique_id, name)]
|
||||
)
|
||||
|
||||
|
||||
class ElecPriceSensor(RestoreEntity, SensorEntity):
|
||||
class ElecPriceSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Class to hold the prices of electricity as a sensor."""
|
||||
|
||||
_attr_icon = ICON
|
||||
_attr_native_unit_of_measurement = UNIT
|
||||
_attr_should_poll = False
|
||||
_attr_state_class = STATE_CLASS_MEASUREMENT
|
||||
coordinator: ElecPricesDataUpdateCoordinator
|
||||
|
||||
def __init__(self, name, unique_id, pvpc_data_handler):
|
||||
"""Initialize the sensor object."""
|
||||
self._name = name
|
||||
self._unique_id = unique_id
|
||||
self._pvpc_data = pvpc_data_handler
|
||||
self._num_retries = 0
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ElecPricesDataUpdateCoordinator,
|
||||
description: SensorEntityDescription,
|
||||
unique_id: str | None,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize ESIOS sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_attribution = coordinator.api.attribution
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_name = name
|
||||
self._attr_device_info = DeviceInfo(
|
||||
configuration_url="https://www.ree.es/es/apidatos",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, coordinator.entry_id)},
|
||||
manufacturer="REE",
|
||||
name="PVPC (REData API)",
|
||||
)
|
||||
self._state: StateType = None
|
||||
self._attrs: Mapping[str, Any] = {}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
if state := await self.async_get_last_state():
|
||||
self._pvpc_data.state = state.state
|
||||
|
||||
# Update 'state' value in hour changes
|
||||
self.async_on_remove(
|
||||
@@ -75,86 +162,31 @@ class ElecPriceSensor(RestoreEntity, SensorEntity):
|
||||
self.hass, self.update_current_price, second=[0], minute=[0]
|
||||
)
|
||||
)
|
||||
# Update prices at random time, 2 times/hour (don't want to upset API)
|
||||
random_minute = randint(1, 29)
|
||||
mins_update = [random_minute, random_minute + 30]
|
||||
self.async_on_remove(
|
||||
async_track_time_change(
|
||||
self.hass, self.async_update_prices, second=[0], minute=mins_update
|
||||
)
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Setup of price sensor %s (%s) with tariff '%s', "
|
||||
"updating prices each hour at %s min",
|
||||
"Setup of price sensor %s (%s) with tariff '%s'",
|
||||
self.name,
|
||||
self.entity_id,
|
||||
self._pvpc_data.tariff,
|
||||
mins_update,
|
||||
self.coordinator.api.tariff,
|
||||
)
|
||||
now = dt_util.utcnow()
|
||||
await self.async_update_prices(now)
|
||||
self.update_current_price(now)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
"""Return a unique ID."""
|
||||
return self._unique_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Return the state of the sensor."""
|
||||
return self._pvpc_data.state
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self._pvpc_data.state_available
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
return self._pvpc_data.attributes
|
||||
|
||||
@callback
|
||||
def update_current_price(self, now):
|
||||
def update_current_price(self, now: datetime) -> None:
|
||||
"""Update the sensor state, by selecting the current price for this hour."""
|
||||
self._pvpc_data.process_state_and_attributes(now)
|
||||
self.coordinator.api.process_state_and_attributes(now)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update_prices(self, now):
|
||||
"""Update electricity prices from the ESIOS API."""
|
||||
prices = await self._pvpc_data.async_update_prices(now)
|
||||
if not prices and self._pvpc_data.source_available:
|
||||
self._num_retries += 1
|
||||
if self._num_retries > 2:
|
||||
_LOGGER.warning(
|
||||
"%s: repeated bad data update, mark component as unavailable source",
|
||||
self.entity_id,
|
||||
)
|
||||
self._pvpc_data.source_available = False
|
||||
return
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
self._state = self.coordinator.api.state
|
||||
return self._state
|
||||
|
||||
retry_delay = 2 * self._num_retries * self._pvpc_data.timeout
|
||||
_LOGGER.debug(
|
||||
"%s: Bad update[retry:%d], will try again in %d s",
|
||||
self.entity_id,
|
||||
self._num_retries,
|
||||
retry_delay,
|
||||
)
|
||||
async_call_later(self.hass, retry_delay, self.async_update_prices)
|
||||
return
|
||||
|
||||
if not prices:
|
||||
_LOGGER.debug("%s: data source is not yet available", self.entity_id)
|
||||
return
|
||||
|
||||
self._num_retries = 0
|
||||
if not self._pvpc_data.source_available:
|
||||
self._pvpc_data.source_available = True
|
||||
_LOGGER.warning("%s: component has recovered data access", self.entity_id)
|
||||
self.update_current_price(now)
|
||||
@property
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
"""Return the state attributes."""
|
||||
self._attrs = {
|
||||
_PRICE_SENSOR_ATTRIBUTES_MAP[key]: value
|
||||
for key, value in self.coordinator.api.attributes.items()
|
||||
if key in _PRICE_SENSOR_ATTRIBUTES_MAP
|
||||
}
|
||||
return self._attrs
|
||||
|
||||
@@ -85,7 +85,7 @@ class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
):
|
||||
await self.async_set_unique_id(controller.mac)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_IP_ADDRESS: ip_address}
|
||||
updates={CONF_IP_ADDRESS: ip_address}, reload_on_update=False
|
||||
)
|
||||
|
||||
# A new rain machine: We will change out the unique id
|
||||
|
||||
@@ -5,7 +5,7 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import pyrepetier
|
||||
import pyrepetierng as pyrepetier
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import SensorEntityDescription
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "repetier",
|
||||
"name": "Repetier-Server",
|
||||
"documentation": "https://www.home-assistant.io/integrations/repetier",
|
||||
"requirements": ["pyrepetier==3.0.5"],
|
||||
"codeowners": ["@MTrab"],
|
||||
"requirements": ["pyrepetierng==0.1.0"],
|
||||
"codeowners": ["@MTrab", "@ShadowBr0ther"],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.const import (
|
||||
CONF_FORCE_UPDATE,
|
||||
CONF_NAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.helpers.event as evt
|
||||
|
||||
@@ -81,6 +82,7 @@ class RflinkBinarySensor(RflinkDevice, BinarySensorEntity):
|
||||
|
||||
if self._state and self._off_delay is not None:
|
||||
|
||||
@callback
|
||||
def off_delay_listener(now):
|
||||
"""Switch device off after a delay."""
|
||||
self._delay_listener = None
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "ring",
|
||||
"name": "Ring",
|
||||
"documentation": "https://www.home-assistant.io/integrations/ring",
|
||||
"requirements": ["ring_doorbell==0.7.1"],
|
||||
"requirements": ["ring_doorbell==0.7.2"],
|
||||
"dependencies": ["ffmpeg"],
|
||||
"codeowners": ["@balloob"],
|
||||
"config_flow": true,
|
||||
|
||||
@@ -68,13 +68,12 @@ from .utils import (
|
||||
BLOCK_PLATFORMS: Final = [
|
||||
"binary_sensor",
|
||||
"button",
|
||||
"climate",
|
||||
"cover",
|
||||
"light",
|
||||
"sensor",
|
||||
"switch",
|
||||
]
|
||||
BLOCK_SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"]
|
||||
BLOCK_SLEEPING_PLATFORMS: Final = ["binary_sensor", "climate", "sensor"]
|
||||
RPC_PLATFORMS: Final = ["binary_sensor", "button", "light", "sensor", "switch"]
|
||||
_LOGGER: Final = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from aioshelly.block_device import Block
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.components.climate import ClimateEntity
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, ClimateEntity
|
||||
from homeassistant.components.climate.const import (
|
||||
CURRENT_HVAC_HEAT,
|
||||
CURRENT_HVAC_IDLE,
|
||||
@@ -20,11 +21,12 @@ from homeassistant.components.climate.const import (
|
||||
SUPPORT_TARGET_TEMPERATURE,
|
||||
)
|
||||
from homeassistant.components.shelly import BlockDeviceWrapper
|
||||
from homeassistant.components.shelly.entity import ShellyBlockEntity
|
||||
from homeassistant.components.shelly.utils import get_device_entry_gen
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.helpers import device_registry, entity, entity_registry
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
@@ -49,10 +51,29 @@ async def async_setup_entry(
|
||||
if get_device_entry_gen(config_entry) == 2:
|
||||
return
|
||||
|
||||
wrapper: BlockDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][
|
||||
config_entry.entry_id
|
||||
][BLOCK]
|
||||
|
||||
if wrapper.device.initialized:
|
||||
await async_setup_climate_entities(async_add_entities, wrapper)
|
||||
else:
|
||||
await async_restore_climate_entities(
|
||||
hass, config_entry, async_add_entities, wrapper
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_climate_entities(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
wrapper: BlockDeviceWrapper,
|
||||
) -> None:
|
||||
"""Set up online climate devices."""
|
||||
|
||||
device_block: Block | None = None
|
||||
sensor_block: Block | None = None
|
||||
|
||||
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK]
|
||||
assert wrapper.device.blocks
|
||||
|
||||
for block in wrapper.device.blocks:
|
||||
if block.type == "device":
|
||||
device_block = block
|
||||
@@ -60,10 +81,38 @@ async def async_setup_entry(
|
||||
sensor_block = block
|
||||
|
||||
if sensor_block and device_block:
|
||||
async_add_entities([ShellyClimate(wrapper, sensor_block, device_block)])
|
||||
_LOGGER.debug("Setup online climate device %s", wrapper.name)
|
||||
async_add_entities([BlockSleepingClimate(wrapper, sensor_block, device_block)])
|
||||
|
||||
|
||||
class ShellyClimate(ShellyBlockEntity, RestoreEntity, ClimateEntity):
|
||||
async def async_restore_climate_entities(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
wrapper: BlockDeviceWrapper,
|
||||
) -> None:
|
||||
"""Restore sleeping climate devices."""
|
||||
|
||||
ent_reg = await entity_registry.async_get_registry(hass)
|
||||
entries = entity_registry.async_entries_for_config_entry(
|
||||
ent_reg, config_entry.entry_id
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
|
||||
if entry.domain != CLIMATE_DOMAIN:
|
||||
continue
|
||||
|
||||
_LOGGER.debug("Setup sleeping climate device %s", wrapper.name)
|
||||
_LOGGER.debug("Found entry %s [%s]", entry.original_name, entry.domain)
|
||||
async_add_entities([BlockSleepingClimate(wrapper, None, None, entry)])
|
||||
|
||||
|
||||
class BlockSleepingClimate(
|
||||
RestoreEntity,
|
||||
ClimateEntity,
|
||||
entity.Entity,
|
||||
):
|
||||
"""Representation of a Shelly climate device."""
|
||||
|
||||
_attr_hvac_modes = [HVAC_MODE_OFF, HVAC_MODE_HEAT]
|
||||
@@ -74,45 +123,77 @@ class ShellyClimate(ShellyBlockEntity, RestoreEntity, ClimateEntity):
|
||||
_attr_target_temperature_step = SHTRV_01_TEMPERATURE_SETTINGS["step"]
|
||||
_attr_temperature_unit = TEMP_CELSIUS
|
||||
|
||||
# pylint: disable=super-init-not-called
|
||||
def __init__(
|
||||
self, wrapper: BlockDeviceWrapper, sensor_block: Block, device_block: Block
|
||||
self,
|
||||
wrapper: BlockDeviceWrapper,
|
||||
sensor_block: Block | None,
|
||||
device_block: Block | None,
|
||||
entry: entity_registry.RegistryEntry | None = None,
|
||||
) -> None:
|
||||
"""Initialize climate."""
|
||||
super().__init__(wrapper, sensor_block)
|
||||
|
||||
self.device_block = device_block
|
||||
|
||||
assert self.block.channel
|
||||
|
||||
self.wrapper = wrapper
|
||||
self.block: Block | None = sensor_block
|
||||
self.control_result: dict[str, Any] | None = None
|
||||
self.device_block: Block | None = device_block
|
||||
self.last_state: State | None = None
|
||||
self.last_state_attributes: MappingProxyType[str, Any]
|
||||
self._preset_modes: list[str] = []
|
||||
|
||||
self._attr_name = self.wrapper.name
|
||||
self._attr_unique_id = self.wrapper.mac
|
||||
self._attr_preset_modes: list[str] = [
|
||||
PRESET_NONE,
|
||||
*wrapper.device.settings["thermostats"][int(self.block.channel)][
|
||||
"schedule_profile_names"
|
||||
],
|
||||
]
|
||||
if self.block is not None and self.device_block is not None:
|
||||
self._unique_id = f"{self.wrapper.mac}-{self.block.description}"
|
||||
assert self.block.channel
|
||||
self._preset_modes = [
|
||||
PRESET_NONE,
|
||||
*wrapper.device.settings["thermostats"][int(self.block.channel)][
|
||||
"schedule_profile_names"
|
||||
],
|
||||
]
|
||||
elif entry is not None:
|
||||
self._unique_id = entry.unique_id
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Set unique id of entity."""
|
||||
return self._unique_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Name of entity."""
|
||||
return self.wrapper.name
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""If device should be polled."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Set target temperature."""
|
||||
return cast(float, self.block.targetTemp)
|
||||
if self.block is not None:
|
||||
return cast(float, self.block.targetTemp)
|
||||
return self.last_state_attributes.get("temperature")
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return current temperature."""
|
||||
return cast(float, self.block.temp)
|
||||
if self.block is not None:
|
||||
return cast(float, self.block.temp)
|
||||
return self.last_state_attributes.get("current_temperature")
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Device availability."""
|
||||
return not cast(bool, self.device_block.valveError)
|
||||
if self.device_block is not None:
|
||||
return not cast(bool, self.device_block.valveError)
|
||||
return self.wrapper.last_update_success
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> str:
|
||||
"""HVAC current mode."""
|
||||
if self.device_block is None:
|
||||
return self.last_state.state if self.last_state else HVAC_MODE_OFF
|
||||
if self.device_block.mode is None or self._check_is_off():
|
||||
return HVAC_MODE_OFF
|
||||
|
||||
@@ -121,20 +202,45 @@ class ShellyClimate(ShellyBlockEntity, RestoreEntity, ClimateEntity):
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Preset current mode."""
|
||||
if self.device_block is None:
|
||||
return self.last_state_attributes.get("preset_mode")
|
||||
if self.device_block.mode is None:
|
||||
return None
|
||||
return self._attr_preset_modes[cast(int, self.device_block.mode)]
|
||||
return PRESET_NONE
|
||||
return self._preset_modes[cast(int, self.device_block.mode)]
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> str | None:
|
||||
"""HVAC current action."""
|
||||
if self.device_block.status is None or self._check_is_off():
|
||||
if (
|
||||
self.device_block is None
|
||||
or self.device_block.status is None
|
||||
or self._check_is_off()
|
||||
):
|
||||
return CURRENT_HVAC_OFF
|
||||
|
||||
return (
|
||||
CURRENT_HVAC_IDLE if self.device_block.status == "0" else CURRENT_HVAC_HEAT
|
||||
)
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str]:
|
||||
"""Preset available modes."""
|
||||
return self._preset_modes
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Device info."""
|
||||
return {
|
||||
"connections": {(device_registry.CONNECTION_NETWORK_MAC, self.wrapper.mac)}
|
||||
}
|
||||
|
||||
@property
|
||||
def channel(self) -> str | None:
|
||||
"""Device channel."""
|
||||
if self.block is not None:
|
||||
return self.block.channel
|
||||
return self.last_state_attributes.get("channel")
|
||||
|
||||
def _check_is_off(self) -> bool:
|
||||
"""Return if valve is off or on."""
|
||||
return bool(
|
||||
@@ -148,7 +254,7 @@ class ShellyClimate(ShellyBlockEntity, RestoreEntity, ClimateEntity):
|
||||
try:
|
||||
async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC):
|
||||
return await self.wrapper.device.http_request(
|
||||
"get", f"thermostat/{self.block.channel}", kwargs
|
||||
"get", f"thermostat/{self.channel}", kwargs
|
||||
)
|
||||
except (asyncio.TimeoutError, OSError) as err:
|
||||
_LOGGER.error(
|
||||
@@ -186,3 +292,41 @@ class ShellyClimate(ShellyBlockEntity, RestoreEntity, ClimateEntity):
|
||||
await self.set_state_full_path(
|
||||
schedule=1, schedule_profile=f"{preset_index}"
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
_LOGGER.info("Restoring entity %s", self.name)
|
||||
|
||||
last_state = await self.async_get_last_state()
|
||||
|
||||
if last_state is not None:
|
||||
self.last_state = last_state
|
||||
self.last_state_attributes = self.last_state.attributes
|
||||
self._preset_modes = cast(
|
||||
list, self.last_state.attributes.get("preset_modes")
|
||||
)
|
||||
|
||||
self.async_on_remove(self.wrapper.async_add_listener(self._update_callback))
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update entity with latest info."""
|
||||
await self.wrapper.async_request_refresh()
|
||||
|
||||
@callback
|
||||
def _update_callback(self) -> None:
|
||||
"""Handle device update."""
|
||||
if not self.wrapper.device.initialized:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
assert self.wrapper.device.blocks
|
||||
|
||||
for block in self.wrapper.device.blocks:
|
||||
if block.type == "device":
|
||||
self.device_block = block
|
||||
if hasattr(block, "targetTemp"):
|
||||
self.block = block
|
||||
|
||||
_LOGGER.debug("Entity %s attached to block", self.name)
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
@@ -12,6 +12,7 @@ from simplipy.errors import (
|
||||
EndpointUnavailableError,
|
||||
InvalidCredentialsError,
|
||||
SimplipyError,
|
||||
WebsocketError,
|
||||
)
|
||||
from simplipy.system import SystemNotification
|
||||
from simplipy.system.v3 import (
|
||||
@@ -472,6 +473,7 @@ class SimpliSafe:
|
||||
self._api = api
|
||||
self._hass = hass
|
||||
self._system_notifications: dict[int, set[SystemNotification]] = {}
|
||||
self._websocket_reconnect_task: asyncio.Task | None = None
|
||||
self.entry = entry
|
||||
self.initial_event_to_use: dict[int, dict[str, Any]] = {}
|
||||
self.systems: dict[int, SystemType] = {}
|
||||
@@ -516,11 +518,44 @@ class SimpliSafe:
|
||||
|
||||
self._system_notifications[system.system_id] = latest_notifications
|
||||
|
||||
async def _async_websocket_on_connect(self) -> None:
|
||||
"""Define a callback for connecting to the websocket."""
|
||||
async def _async_start_websocket_loop(self) -> None:
|
||||
"""Start a websocket reconnection loop."""
|
||||
if TYPE_CHECKING:
|
||||
assert self._api.websocket
|
||||
await self._api.websocket.async_listen()
|
||||
|
||||
should_reconnect = True
|
||||
|
||||
try:
|
||||
await self._api.websocket.async_connect()
|
||||
await self._api.websocket.async_listen()
|
||||
except asyncio.CancelledError:
|
||||
LOGGER.debug("Request to cancel websocket loop received")
|
||||
raise
|
||||
except WebsocketError as err:
|
||||
LOGGER.error("Failed to connect to websocket: %s", err)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
LOGGER.error("Unknown exception while connecting to websocket: %s", err)
|
||||
|
||||
if should_reconnect:
|
||||
LOGGER.info("Disconnected from websocket; reconnecting")
|
||||
await self._async_cancel_websocket_loop()
|
||||
self._websocket_reconnect_task = self._hass.async_create_task(
|
||||
self._async_start_websocket_loop()
|
||||
)
|
||||
|
||||
async def _async_cancel_websocket_loop(self) -> None:
|
||||
"""Stop any existing websocket reconnection loop."""
|
||||
if self._websocket_reconnect_task:
|
||||
self._websocket_reconnect_task.cancel()
|
||||
try:
|
||||
await self._websocket_reconnect_task
|
||||
except asyncio.CancelledError:
|
||||
LOGGER.debug("Websocket reconnection task successfully canceled")
|
||||
self._websocket_reconnect_task = None
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert self._api.websocket
|
||||
await self._api.websocket.async_disconnect()
|
||||
|
||||
@callback
|
||||
def _async_websocket_on_event(self, event: WebsocketEvent) -> None:
|
||||
@@ -560,17 +595,17 @@ class SimpliSafe:
|
||||
assert self._api.refresh_token
|
||||
assert self._api.websocket
|
||||
|
||||
self._api.websocket.add_connect_callback(self._async_websocket_on_connect)
|
||||
self._api.websocket.add_event_callback(self._async_websocket_on_event)
|
||||
asyncio.create_task(self._api.websocket.async_connect())
|
||||
self._websocket_reconnect_task = asyncio.create_task(
|
||||
self._async_start_websocket_loop()
|
||||
)
|
||||
|
||||
async def async_websocket_disconnect_listener(_: Event) -> None:
|
||||
"""Define an event handler to disconnect from the websocket."""
|
||||
if TYPE_CHECKING:
|
||||
assert self._api.websocket
|
||||
|
||||
if self._api.websocket.connected:
|
||||
await self._api.websocket.async_disconnect()
|
||||
await self._async_cancel_websocket_loop()
|
||||
|
||||
self.entry.async_on_unload(
|
||||
self._hass.bus.async_listen_once(
|
||||
@@ -612,10 +647,24 @@ class SimpliSafe:
|
||||
data={**self.entry.data, CONF_TOKEN: token},
|
||||
)
|
||||
|
||||
async def async_handle_refresh_token(token: str) -> None:
|
||||
"""Handle a new refresh token."""
|
||||
async_save_refresh_token(token)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert self._api.websocket
|
||||
|
||||
# Open a new websocket connection with the fresh token:
|
||||
await self._async_cancel_websocket_loop()
|
||||
self._websocket_reconnect_task = self._hass.async_create_task(
|
||||
self._async_start_websocket_loop()
|
||||
)
|
||||
|
||||
self.entry.async_on_unload(
|
||||
self._api.add_refresh_token_callback(async_save_refresh_token)
|
||||
self._api.add_refresh_token_callback(async_handle_refresh_token)
|
||||
)
|
||||
|
||||
# Save the refresh token we got on entry setup:
|
||||
async_save_refresh_token(self._api.refresh_token)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "SimpliSafe",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/simplisafe",
|
||||
"requirements": ["simplisafe-python==2021.12.1"],
|
||||
"requirements": ["simplisafe-python==2021.12.2"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling",
|
||||
"dhcp": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/smappee",
|
||||
"dependencies": ["http"],
|
||||
"requirements": [
|
||||
"pysmappee==0.2.27"
|
||||
"pysmappee==0.2.29"
|
||||
],
|
||||
"codeowners": [
|
||||
"@bsmappee"
|
||||
|
||||
@@ -4,16 +4,11 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL,
|
||||
SensorDeviceClass,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
DEVICE_CLASS_ENERGY,
|
||||
DEVICE_CLASS_POWER,
|
||||
DEVICE_CLASS_POWER_FACTOR,
|
||||
DEVICE_CLASS_TIMESTAMP,
|
||||
DEVICE_CLASS_VOLTAGE,
|
||||
ELECTRIC_POTENTIAL_VOLT,
|
||||
ENERGY_KILO_WATT_HOUR,
|
||||
PERCENTAGE,
|
||||
@@ -38,35 +33,35 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = (
|
||||
SolarLogSensorEntityDescription(
|
||||
key="time",
|
||||
name="last update",
|
||||
device_class=DEVICE_CLASS_TIMESTAMP,
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="power_ac",
|
||||
name="power AC",
|
||||
icon="mdi:solar-power",
|
||||
native_unit_of_measurement=POWER_WATT,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="power_dc",
|
||||
name="power DC",
|
||||
icon="mdi:solar-power",
|
||||
native_unit_of_measurement=POWER_WATT,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="voltage_ac",
|
||||
name="voltage AC",
|
||||
native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT,
|
||||
device_class=DEVICE_CLASS_VOLTAGE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="voltage_dc",
|
||||
name="voltage DC",
|
||||
native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT,
|
||||
device_class=DEVICE_CLASS_VOLTAGE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="yield_day",
|
||||
@@ -101,50 +96,50 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = (
|
||||
name="yield total",
|
||||
icon="mdi:solar-power",
|
||||
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
|
||||
state_class=STATE_CLASS_TOTAL,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
factor=0.001,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="consumption_ac",
|
||||
name="consumption AC",
|
||||
native_unit_of_measurement=POWER_WATT,
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="consumption_day",
|
||||
name="consumption day",
|
||||
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
factor=0.001,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="consumption_yesterday",
|
||||
name="consumption yesterday",
|
||||
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
factor=0.001,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="consumption_month",
|
||||
name="consumption month",
|
||||
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
factor=0.001,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="consumption_year",
|
||||
name="consumption year",
|
||||
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
factor=0.001,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="consumption_total",
|
||||
name="consumption total",
|
||||
native_unit_of_measurement=ENERGY_KILO_WATT_HOUR,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
factor=0.001,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
@@ -152,31 +147,31 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = (
|
||||
name="installed peak power",
|
||||
icon="mdi:solar-power",
|
||||
native_unit_of_measurement=POWER_WATT,
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="alternator_loss",
|
||||
name="alternator loss",
|
||||
icon="mdi:solar-power",
|
||||
native_unit_of_measurement=POWER_WATT,
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="capacity",
|
||||
name="capacity",
|
||||
icon="mdi:solar-power",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=DEVICE_CLASS_POWER_FACTOR,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
factor=100,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="efficiency",
|
||||
name="efficiency",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=DEVICE_CLASS_POWER_FACTOR,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
factor=100,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
@@ -184,15 +179,15 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = (
|
||||
name="power available",
|
||||
icon="mdi:solar-power",
|
||||
native_unit_of_measurement=POWER_WATT,
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SolarLogSensorEntityDescription(
|
||||
key="usage",
|
||||
name="usage",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=DEVICE_CLASS_POWER_FACTOR,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
factor=100,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Platform for solarlog sensors."""
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.helpers import update_coordinator
|
||||
from homeassistant.helpers.entity import DeviceInfo, StateType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.util.dt import as_local
|
||||
|
||||
from . import SolarlogData
|
||||
from .const import DOMAIN, SENSOR_TYPES, SolarLogSensorEntityDescription
|
||||
@@ -38,11 +39,16 @@ class SolarlogSensor(update_coordinator.CoordinatorEntity, SensorEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
def native_value(self):
|
||||
"""Return the native sensor value."""
|
||||
result = getattr(self.coordinator.data, self.entity_description.key)
|
||||
if self.entity_description.factor:
|
||||
state = round(result * self.entity_description.factor, 3)
|
||||
if self.entity_description.key == "time":
|
||||
state = as_local(
|
||||
getattr(self.coordinator.data, self.entity_description.key)
|
||||
)
|
||||
else:
|
||||
state = result
|
||||
result = getattr(self.coordinator.data, self.entity_description.key)
|
||||
if self.entity_description.factor:
|
||||
state = round(result * self.entity_description.factor, 3)
|
||||
else:
|
||||
state = result
|
||||
return state
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Entity representing a Sonos power sensor."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -16,11 +17,14 @@ from .speaker import SonosSpeaker
|
||||
|
||||
ATTR_BATTERY_POWER_SOURCE = "power_source"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up Sonos from a config entry."""
|
||||
|
||||
async def _async_create_entity(speaker: SonosSpeaker) -> None:
|
||||
_LOGGER.debug("Creating battery binary_sensor on %s", speaker.zone_name)
|
||||
entity = SonosPowerEntity(speaker)
|
||||
async_add_entities([entity])
|
||||
|
||||
|
||||
@@ -149,7 +149,6 @@ SONOS_CREATE_BATTERY = "sonos_create_battery"
|
||||
SONOS_CREATE_SWITCHES = "sonos_create_switches"
|
||||
SONOS_CREATE_LEVELS = "sonos_create_levels"
|
||||
SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player"
|
||||
SONOS_ENTITY_CREATED = "sonos_entity_created"
|
||||
SONOS_POLL_UPDATE = "sonos_poll_update"
|
||||
SONOS_ALARMS_UPDATED = "sonos_alarms_updated"
|
||||
SONOS_FAVORITES_UPDATED = "sonos_favorites_updated"
|
||||
|
||||
@@ -10,15 +10,11 @@ from soco.core import SoCo
|
||||
from soco.exceptions import SoCoException
|
||||
|
||||
import homeassistant.helpers.device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
SONOS_ENTITY_CREATED,
|
||||
SONOS_FAVORITES_UPDATED,
|
||||
SONOS_POLL_UPDATE,
|
||||
SONOS_STATE_UPDATED,
|
||||
@@ -60,9 +56,6 @@ class SonosEntity(Entity):
|
||||
self.async_write_ha_state,
|
||||
)
|
||||
)
|
||||
async_dispatcher_send(
|
||||
self.hass, f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.platform.domain
|
||||
)
|
||||
|
||||
async def async_poll(self, now: datetime.datetime) -> None:
|
||||
"""Poll the entity if subscriptions fail."""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Sonos",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/sonos",
|
||||
"requirements": ["soco==0.25.0"],
|
||||
"requirements": ["soco==0.25.1"],
|
||||
"dependencies": ["ssdp"],
|
||||
"after_dependencies": ["plex", "zeroconf"],
|
||||
"zeroconf": ["_sonos._tcp.local."],
|
||||
|
||||
@@ -132,6 +132,7 @@ async def async_setup_entry(
|
||||
@callback
|
||||
def async_create_entities(speaker: SonosSpeaker) -> None:
|
||||
"""Handle device discovery and create entities."""
|
||||
_LOGGER.debug("Creating media_player on %s", speaker.zone_name)
|
||||
async_add_entities([SonosMediaPlayerEntity(speaker)])
|
||||
|
||||
@service.verify_domain_control(hass, SONOS_DOMAIN)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Entity representing a Sonos number control."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.number import NumberEntity
|
||||
from homeassistant.const import ENTITY_CATEGORY_CONFIG
|
||||
from homeassistant.core import callback
|
||||
@@ -13,6 +15,8 @@ from .speaker import SonosSpeaker
|
||||
|
||||
LEVEL_TYPES = ("bass", "treble")
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the Sonos number platform from a config entry."""
|
||||
@@ -21,6 +25,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
def _async_create_entities(speaker: SonosSpeaker) -> None:
|
||||
entities = []
|
||||
for level_type in LEVEL_TYPES:
|
||||
_LOGGER.debug(
|
||||
"Creating %s number control on %s", level_type, speaker.zone_name
|
||||
)
|
||||
entities.append(SonosLevelEntity(speaker, level_type))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Entity representing a Sonos battery level."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.const import (
|
||||
DEVICE_CLASS_BATTERY,
|
||||
@@ -14,6 +16,8 @@ from .const import SONOS_CREATE_AUDIO_FORMAT_SENSOR, SONOS_CREATE_BATTERY
|
||||
from .entity import SonosEntity
|
||||
from .speaker import SonosSpeaker
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up Sonos from a config entry."""
|
||||
@@ -22,11 +26,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
def _async_create_audio_format_entity(
|
||||
speaker: SonosSpeaker, audio_format: str
|
||||
) -> None:
|
||||
_LOGGER.debug("Creating audio input format sensor on %s", speaker.zone_name)
|
||||
entity = SonosAudioInputFormatSensorEntity(speaker, audio_format)
|
||||
async_add_entities([entity])
|
||||
|
||||
@callback
|
||||
def _async_create_battery_sensor(speaker: SonosSpeaker) -> None:
|
||||
_LOGGER.debug("Creating battery level sensor on %s", speaker.zone_name)
|
||||
entity = SonosBatteryEntity(speaker)
|
||||
async_add_entities([entity])
|
||||
|
||||
|
||||
@@ -20,10 +20,7 @@ from soco.music_library import MusicLibrary
|
||||
from soco.plugins.sharelink import ShareLinkPlugin
|
||||
from soco.snapshot import Snapshot
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -42,7 +39,6 @@ from .const import (
|
||||
BATTERY_SCAN_INTERVAL,
|
||||
DATA_SONOS,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
SCAN_INTERVAL,
|
||||
SONOS_CHECK_ACTIVITY,
|
||||
SONOS_CREATE_ALARM,
|
||||
@@ -51,7 +47,6 @@ from .const import (
|
||||
SONOS_CREATE_LEVELS,
|
||||
SONOS_CREATE_MEDIA_PLAYER,
|
||||
SONOS_CREATE_SWITCHES,
|
||||
SONOS_ENTITY_CREATED,
|
||||
SONOS_POLL_UPDATE,
|
||||
SONOS_REBOOTED,
|
||||
SONOS_SPEAKER_ACTIVITY,
|
||||
@@ -161,9 +156,6 @@ class SonosSpeaker:
|
||||
self._share_link_plugin: ShareLinkPlugin | None = None
|
||||
self.available = True
|
||||
|
||||
# Synchronization helpers
|
||||
self._platforms_ready: set[str] = set()
|
||||
|
||||
# Subscriptions and events
|
||||
self.subscriptions_failed: bool = False
|
||||
self._subscriptions: list[SubscriptionBase] = []
|
||||
@@ -193,7 +185,7 @@ class SonosSpeaker:
|
||||
self.volume: int | None = None
|
||||
self.muted: bool | None = None
|
||||
self.night_mode: bool | None = None
|
||||
self.dialog_mode: bool | None = None
|
||||
self.dialog_level: bool | None = None
|
||||
self.cross_fade: bool | None = None
|
||||
self.bass: int | None = None
|
||||
self.treble: int | None = None
|
||||
@@ -217,7 +209,6 @@ class SonosSpeaker:
|
||||
dispatch_pairs = (
|
||||
(SONOS_CHECK_ACTIVITY, self.async_check_activity),
|
||||
(SONOS_SPEAKER_ADDED, self.update_group_for_uid),
|
||||
(f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.async_handle_new_entity),
|
||||
(f"{SONOS_REBOOTED}-{self.soco.uid}", self.async_rebooted),
|
||||
(f"{SONOS_SPEAKER_ACTIVITY}-{self.soco.uid}", self.speaker_activity),
|
||||
)
|
||||
@@ -253,15 +244,11 @@ class SonosSpeaker:
|
||||
self.hass, self.async_poll_battery, BATTERY_SCAN_INTERVAL
|
||||
)
|
||||
dispatcher_send(self.hass, SONOS_CREATE_BATTERY, self)
|
||||
else:
|
||||
self._platforms_ready.update({BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN})
|
||||
|
||||
if new_alarms := [
|
||||
alarm.alarm_id for alarm in self.alarms if alarm.zone.uid == self.soco.uid
|
||||
]:
|
||||
dispatcher_send(self.hass, SONOS_CREATE_ALARM, self, new_alarms)
|
||||
else:
|
||||
self._platforms_ready.add(SWITCH_DOMAIN)
|
||||
|
||||
dispatcher_send(self.hass, SONOS_CREATE_SWITCHES, self)
|
||||
|
||||
@@ -277,19 +264,11 @@ class SonosSpeaker:
|
||||
dispatcher_send(self.hass, SONOS_CREATE_MEDIA_PLAYER, self)
|
||||
dispatcher_send(self.hass, SONOS_SPEAKER_ADDED, self.soco.uid)
|
||||
|
||||
self.hass.create_task(self.async_subscribe())
|
||||
|
||||
#
|
||||
# Entity management
|
||||
#
|
||||
async def async_handle_new_entity(self, entity_type: str) -> None:
|
||||
"""Listen to new entities to trigger first subscription."""
|
||||
if self._platforms_ready == PLATFORMS:
|
||||
return
|
||||
|
||||
self._platforms_ready.add(entity_type)
|
||||
if self._platforms_ready == PLATFORMS:
|
||||
self._resubscription_lock = asyncio.Lock()
|
||||
await self.async_subscribe()
|
||||
|
||||
def write_entity_states(self) -> None:
|
||||
"""Write states for associated SonosEntity instances."""
|
||||
dispatcher_send(self.hass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}")
|
||||
@@ -405,6 +384,9 @@ class SonosSpeaker:
|
||||
|
||||
async def async_resubscribe(self, exception: Exception) -> None:
|
||||
"""Attempt to resubscribe when a renewal failure is detected."""
|
||||
if not self._resubscription_lock:
|
||||
self._resubscription_lock = asyncio.Lock()
|
||||
|
||||
async with self._resubscription_lock:
|
||||
if not self.available:
|
||||
return
|
||||
@@ -498,17 +480,18 @@ class SonosSpeaker:
|
||||
if "mute" in variables:
|
||||
self.muted = variables["mute"]["Master"] == "1"
|
||||
|
||||
if "night_mode" in variables:
|
||||
self.night_mode = variables["night_mode"] == "1"
|
||||
for bool_var in (
|
||||
"dialog_level",
|
||||
"night_mode",
|
||||
"sub_enabled",
|
||||
"surround_enabled",
|
||||
):
|
||||
if bool_var in variables:
|
||||
setattr(self, bool_var, variables[bool_var] == "1")
|
||||
|
||||
if "dialog_level" in variables:
|
||||
self.dialog_mode = variables["dialog_level"] == "1"
|
||||
|
||||
if "bass" in variables:
|
||||
self.bass = variables["bass"]
|
||||
|
||||
if "treble" in variables:
|
||||
self.treble = variables["treble"]
|
||||
for int_var in ("bass", "treble"):
|
||||
if int_var in variables:
|
||||
setattr(self, int_var, variables[int_var])
|
||||
|
||||
self.async_write_entity_states()
|
||||
|
||||
@@ -982,7 +965,7 @@ class SonosSpeaker:
|
||||
self.volume = self.soco.volume
|
||||
self.muted = self.soco.mute
|
||||
self.night_mode = self.soco.night_mode
|
||||
self.dialog_mode = self.soco.dialog_mode
|
||||
self.dialog_level = self.soco.dialog_mode
|
||||
self.bass = self.soco.bass
|
||||
self.treble = self.soco.treble
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "ssdp",
|
||||
"name": "Simple Service Discovery Protocol (SSDP)",
|
||||
"documentation": "https://www.home-assistant.io/integrations/ssdp",
|
||||
"requirements": ["async-upnp-client==0.22.12"],
|
||||
"requirements": ["async-upnp-client==0.23.1"],
|
||||
"dependencies": ["network"],
|
||||
"after_dependencies": ["zeroconf"],
|
||||
"codeowners": [],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Tailscale",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/tailscale",
|
||||
"requirements": ["tailscale==0.1.4"],
|
||||
"requirements": ["tailscale==0.1.6"],
|
||||
"codeowners": ["@frenck"],
|
||||
"quality_scale": "platinum",
|
||||
"iot_class": "cloud_polling"
|
||||
|
||||
@@ -641,9 +641,13 @@ class LightTemplate(TemplateEntity, LightEntity):
|
||||
@callback
|
||||
def _update_color(self, render):
|
||||
"""Update the hs_color from the template."""
|
||||
if render is None:
|
||||
self._color = None
|
||||
return
|
||||
|
||||
h_str = s_str = None
|
||||
if isinstance(render, str):
|
||||
if render in (None, "None", ""):
|
||||
if render in ("None", ""):
|
||||
self._color = None
|
||||
return
|
||||
h_str, s_str = map(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "tibber",
|
||||
"name": "Tibber",
|
||||
"documentation": "https://www.home-assistant.io/integrations/tibber",
|
||||
"requirements": ["pyTibber==0.21.0"],
|
||||
"requirements": ["pyTibber==0.21.1"],
|
||||
"codeowners": ["@danielhiversen"],
|
||||
"quality_scale": "silver",
|
||||
"config_flow": true,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Tile",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/tile",
|
||||
"requirements": ["pytile==5.2.4"],
|
||||
"requirements": ["pytile==2021.12.0"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "totalconnect",
|
||||
"name": "Total Connect",
|
||||
"documentation": "https://www.home-assistant.io/integrations/totalconnect",
|
||||
"requirements": ["total_connect_client==2021.11.4"],
|
||||
"requirements": ["total_connect_client==2021.12"],
|
||||
"dependencies": [],
|
||||
"codeowners": ["@austinmroczek"],
|
||||
"config_flow": true,
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.util.dt import get_time_zone
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -131,12 +132,15 @@ class TrainSensor(SensorEntity):
|
||||
self._state = None
|
||||
self._departure_state = None
|
||||
self._delay_in_minutes = None
|
||||
self._timezone = get_time_zone("Europe/Stockholm")
|
||||
|
||||
async def async_update(self):
|
||||
"""Retrieve latest state."""
|
||||
if self._time is not None:
|
||||
departure_day = next_departuredate(self._weekday)
|
||||
when = datetime.combine(departure_day, self._time)
|
||||
when = datetime.combine(departure_day, self._time).astimezone(
|
||||
self._timezone
|
||||
)
|
||||
try:
|
||||
self._state = await self._train_api.async_get_train_stop(
|
||||
self._from_station, self._to_station, when
|
||||
@@ -193,8 +197,8 @@ class TrainSensor(SensorEntity):
|
||||
"""Return the departure state."""
|
||||
if (state := self._state) is not None:
|
||||
if state.time_at_location is not None:
|
||||
return state.time_at_location
|
||||
return state.time_at_location.astimezone(self._timezone)
|
||||
if state.estimated_time_at_location is not None:
|
||||
return state.estimated_time_at_location
|
||||
return state.advertised_time_at_location
|
||||
return state.estimated_time_at_location.astimezone(self._timezone)
|
||||
return state.advertised_time_at_location.astimezone(self._timezone)
|
||||
return None
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "UPnP/IGD",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/upnp",
|
||||
"requirements": ["async-upnp-client==0.22.12"],
|
||||
"requirements": ["async-upnp-client==0.23.1"],
|
||||
"dependencies": ["network", "ssdp"],
|
||||
"codeowners": ["@StevenLooman","@ehendrix23"],
|
||||
"ssdp": [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Support for Vallox ventilation units."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
import ipaddress
|
||||
import logging
|
||||
@@ -13,7 +14,7 @@ from vallox_websocket_api.vallox import get_uuid as calculate_uuid
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.core import CoreState, HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
from homeassistant.helpers.typing import ConfigType, StateType
|
||||
@@ -25,6 +26,7 @@ from .const import (
|
||||
DEFAULT_FAN_SPEED_HOME,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
INITIAL_COORDINATOR_UPDATE_RETRY_INTERVAL_SECONDS,
|
||||
METRIC_KEY_PROFILE_FAN_SPEED_AWAY,
|
||||
METRIC_KEY_PROFILE_FAN_SPEED_BOOST,
|
||||
METRIC_KEY_PROFILE_FAN_SPEED_HOME,
|
||||
@@ -171,7 +173,24 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass.data[DOMAIN] = {"client": client, "coordinator": coordinator, "name": name}
|
||||
|
||||
async def _async_load_platform_delayed(*_: Any) -> None:
|
||||
await coordinator.async_refresh()
|
||||
# We need a successful update before loading the platforms, because platform init code
|
||||
# derives the UUIDs from the data the coordinator fetches.
|
||||
warned_once = False
|
||||
while hass.state == CoreState.running:
|
||||
await coordinator.async_refresh()
|
||||
if coordinator.last_update_success:
|
||||
break
|
||||
|
||||
if not warned_once:
|
||||
_LOGGER.warning(
|
||||
"Vallox integration not ready yet; Retrying in background"
|
||||
)
|
||||
warned_once = True
|
||||
|
||||
await asyncio.sleep(INITIAL_COORDINATOR_UPDATE_RETRY_INTERVAL_SECONDS)
|
||||
else:
|
||||
return
|
||||
|
||||
hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config))
|
||||
hass.async_create_task(async_load_platform(hass, "fan", DOMAIN, {}, config))
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from vallox_websocket_api import PROFILE as VALLOX_PROFILE
|
||||
DOMAIN = "vallox"
|
||||
DEFAULT_NAME = "Vallox"
|
||||
|
||||
INITIAL_COORDINATOR_UPDATE_RETRY_INTERVAL_SECONDS = 5
|
||||
STATE_SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
# Common metric keys and (default) values.
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import ValloxDataUpdateCoordinator
|
||||
from .const import (
|
||||
@@ -107,7 +108,7 @@ class ValloxFilterRemainingSensor(ValloxSensor):
|
||||
days_remaining_delta = timedelta(days=days_remaining)
|
||||
now = datetime.utcnow().replace(hour=13, minute=0, second=0, microsecond=0)
|
||||
|
||||
return now + days_remaining_delta
|
||||
return (now + days_remaining_delta).astimezone(dt_util.UTC)
|
||||
|
||||
|
||||
class ValloxCellStateSensor(ValloxSensor):
|
||||
|
||||
@@ -61,6 +61,11 @@ class VelbusClimate(VelbusEntity, ClimateEntity):
|
||||
None,
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> int | None:
|
||||
"""Return the current temperature."""
|
||||
return self._channel.get_state()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperatures."""
|
||||
if (temp := kwargs.get(ATTR_TEMPERATURE)) is None:
|
||||
|
||||
@@ -49,7 +49,7 @@ class VelbusLight(VelbusEntity, LightEntity):
|
||||
"""Representation of a Velbus light."""
|
||||
|
||||
_channel: VelbusDimmer
|
||||
_attr_supported_feature = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION
|
||||
_attr_supported_features = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
@@ -96,7 +96,7 @@ class VelbusButtonLight(VelbusEntity, LightEntity):
|
||||
|
||||
_channel: VelbusButton
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_attr_supported_feature = SUPPORT_FLASH
|
||||
_attr_supported_features = SUPPORT_FLASH
|
||||
|
||||
def __init__(self, channel: VelbusChannel) -> None:
|
||||
"""Initialize the button light (led)."""
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "{name} ({host})",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "{name}",
|
||||
"description": "Set up ViCare integration. To generate API key go to https://developer.viessmann.com",
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"scan_interval": "Scan Interval (seconds)",
|
||||
"username": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"client_id": "[%key:common::config_flow::data::api_key%]",
|
||||
"heating_type": "Heating type"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Already configured. Only a single configuration possible.",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"error": {
|
||||
"invalid_auth": "Invalid authentication"
|
||||
},
|
||||
"flow_title": "{name} ({host})",
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"scan_interval": "Scan Interval (seconds)",
|
||||
"client_id": "API Key",
|
||||
"heating_type": "Heating type",
|
||||
"password": "Password",
|
||||
"username": "Email"
|
||||
},
|
||||
"title": "{name}",
|
||||
"description": "Set up ViCare integration. To generate API key go to https://developer.viessmann.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Belkin WeMo",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/wemo",
|
||||
"requirements": ["pywemo==0.6.7"],
|
||||
"requirements": ["pywemo==0.7.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Belkin International Inc."
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Xiaomi Miio",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/xiaomi_miio",
|
||||
"requirements": ["construct==2.10.56", "micloud==0.4", "python-miio==0.5.9.1"],
|
||||
"requirements": ["construct==2.10.56", "micloud==0.4", "python-miio==0.5.9.2"],
|
||||
"codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"],
|
||||
"zeroconf": ["_miio._udp.local."],
|
||||
"iot_class": "local_polling"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "yeelight",
|
||||
"name": "Yeelight",
|
||||
"documentation": "https://www.home-assistant.io/integrations/yeelight",
|
||||
"requirements": ["yeelight==0.7.8", "async-upnp-client==0.22.12"],
|
||||
"requirements": ["yeelight==0.7.8", "async-upnp-client==0.23.1"],
|
||||
"codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["network"],
|
||||
|
||||
@@ -206,11 +206,11 @@ class Thermostat(ZhaEntity, ClimateEntity):
|
||||
|
||||
unoccupied_cooling_setpoint = self._thrm.unoccupied_cooling_setpoint
|
||||
if unoccupied_cooling_setpoint is not None:
|
||||
data[ATTR_UNOCCP_HEAT_SETPT] = unoccupied_cooling_setpoint
|
||||
data[ATTR_UNOCCP_COOL_SETPT] = unoccupied_cooling_setpoint
|
||||
|
||||
unoccupied_heating_setpoint = self._thrm.unoccupied_heating_setpoint
|
||||
if unoccupied_heating_setpoint is not None:
|
||||
data[ATTR_UNOCCP_COOL_SETPT] = unoccupied_heating_setpoint
|
||||
data[ATTR_UNOCCP_HEAT_SETPT] = unoccupied_heating_setpoint
|
||||
return data
|
||||
|
||||
@property
|
||||
|
||||
@@ -234,6 +234,7 @@ class GroupProbe:
|
||||
unsub()
|
||||
self._unsubs.remove(unsub)
|
||||
|
||||
@callback
|
||||
def _reprobe_group(self, group_id: int) -> None:
|
||||
"""Reprobe a group for entities after its members change."""
|
||||
zha_gateway = self._hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user