Compare commits

..

61 Commits

Author SHA1 Message Date
Franck Nijhof
3d75b0776f Merge pull request #72824 from home-assistant/rc 2022-06-01 15:21:30 +02:00
Franck Nijhof
39da7a93ec Bumped version to 2022.6.0 2022-06-01 13:04:12 +02:00
J. Nick Koston
bf47d86d30 Fix logbook spinner never disappearing when all entities are filtered (#72816) 2022-06-01 13:03:43 +02:00
J. Nick Koston
2f3359f376 Fix purge of legacy database events that are not state changed (#72815) 2022-06-01 13:03:39 +02:00
starkillerOG
1139136365 Add Motionblinds WoodShutter support (#72814) 2022-06-01 13:03:36 +02:00
J. Nick Koston
9e723f9b6d Bump sqlalchemy to 1.4.37 (#72809)
Fixes a bug where reconnects might fail with MySQL 8.0.24+

Changelog: https://docs.sqlalchemy.org/en/14/changelog/changelog_14.html#change-1.4.37
2022-06-01 13:03:32 +02:00
Paulus Schoutsen
9bd2e3ad7c Don't trigger entity sync when Google Assistant gets disabled (#72805) 2022-06-01 13:03:28 +02:00
jjlawren
384cb44d15 Cleanup handling of new enqueue & announce features in Sonos (#72801) 2022-06-01 13:03:25 +02:00
Christopher Bailey
1274448de1 Add package constraint for pydantic (#72799)
Co-authored-by: J. Nick Koston <nick@koston.org>
2022-06-01 13:03:20 +02:00
Paulus Schoutsen
354149e43c Bumped version to 2022.6.0b7 2022-05-31 20:41:59 -07:00
jjlawren
17a3c62821 Support add/next/play/replace enqueue options in Sonos (#72800) 2022-05-31 20:41:35 -07:00
Diogo Gomes
668f56f103 Fix #72749 (#72794) 2022-05-31 20:41:35 -07:00
Paulus Schoutsen
0db9863746 Sync entities when enabling/disabling Google Assistant (#72791) 2022-05-31 20:41:34 -07:00
Erik Montnemery
e60dc1b503 Stringify mikrotik device_tracker name (#72788)
Co-authored-by: J. Nick Koston <nick@koston.org>
2022-05-31 20:41:33 -07:00
Erik Montnemery
8606447848 Improve cast HLS detection (#72787) 2022-05-31 20:41:32 -07:00
J. Nick Koston
de0c672cc2 Ensure the statistics_meta table is using the dynamic row format (#72784) 2022-05-31 20:41:32 -07:00
Paulus Schoutsen
c3acdcb2c8 Bumped version to 2022.6.0b6 2022-05-31 13:22:38 -07:00
J. Nick Koston
9effb78a7f Prevent live logbook from sending state changed events when we only want device ids (#72780) 2022-05-31 13:22:34 -07:00
Joakim Plate
647df29a00 Don't set headers kwargs multiple times (#72779) 2022-05-31 13:22:33 -07:00
J. Nick Koston
a54a5b2d20 Fix queries for logbook context_ids running in the wrong executor (#72778) 2022-05-31 13:22:33 -07:00
Bram Kragten
f4d280b59d Update frontend to 20220531.0 (#72775) 2022-05-31 13:22:32 -07:00
David F. Mulcahey
d268c828ee Bump ZHA quirks lib to 0.0.75 (#72765) 2022-05-31 13:22:31 -07:00
Erik Montnemery
82ed6869d0 Improve integration sensor's time unit handling (#72759) 2022-05-31 13:22:30 -07:00
Malte Franken
6b3a284135 Make zone condition more robust by ignoring unavailable and unknown entities (#72751)
* ignore entities with state unavailable or unknown

* test for unavailable entity
2022-05-31 13:22:29 -07:00
J. Nick Koston
ca8c750a5a Small performance improvement for matching logbook rows (#72750) 2022-05-31 13:22:29 -07:00
Aaron Bach
7c2f73ddba Alter RainMachine to not create entities if the underlying data is missing (#72733) 2022-05-31 13:22:28 -07:00
Khole
1b2cb4eab7 Fix hive authentication process (#72719)
* Fix hive authentication process

* Update hive test scripts to add new data
2022-05-31 13:22:27 -07:00
Alexey Zimarev
4bf5132a06 SmartThings issue with unique_id (#72715)
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
2022-05-31 13:22:26 -07:00
eyager1
6e06b6c9ed Add empty string to list of invalid states (#72590)
Add null state to list of invalid states
2022-05-31 13:22:26 -07:00
Paulus Schoutsen
103f324c52 Bumped version to 2022.6.0b5 2022-05-30 22:57:22 -07:00
Aaron Bach
48d36e49f0 Bump simplisafe-python to 2022.05.2 (#72740) 2022-05-30 22:57:12 -07:00
Aaron Bach
a4e2d31a19 Bump regenmaschine to 2022.05.1 (#72735) 2022-05-30 22:57:11 -07:00
Aaron Bach
15bdfb2a45 Fix invalid RainMachine syntax (#72732) 2022-05-30 22:57:10 -07:00
Raman Gupta
b842c76fbd Bump zwave-js-server-python to 0.37.1 (#72731)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-05-30 22:56:31 -07:00
jjlawren
a98528c93f Bump plexapi to 4.11.2 (#72729) 2022-05-30 22:53:07 -07:00
J. Nick Koston
a202ffe4c1 Make logbook inherit the recorder filter (#72728) 2022-05-30 22:53:06 -07:00
Paulus Schoutsen
77e4c86c07 Add support for announce to play_media (#72566) 2022-05-30 22:53:05 -07:00
Paulus Schoutsen
72a79736a6 Bumped version to 2022.6.0b4 2022-05-30 14:40:55 -07:00
Erik Montnemery
2809592e71 Improve handling of MQTT overridden settings (#72698)
* Improve handling of MQTT overridden settings

* Don't warn unless config entry overrides yaml
2022-05-30 14:40:45 -07:00
Erik Montnemery
da7446bf52 Bump hatasmota to 0.5.1 (#72696) 2022-05-30 14:40:44 -07:00
rikroe
2942986a7b Bump bimmer_connected to 0.9.3 (#72677)
Bump bimmer_connected to 0.9.3, fix retrieved units

Co-authored-by: rikroe <rikroe@users.noreply.github.com>
2022-05-30 14:40:43 -07:00
Michael
67ef3229fd Address late review comments for Tankerkoenig (#72672)
* address late review comment from #72654

* use entry_id instead of unique_id

* remove not needed `_hass` property

* fix skiping failing stations

* remove not neccessary error log

* set DeviceEntryType.SERVICE

* fix use entry_id instead of unique_id

* apply suggestions on tests

* add return value also to other tests

* invert data check to early return user form
2022-05-30 14:40:42 -07:00
shbatm
952433d16e Check ISY994 climate for unknown humidity on Z-Wave Thermostat (#72670) 2022-05-30 14:40:42 -07:00
Joakim Sørensen
6f01c13845 Switch severity for gesture logging (#72668) 2022-05-30 14:40:41 -07:00
Paulus Schoutsen
f8b7527bf0 Allow removing a ring device (#72665) 2022-05-30 14:40:40 -07:00
Raman Gupta
f039aac31c Fix zwave_js custom trigger validation bug (#72656)
* Fix zwave_js custom trigger validation bug

* update comments

* Switch to ValueError

* Switch to ValueError
2022-05-30 14:40:39 -07:00
Aaron Bach
c62692dff1 Guard against missing data in 1st generation RainMachine controllers (#72632) 2022-05-30 14:40:39 -07:00
BigMoby
4b524c0776 iAlarm XR integration refinements (#72616)
* fixing after MartinHjelmare review

* fixing after MartinHjelmare review conversion alarm state to hass state

* fixing after MartinHjelmare review conversion alarm state to hass state

* manage the status in the alarm control

* simplyfing return function
2022-05-30 14:40:38 -07:00
Duco Sebel
f41b2fa2cf Fix homewizard diagnostics and add tests (#72611) 2022-05-30 14:40:37 -07:00
Matrix
ce4825c9e2 Fix yolink device unavailable on startup (#72579)
* fetch device state on startup

* Suggest change

* suggest fix

* fix

* fix

* Fix suggest

* suggest fix
2022-05-30 14:40:36 -07:00
Paulus Schoutsen
6bf6a0f7bc Convert media player enqueue to an enum (#72406) 2022-05-30 14:40:35 -07:00
Shawn Saenger
f33517ef2c Incorporate various improvements for the ws66i integration (#71717)
* Improve readability and remove unused code

* Remove ws66i custom services. Scenes can be used instead.

* Unmute WS66i Zone when volume changes

* Raise CannotConnect instead of ConnectionError in validation method

* Move _verify_connection() method to module level
2022-05-30 14:40:35 -07:00
Paulus Schoutsen
da62e2cc23 Bumped version to 2022.6.0b3 2022-05-28 20:46:51 -07:00
Michael
b360f0280b Manage stations via integrations configuration in Tankerkoenig (#72654) 2022-05-28 20:46:36 -07:00
rikroe
50eaf2f475 Bump bimmer_connected to 0.9.2 (#72653)
Co-authored-by: rikroe <rikroe@users.noreply.github.com>
2022-05-28 20:46:35 -07:00
J. Nick Koston
bd222a1fe0 Prevent config entries from being reloaded concurrently (#72636)
* Prevent config entries being reloaded concurrently

- Fixes Config entry has already been setup when
  two places try to reload the config entry at the
  same time.

- This comes up quite a bit:
  https://github.com/home-assistant/core/issues?q=is%3Aissue+sort%3Aupdated-desc+%22Config+entry+has+already+been+setup%22+is%3Aclosed

* Make sure plex creates mocks in the event loop

* drop reload_lock, already inherits
2022-05-28 20:46:34 -07:00
Joakim Sørensen
3a06b5f320 Bump awesomeversion from 22.5.1 to 22.5.2 (#72624) 2022-05-28 20:46:12 -07:00
J. Nick Koston
c45dc49270 Escape % and _ in history/logbook entity_globs, and use ? as _ (#72623)
Co-authored-by: pyos <pyos100500@gmail.com>
2022-05-28 20:45:14 -07:00
Allen Porter
301f7647d1 Defer google calendar integration reload to a task to avoid races of reload during setup (#72608) 2022-05-28 20:45:13 -07:00
Allen Porter
79340f85d2 Don't import google calendar user pref for disabling new entities (#72652) 2022-05-28 20:44:50 -07:00
Paulus Schoutsen
afcc8679dd Handle OAuth2 rejection (#72040) 2022-05-28 20:43:48 -07:00
131 changed files with 2538 additions and 1091 deletions

View File

@@ -491,7 +491,6 @@ omit =
homeassistant/components/homematic/*
homeassistant/components/home_plus_control/api.py
homeassistant/components/home_plus_control/switch.py
homeassistant/components/homewizard/diagnostics.py
homeassistant/components/homeworks/*
homeassistant/components/honeywell/__init__.py
homeassistant/components/honeywell/climate.py
@@ -966,6 +965,7 @@ omit =
homeassistant/components/rainmachine/model.py
homeassistant/components/rainmachine/sensor.py
homeassistant/components/rainmachine/switch.py
homeassistant/components/rainmachine/util.py
homeassistant/components/raspyrfm/*
homeassistant/components/recollect_waste/__init__.py
homeassistant/components/recollect_waste/sensor.py

View File

@@ -24,10 +24,7 @@ from homeassistant.components.media_player import (
from homeassistant.components.media_player.browse_media import (
async_process_play_media_url,
)
from homeassistant.components.media_player.const import (
ATTR_MEDIA_ENQUEUE,
MEDIA_TYPE_MUSIC,
)
from homeassistant.components.media_player.const import MEDIA_TYPE_MUSIC
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_HOST,
@@ -1023,11 +1020,7 @@ class BluesoundPlayer(MediaPlayerEntity):
return await self.send_bluesound_command(f"Play?seek={float(position)}")
async def async_play_media(self, media_type, media_id, **kwargs):
"""
Send the play_media command to the media player.
If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue.
"""
"""Send the play_media command to the media player."""
if self.is_grouped and not self.is_master:
return
@@ -1041,9 +1034,6 @@ class BluesoundPlayer(MediaPlayerEntity):
url = f"Play?url={media_id}"
if kwargs.get(ATTR_MEDIA_ENQUEUE):
return await self.send_bluesound_command(url)
return await self.send_bluesound_command(url)
async def async_volume_up(self):

View File

@@ -6,7 +6,7 @@ import logging
from bimmer_connected.account import MyBMWAccount
from bimmer_connected.api.regions import get_region_from_name
from bimmer_connected.vehicle.models import GPSPosition
from bimmer_connected.models import GPSPosition
from httpx import HTTPError, TimeoutException
from homeassistant.config_entries import ConfigEntry
@@ -32,6 +32,7 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator):
entry.data[CONF_PASSWORD],
get_region_from_name(entry.data[CONF_REGION]),
observer_position=GPSPosition(hass.config.latitude, hass.config.longitude),
use_metric_units=hass.config.units.is_metric,
)
self.read_only = entry.options[CONF_READ_ONLY]
self._entry = entry

View File

@@ -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.9.0"],
"requirements": ["bimmer_connected==0.9.3"],
"codeowners": ["@gerard33", "@rikroe"],
"config_flow": true,
"iot_class": "cloud_polling",

View File

@@ -6,8 +6,8 @@ from dataclasses import dataclass
import logging
from typing import cast
from bimmer_connected.models import ValueWithUnit
from bimmer_connected.vehicle import MyBMWVehicle
from bimmer_connected.vehicle.models import ValueWithUnit
from homeassistant.components.sensor import (
SensorDeviceClass,

View File

@@ -266,10 +266,8 @@ async def parse_m3u(hass, url):
hls_content_types = (
# https://tools.ietf.org/html/draft-pantos-http-live-streaming-19#section-10
"application/vnd.apple.mpegurl",
# Some sites serve these as the informal HLS m3u type.
"application/x-mpegurl",
"audio/mpegurl",
"audio/x-mpegurl",
# Additional informal types used by Mozilla gecko not included as they
# don't reliably indicate HLS streams
)
m3u_data = await _fetch_playlist(hass, url, hls_content_types)
m3u_lines = m3u_data.splitlines()
@@ -292,6 +290,9 @@ async def parse_m3u(hass, url):
elif line.startswith("#EXT-X-VERSION:"):
# HLS stream, supported by cast devices
raise PlaylistSupported("HLS")
elif line.startswith("#EXT-X-STREAM-INF:"):
# HLS stream, supported by cast devices
raise PlaylistSupported("HLS")
elif line.startswith("#"):
# Ignore other extensions
continue

View File

@@ -195,6 +195,8 @@ class CloudGoogleConfig(AbstractConfig):
):
await async_setup_component(self.hass, GOOGLE_DOMAIN, {})
sync_entities = False
if self.should_report_state != self.is_reporting_state:
if self.should_report_state:
self.async_enable_report_state()
@@ -203,7 +205,7 @@ class CloudGoogleConfig(AbstractConfig):
# State reporting is reported as a property on entities.
# So when we change it, we need to sync all entities.
await self.async_sync_entities_all()
sync_entities = True
# If entity prefs are the same or we have filter in config.yaml,
# don't sync.
@@ -215,12 +217,16 @@ class CloudGoogleConfig(AbstractConfig):
if self.enabled and not self.is_local_sdk_active:
self.async_enable_local_sdk()
sync_entities = True
elif not self.enabled and self.is_local_sdk_active:
self.async_disable_local_sdk()
self._cur_entity_prefs = prefs.google_entity_configs
self._cur_default_expose = prefs.google_default_expose
if sync_entities:
await self.async_sync_entities_all()
@callback
def _handle_entity_registry_updated(self, event: Event) -> None:
"""Handle when entity registry updated."""

View File

@@ -2,7 +2,7 @@
"domain": "frontend",
"name": "Home Assistant Frontend",
"documentation": "https://www.home-assistant.io/integrations/frontend",
"requirements": ["home-assistant-frontend==20220526.0"],
"requirements": ["home-assistant-frontend==20220531.0"],
"dependencies": [
"api",
"auth",

View File

@@ -199,18 +199,24 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
_LOGGER.warning(
"Configuration of Google Calendar in YAML in configuration.yaml is "
"is deprecated and will be removed in a future release; Your existing "
"OAuth Application Credentials and other settings have been imported "
"OAuth Application Credentials and access settings have been imported "
"into the UI automatically and can be safely removed from your "
"configuration.yaml file"
)
if conf.get(CONF_TRACK_NEW) is False:
# The track_new as False would previously result in new entries
# in google_calendars.yaml with track set to Fasle which is
# handled at calendar entity creation time.
_LOGGER.warning(
"You must manually set the integration System Options in the "
"UI to disable newly discovered entities going forward"
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Google from a config entry."""
hass.data.setdefault(DOMAIN, {})
async_upgrade_entry(hass, entry)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
@@ -233,10 +239,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
access = FeatureAccess[entry.options[CONF_CALENDAR_ACCESS]]
token_scopes = session.token.get("scope", [])
if access.scope not in token_scopes:
_LOGGER.debug("Scope '%s' not in scopes '%s'", access.scope, token_scopes)
if not async_entry_has_scopes(hass, entry):
raise ConfigEntryAuthFailed(
"Required scopes are not available, reauth required"
)
@@ -247,37 +250,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await async_setup_services(hass, calendar_service)
# Only expose the add event service if we have the correct permissions
if access is FeatureAccess.read_write:
if get_feature_access(hass, entry) is FeatureAccess.read_write:
await async_setup_add_event_service(hass, calendar_service)
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
# Reload entry when options are updated
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
return True
def async_upgrade_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Upgrade the config entry if needed."""
if DATA_CONFIG not in hass.data[DOMAIN] and entry.options:
return
options = (
entry.options
if entry.options
else {
CONF_CALENDAR_ACCESS: get_feature_access(hass).name,
}
)
disable_new_entities = (
not hass.data[DOMAIN].get(DATA_CONFIG, {}).get(CONF_TRACK_NEW, True)
)
hass.config_entries.async_update_entry(
entry,
options=options,
pref_disable_new_entities=disable_new_entities,
)
def async_entry_has_scopes(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Verify that the config entry desired scope is present in the oauth token."""
access = get_feature_access(hass, entry)
token_scopes = entry.data.get("token", {}).get("scope", [])
return access.scope in token_scopes
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -286,8 +273,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload the config entry when it changed."""
await hass.config_entries.async_reload(entry.entry_id)
"""Reload config entry if the access options change."""
if not async_entry_has_scopes(hass, entry):
await hass.config_entries.async_reload(entry.entry_id)
async def async_setup_services(

View File

@@ -19,6 +19,7 @@ from oauth2client.client import (
)
from homeassistant.components.application_credentials import AuthImplementation
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.event import async_track_time_interval
@@ -127,8 +128,17 @@ class DeviceFlow:
)
def get_feature_access(hass: HomeAssistant) -> FeatureAccess:
def get_feature_access(
hass: HomeAssistant, config_entry: ConfigEntry | None = None
) -> FeatureAccess:
"""Return the desired calendar feature access."""
if (
config_entry
and config_entry.options
and CONF_CALENDAR_ACCESS in config_entry.options
):
return FeatureAccess[config_entry.options[CONF_CALENDAR_ACCESS]]
# This may be called during config entry setup without integration setup running when there
# is no google entry in configuration.yaml
return cast(

View File

@@ -213,6 +213,9 @@ class AbstractConfig(ABC):
async def async_sync_entities_all(self):
"""Sync all entities to Google for all registered agents."""
if not self._store.agent_user_ids:
return 204
res = await gather(
*(
self.async_sync_entities(agent_user_id)

View File

@@ -12,6 +12,7 @@ from typing_extensions import ParamSpec
from homeassistant.components import media_source
from homeassistant.components.media_player import (
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityFeature,
)
@@ -73,6 +74,14 @@ CONTROL_TO_SUPPORT = {
heos_const.CONTROL_PLAY_NEXT: MediaPlayerEntityFeature.NEXT_TRACK,
}
HA_HEOS_ENQUEUE_MAP = {
None: heos_const.ADD_QUEUE_REPLACE_AND_PLAY,
MediaPlayerEnqueue.ADD: heos_const.ADD_QUEUE_ADD_TO_END,
MediaPlayerEnqueue.REPLACE: heos_const.ADD_QUEUE_REPLACE_AND_PLAY,
MediaPlayerEnqueue.NEXT: heos_const.ADD_QUEUE_PLAY_NEXT,
MediaPlayerEnqueue.PLAY: heos_const.ADD_QUEUE_PLAY_NOW,
}
_LOGGER = logging.getLogger(__name__)
@@ -224,11 +233,8 @@ class HeosMediaPlayer(MediaPlayerEntity):
playlist = next((p for p in playlists if p.name == media_id), None)
if not playlist:
raise ValueError(f"Invalid playlist '{media_id}'")
add_queue_option = (
heos_const.ADD_QUEUE_ADD_TO_END
if kwargs.get(ATTR_MEDIA_ENQUEUE)
else heos_const.ADD_QUEUE_REPLACE_AND_PLAY
)
add_queue_option = HA_HEOS_ENQUEUE_MAP.get(kwargs.get(ATTR_MEDIA_ENQUEUE))
await self._player.add_to_queue(playlist, add_queue_option)
return

View File

@@ -76,8 +76,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Hive from a config entry."""
websession = aiohttp_client.async_get_clientsession(hass)
hive = Hive(websession)
hive_config = dict(entry.data)
hive = Hive(
websession,
deviceGroupKey=hive_config["device_data"][0],
deviceKey=hive_config["device_data"][1],
devicePassword=hive_config["device_data"][2],
)
hive_config["options"] = {}
hive_config["options"].update(

View File

@@ -103,6 +103,7 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
# Setup the config entry
self.data["tokens"] = self.tokens
self.data["device_data"] = await self.hive_auth.getDeviceData()
if self.context["source"] == config_entries.SOURCE_REAUTH:
self.hass.config_entries.async_update_entry(
self.entry, title=self.data["username"], data=self.data

View File

@@ -3,7 +3,7 @@
"name": "Hive",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hive",
"requirements": ["pyhiveapi==0.4.2"],
"requirements": ["pyhiveapi==0.5.4"],
"codeowners": ["@Rendili", "@KJonline"],
"iot_class": "cloud_polling",
"loggers": ["apyhiveapi"]

View File

@@ -1,6 +1,7 @@
"""Diagnostics support for P1 Monitor."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
@@ -21,10 +22,10 @@ async def async_get_config_entry_diagnostics(
coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
meter_data = {
"device": coordinator.api.device.todict(),
"data": coordinator.api.data.todict(),
"state": coordinator.api.state.todict()
if coordinator.api.state is not None
"device": asdict(coordinator.data["device"]),
"data": asdict(coordinator.data["data"]),
"state": asdict(coordinator.data["state"])
if coordinator.data["state"] is not None
else None,
}

View File

@@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, IALARMXR_TO_HASS
from .const import DOMAIN
from .utils import async_get_ialarmxr_mac
PLATFORMS = [Platform.ALARM_CONTROL_PANEL]
@@ -74,7 +74,7 @@ class IAlarmXRDataUpdateCoordinator(DataUpdateCoordinator):
def __init__(self, hass: HomeAssistant, ialarmxr: IAlarmXR, mac: str) -> None:
"""Initialize global iAlarm data updater."""
self.ialarmxr: IAlarmXR = ialarmxr
self.state: str | None = None
self.state: int | None = None
self.host: str = ialarmxr.host
self.mac: str = mac
@@ -90,7 +90,7 @@ class IAlarmXRDataUpdateCoordinator(DataUpdateCoordinator):
status: int = self.ialarmxr.get_status()
_LOGGER.debug("iAlarmXR status: %s", status)
self.state = IALARMXR_TO_HASS.get(status)
self.state = status
async def _async_update_data(self) -> None:
"""Fetch data from iAlarmXR."""

View File

@@ -1,11 +1,19 @@
"""Interfaces with iAlarmXR control panels."""
from __future__ import annotations
from pyialarmxr import IAlarmXR
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_DISARMED,
STATE_ALARM_TRIGGERED,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry
from homeassistant.helpers.entity import DeviceInfo
@@ -15,6 +23,13 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import IAlarmXRDataUpdateCoordinator
from .const import DOMAIN
IALARMXR_TO_HASS = {
IAlarmXR.ARMED_AWAY: STATE_ALARM_ARMED_AWAY,
IAlarmXR.ARMED_STAY: STATE_ALARM_ARMED_HOME,
IAlarmXR.DISARMED: STATE_ALARM_DISARMED,
IAlarmXR.TRIGGERED: STATE_ALARM_TRIGGERED,
}
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
@@ -24,7 +39,9 @@ async def async_setup_entry(
async_add_entities([IAlarmXRPanel(coordinator)])
class IAlarmXRPanel(CoordinatorEntity, AlarmControlPanelEntity):
class IAlarmXRPanel(
CoordinatorEntity[IAlarmXRDataUpdateCoordinator], AlarmControlPanelEntity
):
"""Representation of an iAlarmXR device."""
_attr_supported_features = (
@@ -37,7 +54,6 @@ class IAlarmXRPanel(CoordinatorEntity, AlarmControlPanelEntity):
def __init__(self, coordinator: IAlarmXRDataUpdateCoordinator) -> None:
"""Initialize the alarm panel."""
super().__init__(coordinator)
self.coordinator: IAlarmXRDataUpdateCoordinator = coordinator
self._attr_unique_id = coordinator.mac
self._attr_device_info = DeviceInfo(
manufacturer="Antifurto365 - Meian",
@@ -48,7 +64,7 @@ class IAlarmXRPanel(CoordinatorEntity, AlarmControlPanelEntity):
@property
def state(self) -> str | None:
"""Return the state of the device."""
return self.coordinator.state
return IALARMXR_TO_HASS.get(self.coordinator.state)
def alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""

View File

@@ -72,13 +72,13 @@ class IAlarmConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"IAlarmXRGenericException with message: [ %s ]",
ialarmxr_exception.message,
)
errors["base"] = "unknown"
errors["base"] = "cannot_connect"
except IAlarmXRSocketTimeoutException as ialarmxr_socket_timeout_exception:
_LOGGER.debug(
"IAlarmXRSocketTimeoutException with message: [ %s ]",
ialarmxr_socket_timeout_exception.message,
)
errors["base"] = "unknown"
errors["base"] = "timeout"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"

View File

@@ -1,18 +1,3 @@
"""Constants for the iAlarmXR integration."""
from pyialarmxr import IAlarmXR
from homeassistant.const import (
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_DISARMED,
STATE_ALARM_TRIGGERED,
)
DOMAIN = "ialarm_xr"
IALARMXR_TO_HASS = {
IAlarmXR.ARMED_AWAY: STATE_ALARM_ARMED_AWAY,
IAlarmXR.ARMED_STAY: STATE_ALARM_ARMED_HOME,
IAlarmXR.DISARMED: STATE_ALARM_DISARMED,
IAlarmXR.TRIGGERED: STATE_ALARM_TRIGGERED,
}

View File

@@ -1,8 +1,8 @@
{
"domain": "ialarm_xr",
"name": "Antifurto365 iAlarmXR",
"documentation": "https://www.home-assistant.io/integrations/ialarmxr",
"requirements": ["pyialarmxr==1.0.13"],
"documentation": "https://www.home-assistant.io/integrations/ialarm_xr",
"requirements": ["pyialarmxr==1.0.18"],
"codeowners": ["@bigmoby"],
"config_flow": true,
"iot_class": "cloud_polling",

View File

@@ -12,6 +12,7 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout": "[%key:common::config_flow::error::timeout_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {

View File

@@ -5,6 +5,7 @@
},
"error": {
"cannot_connect": "Failed to connect",
"timeout": "Timeout establishing connection",
"unknown": "Unexpected error"
},
"step": {

View File

@@ -154,17 +154,26 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
self._method = integration_method
self._attr_name = name if name is not None else f"{source_entity} integral"
self._unit_template = (
f"{'' if unit_prefix is None else unit_prefix}{{}}{unit_time}"
)
self._unit_template = f"{'' if unit_prefix is None else unit_prefix}{{}}"
self._unit_of_measurement = None
self._unit_prefix = UNIT_PREFIXES[unit_prefix]
self._unit_time = UNIT_TIME[unit_time]
self._unit_time_str = unit_time
self._attr_state_class = SensorStateClass.TOTAL
self._attr_icon = "mdi:chart-histogram"
self._attr_should_poll = False
self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity}
def _unit(self, source_unit: str) -> str:
"""Derive unit from the source sensor, SI prefix and time unit."""
unit_time = self._unit_time_str
if source_unit.endswith(f"/{unit_time}"):
integral_unit = source_unit[0 : (-(1 + len(unit_time)))]
else:
integral_unit = f"{source_unit}{unit_time}"
return self._unit_template.format(integral_unit)
async def async_added_to_hass(self):
"""Handle entity which will be added."""
await super().async_added_to_hass()
@@ -203,7 +212,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
update_state = False
unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if unit is not None:
new_unit_of_measurement = self._unit_template.format(unit)
new_unit_of_measurement = self._unit(unit)
if self._unit_of_measurement != new_unit_of_measurement:
self._unit_of_measurement = new_unit_of_measurement
update_state = True

View File

@@ -6,6 +6,7 @@ from typing import Any
from pyisy.constants import (
CMD_CLIMATE_FAN_SETTING,
CMD_CLIMATE_MODE,
ISY_VALUE_UNKNOWN,
PROP_HEAT_COOL_STATE,
PROP_HUMIDITY,
PROP_SETPOINT_COOL,
@@ -116,6 +117,8 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity):
"""Return the current humidity."""
if not (humidity := self._node.aux_properties.get(PROP_HUMIDITY)):
return None
if humidity == ISY_VALUE_UNKNOWN:
return None
return int(humidity.value)
@property

View File

@@ -7,7 +7,10 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import frontend
from homeassistant.components.recorder.const import DOMAIN as RECORDER_DOMAIN
from homeassistant.components.recorder.filters import (
extract_include_exclude_filter_conf,
merge_include_exclude_filters,
sqlalchemy_filter_from_include_exclude_conf,
)
from homeassistant.const import (
@@ -115,9 +118,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass, "logbook", "logbook", "hass:format-list-bulleted-type"
)
if conf := config.get(DOMAIN, {}):
filters = sqlalchemy_filter_from_include_exclude_conf(conf)
entities_filter = convert_include_exclude_filter(conf)
recorder_conf = config.get(RECORDER_DOMAIN, {})
logbook_conf = config.get(DOMAIN, {})
recorder_filter = extract_include_exclude_filter_conf(recorder_conf)
logbook_filter = extract_include_exclude_filter_conf(logbook_conf)
merged_filter = merge_include_exclude_filters(recorder_filter, logbook_filter)
possible_merged_entities_filter = convert_include_exclude_filter(merged_filter)
if not possible_merged_entities_filter.empty_filter:
filters = sqlalchemy_filter_from_include_exclude_conf(merged_filter)
entities_filter = possible_merged_entities_filter
else:
filters = None
entities_filter = None

View File

@@ -132,6 +132,12 @@ def async_subscribe_events(
if not _is_state_filtered(ent_reg, state):
target(event)
if device_ids and not entity_ids:
# No entities to subscribe to but we are filtering
# on device ids so we do not want to get any state
# changed events
return
if entity_ids:
subscriptions.append(
async_track_state_change_event(

View File

@@ -407,7 +407,8 @@ class ContextAugmenter:
def _rows_match(row: Row | EventAsRow, other_row: Row | EventAsRow) -> bool:
"""Check of rows match by using the same method as Events __hash__."""
if (
(state_id := row.state_id) is not None
row is other_row
or (state_id := row.state_id) is not None
and state_id == other_row.state_id
or (event_id := row.event_id) is not None
and event_id == other_row.event_id

View File

@@ -356,7 +356,7 @@ async def ws_event_stream(
)
await _async_wait_for_recorder_sync(hass)
if not subscriptions:
if msg_id not in connection.subscriptions:
# Unsubscribe happened while waiting for recorder
return
@@ -388,6 +388,8 @@ async def ws_event_stream(
if not subscriptions:
# Unsubscribe happened while waiting for formatted events
# or there are no supported entities (all UOM or state class)
# or devices
return
live_stream.task = asyncio.create_task(
@@ -475,7 +477,7 @@ async def ws_get_events(
)
connection.send_message(
await hass.async_add_executor_job(
await get_instance(hass).async_add_executor_job(
_ws_formatted_get_events,
msg["id"],
start_time,

View File

@@ -76,6 +76,7 @@ from .const import ( # noqa: F401
ATTR_INPUT_SOURCE_LIST,
ATTR_MEDIA_ALBUM_ARTIST,
ATTR_MEDIA_ALBUM_NAME,
ATTR_MEDIA_ANNOUNCE,
ATTR_MEDIA_ARTIST,
ATTR_MEDIA_CHANNEL,
ATTR_MEDIA_CONTENT_ID,
@@ -147,6 +148,19 @@ ENTITY_IMAGE_CACHE = {CACHE_IMAGES: collections.OrderedDict(), CACHE_MAXSIZE: 16
SCAN_INTERVAL = dt.timedelta(seconds=10)
class MediaPlayerEnqueue(StrEnum):
"""Enqueue types for playing media."""
# add given media item to end of the queue
ADD = "add"
# play the given media item next, keep queue
NEXT = "next"
# play the given media item now, keep queue
PLAY = "play"
# play the given media item now, clear queue
REPLACE = "replace"
class MediaPlayerDeviceClass(StrEnum):
"""Device class for media players."""
@@ -169,7 +183,10 @@ DEVICE_CLASS_RECEIVER = MediaPlayerDeviceClass.RECEIVER.value
MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = {
vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string,
vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string,
vol.Optional(ATTR_MEDIA_ENQUEUE): cv.boolean,
vol.Exclusive(ATTR_MEDIA_ENQUEUE, "enqueue_announce"): vol.Any(
cv.boolean, vol.Coerce(MediaPlayerEnqueue)
),
vol.Exclusive(ATTR_MEDIA_ANNOUNCE, "enqueue_announce"): cv.boolean,
vol.Optional(ATTR_MEDIA_EXTRA, default={}): dict,
}
@@ -350,10 +367,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"async_select_sound_mode",
[MediaPlayerEntityFeature.SELECT_SOUND_MODE],
)
# Remove in Home Assistant 2022.9
def _rewrite_enqueue(value):
"""Rewrite the enqueue value."""
if ATTR_MEDIA_ENQUEUE not in value:
pass
elif value[ATTR_MEDIA_ENQUEUE] is True:
value[ATTR_MEDIA_ENQUEUE] = MediaPlayerEnqueue.ADD
_LOGGER.warning(
"Playing media with enqueue set to True is deprecated. Use 'add' instead"
)
elif value[ATTR_MEDIA_ENQUEUE] is False:
value[ATTR_MEDIA_ENQUEUE] = MediaPlayerEnqueue.PLAY
_LOGGER.warning(
"Playing media with enqueue set to False is deprecated. Use 'play' instead"
)
return value
component.async_register_entity_service(
SERVICE_PLAY_MEDIA,
vol.All(
cv.make_entity_service_schema(MEDIA_PLAYER_PLAY_MEDIA_SCHEMA),
_rewrite_enqueue,
_rename_keys(
media_type=ATTR_MEDIA_CONTENT_TYPE,
media_id=ATTR_MEDIA_CONTENT_ID,

View File

@@ -10,6 +10,7 @@ ATTR_ENTITY_PICTURE_LOCAL = "entity_picture_local"
ATTR_GROUP_MEMBERS = "group_members"
ATTR_INPUT_SOURCE = "source"
ATTR_INPUT_SOURCE_LIST = "source_list"
ATTR_MEDIA_ANNOUNCE = "announce"
ATTR_MEDIA_ALBUM_ARTIST = "media_album_artist"
ATTR_MEDIA_ALBUM_NAME = "media_album_name"
ATTR_MEDIA_ARTIST = "media_artist"

View File

@@ -27,7 +27,6 @@ from .const import (
ATTR_INPUT_SOURCE,
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
ATTR_SOUND_MODE,
@@ -118,7 +117,7 @@ async def _async_reproduce_states(
if features & MediaPlayerEntityFeature.PLAY_MEDIA:
await call_service(
SERVICE_PLAY_MEDIA,
[ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_ENQUEUE],
[ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_CONTENT_ID],
)
already_playing = True

View File

@@ -151,6 +151,29 @@ play_media:
selector:
text:
enqueue:
name: Enqueue
description: If the content should be played now or be added to the queue.
required: false
selector:
select:
options:
- label: "Play now"
value: "play"
- label: "Play next"
value: "next"
- label: "Add to queue"
value: "add"
- label: "Play now and clear queue"
value: "replace"
announce:
name: Announce
description: If the media should be played as an announcement.
required: false
example: "true"
selector:
boolean:
select_source:
name: Select source
description: Send the media player the command to change input source.

View File

@@ -102,7 +102,8 @@ class MikrotikHubTracker(ScannerEntity):
@property
def name(self) -> str:
"""Return the name of the client."""
return self.device.name
# Stringify to ensure we return a string
return str(self.device.name)
@property
def hostname(self) -> str:

View File

@@ -9,6 +9,7 @@ from homeassistant.components.cover import (
ATTR_TILT_POSITION,
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -64,6 +65,10 @@ TILT_DEVICE_MAP = {
BlindType.VerticalBlindRight: CoverDeviceClass.BLIND,
}
TILT_ONLY_DEVICE_MAP = {
BlindType.WoodShutter: CoverDeviceClass.BLIND,
}
TDBU_DEVICE_MAP = {
BlindType.TopDownBottomUp: CoverDeviceClass.SHADE,
}
@@ -108,6 +113,16 @@ async def async_setup_entry(
)
)
elif blind.type in TILT_ONLY_DEVICE_MAP:
entities.append(
MotionTiltOnlyDevice(
coordinator,
blind,
TILT_ONLY_DEVICE_MAP[blind.type],
sw_version,
)
)
elif blind.type in TDBU_DEVICE_MAP:
entities.append(
MotionTDBUDevice(
@@ -356,6 +371,49 @@ class MotionTiltDevice(MotionPositionDevice):
await self.hass.async_add_executor_job(self._blind.Stop)
class MotionTiltOnlyDevice(MotionTiltDevice):
"""Representation of a Motion Blind Device."""
_restore_tilt = False
@property
def supported_features(self):
"""Flag supported features."""
supported_features = (
CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT
)
if self.current_cover_tilt_position is not None:
supported_features |= CoverEntityFeature.SET_TILT_POSITION
return supported_features
@property
def current_cover_position(self):
"""Return current position of cover."""
return None
@property
def is_closed(self):
"""Return if the cover is closed or not."""
if self._blind.angle is None:
return None
return self._blind.angle == 0
async def async_set_absolute_position(self, **kwargs):
"""Move the cover to a specific absolute position (see TDBU)."""
angle = kwargs.get(ATTR_TILT_POSITION)
if angle is not None:
angle = angle * 180 / 100
async with self._api_lock:
await self.hass.async_add_executor_job(
self._blind.Set_angle,
angle,
)
class MotionTDBUDevice(MotionPositionDevice):
"""Representation of a Motion Top Down Bottom Up blind Device."""

View File

@@ -3,7 +3,7 @@
"name": "Motion Blinds",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/motion_blinds",
"requirements": ["motionblinds==0.6.7"],
"requirements": ["motionblinds==0.6.8"],
"dependencies": ["network"],
"dhcp": [
{ "registered_devices": true },

View File

@@ -685,14 +685,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# User has configuration.yaml config, warn about config entry overrides
elif any(key in conf for key in entry.data):
shared_keys = conf.keys() & entry.data.keys()
override = {k: entry.data[k] for k in shared_keys}
override = {k: entry.data[k] for k in shared_keys if conf[k] != entry.data[k]}
if CONF_PASSWORD in override:
override[CONF_PASSWORD] = "********"
_LOGGER.warning(
"Deprecated configuration settings found in configuration.yaml. "
"These settings from your configuration entry will override: %s",
override,
)
if override:
_LOGGER.warning(
"Deprecated configuration settings found in configuration.yaml. "
"These settings from your configuration entry will override: %s",
override,
)
# Merge advanced configuration values from configuration.yaml
conf = _merge_extended_config(entry, conf)

View File

@@ -85,9 +85,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Receive touch event."""
gesture_type = TOUCH_GESTURE_TRIGGER_MAP.get(event.gesture_id)
if gesture_type is None:
_LOGGER.debug("Received unknown touch gesture ID %s", event.gesture_id)
_LOGGER.warning(
"Received unknown touch gesture ID %s", event.gesture_id
)
return
_LOGGER.warning("Received touch gesture %s", gesture_type)
_LOGGER.debug("Received touch gesture %s", gesture_type)
hass.bus.async_fire(
NANOLEAF_EVENT,
{CONF_DEVICE_ID: device_entry.id, CONF_TYPE: gesture_type},

View File

@@ -4,7 +4,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/plex",
"requirements": [
"plexapi==4.11.1",
"plexapi==4.11.2",
"plexauth==0.0.6",
"plexwebsocket==0.0.13"
],

View File

@@ -50,10 +50,7 @@ from .const import (
LOGGER,
)
DEFAULT_ATTRIBUTION = "Data provided by Green Electronics LLC"
DEFAULT_ICON = "mdi:water"
DEFAULT_SSL = True
DEFAULT_UPDATE_INTERVAL = timedelta(seconds=15)
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)

View File

@@ -1,6 +1,5 @@
"""This platform provides binary sensors for key RainMachine data."""
from dataclasses import dataclass
from functools import partial
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
@@ -21,6 +20,7 @@ from .const import (
DOMAIN,
)
from .model import RainMachineDescriptionMixinApiCategory
from .util import key_exists
TYPE_FLOW_SENSOR = "flow_sensor"
TYPE_FREEZE = "freeze"
@@ -46,6 +46,7 @@ BINARY_SENSOR_DESCRIPTIONS = (
name="Flow Sensor",
icon="mdi:water-pump",
api_category=DATA_PROVISION_SETTINGS,
data_key="useFlowSensor",
),
RainMachineBinarySensorDescription(
key=TYPE_FREEZE,
@@ -53,6 +54,7 @@ BINARY_SENSOR_DESCRIPTIONS = (
icon="mdi:cancel",
entity_category=EntityCategory.DIAGNOSTIC,
api_category=DATA_RESTRICTIONS_CURRENT,
data_key="freeze",
),
RainMachineBinarySensorDescription(
key=TYPE_FREEZE_PROTECTION,
@@ -60,6 +62,7 @@ BINARY_SENSOR_DESCRIPTIONS = (
icon="mdi:weather-snowy",
entity_category=EntityCategory.DIAGNOSTIC,
api_category=DATA_RESTRICTIONS_UNIVERSAL,
data_key="freezeProtectEnabled",
),
RainMachineBinarySensorDescription(
key=TYPE_HOT_DAYS,
@@ -67,6 +70,7 @@ BINARY_SENSOR_DESCRIPTIONS = (
icon="mdi:thermometer-lines",
entity_category=EntityCategory.DIAGNOSTIC,
api_category=DATA_RESTRICTIONS_UNIVERSAL,
data_key="hotDaysExtraWatering",
),
RainMachineBinarySensorDescription(
key=TYPE_HOURLY,
@@ -75,6 +79,7 @@ BINARY_SENSOR_DESCRIPTIONS = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
api_category=DATA_RESTRICTIONS_CURRENT,
data_key="hourly",
),
RainMachineBinarySensorDescription(
key=TYPE_MONTH,
@@ -83,6 +88,7 @@ BINARY_SENSOR_DESCRIPTIONS = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
api_category=DATA_RESTRICTIONS_CURRENT,
data_key="month",
),
RainMachineBinarySensorDescription(
key=TYPE_RAINDELAY,
@@ -91,6 +97,7 @@ BINARY_SENSOR_DESCRIPTIONS = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
api_category=DATA_RESTRICTIONS_CURRENT,
data_key="rainDelay",
),
RainMachineBinarySensorDescription(
key=TYPE_RAINSENSOR,
@@ -99,6 +106,7 @@ BINARY_SENSOR_DESCRIPTIONS = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
api_category=DATA_RESTRICTIONS_CURRENT,
data_key="rainSensor",
),
RainMachineBinarySensorDescription(
key=TYPE_WEEKDAY,
@@ -107,6 +115,7 @@ BINARY_SENSOR_DESCRIPTIONS = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
api_category=DATA_RESTRICTIONS_CURRENT,
data_key="weekDay",
),
)
@@ -118,35 +127,20 @@ async def async_setup_entry(
controller = hass.data[DOMAIN][entry.entry_id][DATA_CONTROLLER]
coordinators = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR]
@callback
def async_get_sensor_by_api_category(api_category: str) -> partial:
"""Generate the appropriate sensor object for an API category."""
if api_category == DATA_PROVISION_SETTINGS:
return partial(
ProvisionSettingsBinarySensor,
entry,
coordinators[DATA_PROVISION_SETTINGS],
)
if api_category == DATA_RESTRICTIONS_CURRENT:
return partial(
CurrentRestrictionsBinarySensor,
entry,
coordinators[DATA_RESTRICTIONS_CURRENT],
)
return partial(
UniversalRestrictionsBinarySensor,
entry,
coordinators[DATA_RESTRICTIONS_UNIVERSAL],
)
api_category_sensor_map = {
DATA_PROVISION_SETTINGS: ProvisionSettingsBinarySensor,
DATA_RESTRICTIONS_CURRENT: CurrentRestrictionsBinarySensor,
DATA_RESTRICTIONS_UNIVERSAL: UniversalRestrictionsBinarySensor,
}
async_add_entities(
[
async_get_sensor_by_api_category(description.api_category)(
controller, description
api_category_sensor_map[description.api_category](
entry, coordinator, controller, description
)
for description in BINARY_SENSOR_DESCRIPTIONS
if (coordinator := coordinators[description.api_category]) is not None
and key_exists(coordinator.data, description.data_key)
]
)
@@ -158,17 +152,17 @@ class CurrentRestrictionsBinarySensor(RainMachineEntity, BinarySensorEntity):
def update_from_latest_data(self) -> None:
"""Update the state."""
if self.entity_description.key == TYPE_FREEZE:
self._attr_is_on = self.coordinator.data["freeze"]
self._attr_is_on = self.coordinator.data.get("freeze")
elif self.entity_description.key == TYPE_HOURLY:
self._attr_is_on = self.coordinator.data["hourly"]
self._attr_is_on = self.coordinator.data.get("hourly")
elif self.entity_description.key == TYPE_MONTH:
self._attr_is_on = self.coordinator.data["month"]
self._attr_is_on = self.coordinator.data.get("month")
elif self.entity_description.key == TYPE_RAINDELAY:
self._attr_is_on = self.coordinator.data["rainDelay"]
self._attr_is_on = self.coordinator.data.get("rainDelay")
elif self.entity_description.key == TYPE_RAINSENSOR:
self._attr_is_on = self.coordinator.data["rainSensor"]
self._attr_is_on = self.coordinator.data.get("rainSensor")
elif self.entity_description.key == TYPE_WEEKDAY:
self._attr_is_on = self.coordinator.data["weekDay"]
self._attr_is_on = self.coordinator.data.get("weekDay")
class ProvisionSettingsBinarySensor(RainMachineEntity, BinarySensorEntity):
@@ -188,6 +182,6 @@ class UniversalRestrictionsBinarySensor(RainMachineEntity, BinarySensorEntity):
def update_from_latest_data(self) -> None:
"""Update the state."""
if self.entity_description.key == TYPE_FREEZE_PROTECTION:
self._attr_is_on = self.coordinator.data["freezeProtectEnabled"]
self._attr_is_on = self.coordinator.data.get("freezeProtectEnabled")
elif self.entity_description.key == TYPE_HOT_DAYS:
self._attr_is_on = self.coordinator.data["hotDaysExtraWatering"]
self._attr_is_on = self.coordinator.data.get("hotDaysExtraWatering")

View File

@@ -3,7 +3,7 @@
"name": "RainMachine",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rainmachine",
"requirements": ["regenmaschine==2022.05.0"],
"requirements": ["regenmaschine==2022.05.1"],
"codeowners": ["@bachya"],
"iot_class": "local_polling",
"homekit": {

View File

@@ -7,6 +7,7 @@ class RainMachineDescriptionMixinApiCategory:
"""Define an entity description mixin for binary and regular sensors."""
api_category: str
data_key: str
@dataclass

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from functools import partial
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -33,6 +32,7 @@ from .model import (
RainMachineDescriptionMixinApiCategory,
RainMachineDescriptionMixinUid,
)
from .util import key_exists
DEFAULT_ZONE_COMPLETION_TIME_WOBBLE_TOLERANCE = timedelta(seconds=5)
@@ -68,6 +68,7 @@ SENSOR_DESCRIPTIONS = (
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
api_category=DATA_PROVISION_SETTINGS,
data_key="flowSensorClicksPerCubicMeter",
),
RainMachineSensorDescriptionApiCategory(
key=TYPE_FLOW_SENSOR_CONSUMED_LITERS,
@@ -78,6 +79,7 @@ SENSOR_DESCRIPTIONS = (
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
api_category=DATA_PROVISION_SETTINGS,
data_key="flowSensorWateringClicks",
),
RainMachineSensorDescriptionApiCategory(
key=TYPE_FLOW_SENSOR_START_INDEX,
@@ -87,6 +89,7 @@ SENSOR_DESCRIPTIONS = (
native_unit_of_measurement="index",
entity_registry_enabled_default=False,
api_category=DATA_PROVISION_SETTINGS,
data_key="flowSensorStartIndex",
),
RainMachineSensorDescriptionApiCategory(
key=TYPE_FLOW_SENSOR_WATERING_CLICKS,
@@ -97,6 +100,7 @@ SENSOR_DESCRIPTIONS = (
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
api_category=DATA_PROVISION_SETTINGS,
data_key="flowSensorWateringClicks",
),
RainMachineSensorDescriptionApiCategory(
key=TYPE_FREEZE_TEMP,
@@ -107,6 +111,7 @@ SENSOR_DESCRIPTIONS = (
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
api_category=DATA_RESTRICTIONS_UNIVERSAL,
data_key="freezeProtectTemp",
),
)
@@ -118,27 +123,18 @@ async def async_setup_entry(
controller = hass.data[DOMAIN][entry.entry_id][DATA_CONTROLLER]
coordinators = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR]
@callback
def async_get_sensor_by_api_category(api_category: str) -> partial:
"""Generate the appropriate sensor object for an API category."""
if api_category == DATA_PROVISION_SETTINGS:
return partial(
ProvisionSettingsSensor,
entry,
coordinators[DATA_PROVISION_SETTINGS],
)
return partial(
UniversalRestrictionsSensor,
entry,
coordinators[DATA_RESTRICTIONS_UNIVERSAL],
)
api_category_sensor_map = {
DATA_PROVISION_SETTINGS: ProvisionSettingsSensor,
DATA_RESTRICTIONS_UNIVERSAL: UniversalRestrictionsSensor,
}
sensors = [
async_get_sensor_by_api_category(description.api_category)(
controller, description
api_category_sensor_map[description.api_category](
entry, coordinator, controller, description
)
for description in SENSOR_DESCRIPTIONS
if (coordinator := coordinators[description.api_category]) is not None
and key_exists(coordinator.data, description.data_key)
]
zone_coordinator = coordinators[DATA_ZONES]
@@ -198,7 +194,7 @@ class UniversalRestrictionsSensor(RainMachineEntity, SensorEntity):
def update_from_latest_data(self) -> None:
"""Update the state."""
if self.entity_description.key == TYPE_FREEZE_TEMP:
self._attr_native_value = self.coordinator.data["freezeProtectTemp"]
self._attr_native_value = self.coordinator.data.get("freezeProtectTemp")
class ZoneTimeRemainingSensor(RainMachineEntity, SensorEntity):

View File

@@ -389,23 +389,32 @@ class RainMachineZone(RainMachineActivitySwitch):
self._attr_is_on = bool(data["state"])
self._attr_extra_state_attributes.update(
{
ATTR_AREA: round(data["waterSense"]["area"], 2),
ATTR_CURRENT_CYCLE: data["cycle"],
ATTR_FIELD_CAPACITY: round(data["waterSense"]["fieldCapacity"], 2),
ATTR_ID: data["uid"],
ATTR_NO_CYCLES: data["noOfCycles"],
ATTR_PRECIP_RATE: round(data["waterSense"]["precipitationRate"], 2),
ATTR_RESTRICTIONS: data["restriction"],
ATTR_SLOPE: SLOPE_TYPE_MAP.get(data["slope"], 99),
ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(data["soil"], 99),
ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(data["group_id"], 99),
ATTR_STATUS: RUN_STATE_MAP[data["state"]],
ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get(data.get("sun")),
ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(data["type"], 99),
}
)
attrs = {
ATTR_CURRENT_CYCLE: data["cycle"],
ATTR_ID: data["uid"],
ATTR_NO_CYCLES: data["noOfCycles"],
ATTR_RESTRICTIONS: data["restriction"],
ATTR_SLOPE: SLOPE_TYPE_MAP.get(data["slope"], 99),
ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(data["soil"], 99),
ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(data["group_id"], 99),
ATTR_STATUS: RUN_STATE_MAP[data["state"]],
ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get(data.get("sun")),
ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(data["type"], 99),
}
if "waterSense" in data:
if "area" in data["waterSense"]:
attrs[ATTR_AREA] = round(data["waterSense"]["area"], 2)
if "fieldCapacity" in data["waterSense"]:
attrs[ATTR_FIELD_CAPACITY] = round(
data["waterSense"]["fieldCapacity"], 2
)
if "precipitationRate" in data["waterSense"]:
attrs[ATTR_PRECIP_RATE] = round(
data["waterSense"]["precipitationRate"], 2
)
self._attr_extra_state_attributes.update(attrs)
class RainMachineZoneEnabled(RainMachineEnabledSwitch):

View File

@@ -0,0 +1,14 @@
"""Define RainMachine utilities."""
from __future__ import annotations
from typing import Any
def key_exists(data: dict[str, Any], search_key: str) -> bool:
"""Return whether a key exists in a nested dict."""
for key, value in data.items():
if key == search_key:
return True
if isinstance(value, dict):
return key_exists(value, search_key)
return False

View File

@@ -18,10 +18,47 @@ DOMAIN = "history"
HISTORY_FILTERS = "history_filters"
GLOB_TO_SQL_CHARS = {
42: "%", # *
46: "_", # .
ord("*"): "%",
ord("?"): "_",
ord("%"): "\\%",
ord("_"): "\\_",
ord("\\"): "\\\\",
}
FILTER_TYPES = (CONF_EXCLUDE, CONF_INCLUDE)
FITLER_MATCHERS = (CONF_ENTITIES, CONF_DOMAINS, CONF_ENTITY_GLOBS)
def extract_include_exclude_filter_conf(conf: ConfigType) -> dict[str, Any]:
"""Extract an include exclude filter from configuration.
This makes a copy so we do not alter the original data.
"""
return {
filter_type: {
matcher: set(conf.get(filter_type, {}).get(matcher, []))
for matcher in FITLER_MATCHERS
}
for filter_type in FILTER_TYPES
}
def merge_include_exclude_filters(
base_filter: dict[str, Any], add_filter: dict[str, Any]
) -> dict[str, Any]:
"""Merge two filters.
This makes a copy so we do not alter the original data.
"""
return {
filter_type: {
matcher: base_filter[filter_type][matcher]
| add_filter[filter_type][matcher]
for matcher in FITLER_MATCHERS
}
for filter_type in FILTER_TYPES
}
def sqlalchemy_filter_from_include_exclude_conf(conf: ConfigType) -> Filters | None:
"""Build a sql filter from config."""
@@ -43,13 +80,13 @@ class Filters:
def __init__(self) -> None:
"""Initialise the include and exclude filters."""
self.excluded_entities: list[str] = []
self.excluded_domains: list[str] = []
self.excluded_entity_globs: list[str] = []
self.excluded_entities: Iterable[str] = []
self.excluded_domains: Iterable[str] = []
self.excluded_entity_globs: Iterable[str] = []
self.included_entities: list[str] = []
self.included_domains: list[str] = []
self.included_entity_globs: list[str] = []
self.included_entities: Iterable[str] = []
self.included_domains: Iterable[str] = []
self.included_entity_globs: Iterable[str] = []
@property
def has_config(self) -> bool:
@@ -122,7 +159,9 @@ def _globs_to_like(
) -> ClauseList:
"""Translate glob to sql."""
return or_(
cast(column, Text()).like(encoder(glob_str.translate(GLOB_TO_SQL_CHARS)))
cast(column, Text()).like(
encoder(glob_str).translate(GLOB_TO_SQL_CHARS), escape="\\"
)
for glob_str in glob_strs
for column in columns
)

View File

@@ -2,7 +2,7 @@
"domain": "recorder",
"name": "Recorder",
"documentation": "https://www.home-assistant.io/integrations/recorder",
"requirements": ["sqlalchemy==1.4.36", "fnvhash==0.1.0", "lru-dict==1.1.7"],
"requirements": ["sqlalchemy==1.4.37", "fnvhash==0.1.0", "lru-dict==1.1.7"],
"codeowners": ["@home-assistant/core"],
"quality_scale": "internal",
"iot_class": "local_push"

View File

@@ -712,6 +712,17 @@ def _apply_update( # noqa: C901
elif new_version == 29:
# Recreate statistics_meta index to block duplicated statistic_id
_drop_index(session_maker, "statistics_meta", "ix_statistics_meta_statistic_id")
if engine.dialect.name == SupportedDialect.MYSQL:
# Ensure the row format is dynamic or the index
# unique will be too large
with session_scope(session=session_maker()) as session:
connection = session.connection()
# This is safe to run multiple times and fast since the table is small
connection.execute(
text(
"ALTER TABLE statistics_meta ENGINE=InnoDB, ROW_FORMAT=DYNAMIC"
)
)
try:
_create_index(
session_maker, "statistics_meta", "ix_statistics_meta_statistic_id"

View File

@@ -631,7 +631,7 @@ def find_legacy_event_state_and_attributes_and_data_ids_to_purge(
lambda: select(
Events.event_id, Events.data_id, States.state_id, States.attributes_id
)
.join(States, Events.event_id == States.event_id)
.outerjoin(States, Events.event_id == States.event_id)
.filter(Events.time_fired < purge_before)
.limit(MAX_ROWS_TO_PURGE)
)

View File

@@ -16,6 +16,7 @@ from ring_doorbell import Auth, Ring
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform, __version__
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.async_ import run_callback_threadsafe
@@ -146,6 +147,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
return True
class GlobalDataUpdater:
"""Data storage for single API endpoint."""

View File

@@ -3,7 +3,7 @@
"name": "SimpliSafe",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/simplisafe",
"requirements": ["simplisafe-python==2022.05.1"],
"requirements": ["simplisafe-python==2022.05.2"],
"codeowners": ["@bachya"],
"iot_class": "cloud_polling",
"dhcp": [

View File

@@ -405,7 +405,7 @@ async def _continue_flow(
(
flow
for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
if flow["context"]["unique_id"] == unique_id
if flow["context"].get("unique_id") == unique_id
),
None,
)

View File

@@ -18,12 +18,14 @@ import voluptuous as vol
from homeassistant.components import media_source, spotify
from homeassistant.components.media_player import (
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityFeature,
async_process_play_media_url,
)
from homeassistant.components.media_player.const import (
ATTR_INPUT_SOURCE,
ATTR_MEDIA_ANNOUNCE,
ATTR_MEDIA_ENQUEUE,
MEDIA_TYPE_ALBUM,
MEDIA_TYPE_ARTIST,
@@ -526,7 +528,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
self.coordinator.soco.clear_queue()
@soco_error()
def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None:
def play_media( # noqa: C901
self, media_type: str, media_id: str, **kwargs: Any
) -> None:
"""
Send the play_media command to the media player.
@@ -537,9 +541,13 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
If media_type is "playlist", media_id should be a Sonos
Playlist name. Otherwise, media_id should be a URI.
If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue.
"""
# Use 'replace' as the default enqueue option
enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE)
if kwargs.get(ATTR_MEDIA_ANNOUNCE):
# Temporary workaround until announce support is added
enqueue = MediaPlayerEnqueue.PLAY
if spotify.is_spotify_media_type(media_type):
media_type = spotify.resolve_spotify_media_type(media_type)
media_id = spotify.spotify_uri_from_media_browser_url(media_id)
@@ -575,9 +583,17 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
)
if result.shuffle:
self.set_shuffle(True)
if kwargs.get(ATTR_MEDIA_ENQUEUE):
if enqueue == MediaPlayerEnqueue.ADD:
plex_plugin.add_to_queue(result.media)
else:
elif enqueue in (
MediaPlayerEnqueue.NEXT,
MediaPlayerEnqueue.PLAY,
):
pos = (self.media.queue_position or 0) + 1
new_pos = plex_plugin.add_to_queue(result.media, position=pos)
if enqueue == MediaPlayerEnqueue.PLAY:
soco.play_from_queue(new_pos - 1)
elif enqueue == MediaPlayerEnqueue.REPLACE:
soco.clear_queue()
plex_plugin.add_to_queue(result.media)
soco.play_from_queue(0)
@@ -585,9 +601,17 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
share_link = self.coordinator.share_link
if share_link.is_share_link(media_id):
if kwargs.get(ATTR_MEDIA_ENQUEUE):
if enqueue == MediaPlayerEnqueue.ADD:
share_link.add_share_link_to_queue(media_id)
else:
elif enqueue in (
MediaPlayerEnqueue.NEXT,
MediaPlayerEnqueue.PLAY,
):
pos = (self.media.queue_position or 0) + 1
new_pos = share_link.add_share_link_to_queue(media_id, position=pos)
if enqueue == MediaPlayerEnqueue.PLAY:
soco.play_from_queue(new_pos - 1)
elif enqueue == MediaPlayerEnqueue.REPLACE:
soco.clear_queue()
share_link.add_share_link_to_queue(media_id)
soco.play_from_queue(0)
@@ -595,9 +619,17 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
# If media ID is a relative URL, we serve it from HA.
media_id = async_process_play_media_url(self.hass, media_id)
if kwargs.get(ATTR_MEDIA_ENQUEUE):
if enqueue == MediaPlayerEnqueue.ADD:
soco.add_uri_to_queue(media_id)
else:
elif enqueue in (
MediaPlayerEnqueue.NEXT,
MediaPlayerEnqueue.PLAY,
):
pos = (self.media.queue_position or 0) + 1
new_pos = soco.add_uri_to_queue(media_id, position=pos)
if enqueue == MediaPlayerEnqueue.PLAY:
soco.play_from_queue(new_pos - 1)
elif enqueue == MediaPlayerEnqueue.REPLACE:
soco.play_uri(media_id, force_radio=is_radio)
elif media_type == MEDIA_TYPE_PLAYLIST:
if media_id.startswith("S:"):

View File

@@ -2,7 +2,7 @@
"domain": "sql",
"name": "SQL",
"documentation": "https://www.home-assistant.io/integrations/sql",
"requirements": ["sqlalchemy==1.4.36"],
"requirements": ["sqlalchemy==1.4.37"],
"codeowners": ["@dgomes", "@gjohansson-ST"],
"config_flow": true,
"iot_class": "local_polling"

View File

@@ -10,6 +10,7 @@ import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityFeature,
)
@@ -469,16 +470,17 @@ class SqueezeBoxEntity(MediaPlayerEntity):
await self._player.async_set_power(True)
async def async_play_media(self, media_type, media_id, **kwargs):
"""
Send the play_media command to the media player.
If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the current playlist.
"""
cmd = "play"
"""Send the play_media command to the media player."""
index = None
if kwargs.get(ATTR_MEDIA_ENQUEUE):
enqueue: MediaPlayerEnqueue | None = kwargs.get(ATTR_MEDIA_ENQUEUE)
if enqueue == MediaPlayerEnqueue.ADD:
cmd = "add"
elif enqueue == MediaPlayerEnqueue.NEXT:
cmd = "insert"
else:
cmd = "play"
if media_source.is_media_source_id(media_id):
media_type = MEDIA_TYPE_MUSIC

View File

@@ -350,7 +350,7 @@ class StatisticsSensor(SensorEntity):
if new_state.state == STATE_UNAVAILABLE:
self.attributes[STAT_SOURCE_VALUE_VALID] = None
return
if new_state.state in (STATE_UNKNOWN, None):
if new_state.state in (STATE_UNKNOWN, None, ""):
self.attributes[STAT_SOURCE_VALUE_VALID] = False
return

View File

@@ -11,6 +11,7 @@ import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
ATTR_ID,
CONF_API_KEY,
CONF_LATITUDE,
CONF_LOCATION,
@@ -24,8 +25,14 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import (
CONF_FUEL_TYPES,
@@ -109,9 +116,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set a tankerkoenig configuration entry up."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][
entry.unique_id
] = coordinator = TankerkoenigDataUpdateCoordinator(
hass.data[DOMAIN][entry.entry_id] = coordinator = TankerkoenigDataUpdateCoordinator(
hass,
entry,
_LOGGER,
@@ -140,7 +145,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Tankerkoenig config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.unique_id)
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
@@ -172,7 +177,6 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator):
self._api_key: str = entry.data[CONF_API_KEY]
self._selected_stations: list[str] = entry.data[CONF_STATIONS]
self._hass = hass
self.stations: dict[str, dict] = {}
self.fuel_types: list[str] = entry.data[CONF_FUEL_TYPES]
self.show_on_map: bool = entry.options[CONF_SHOW_ON_MAP]
@@ -195,7 +199,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator):
station_id,
station_data["message"],
)
return False
continue
self.add_station(station_data["station"])
if len(self.stations) > 10:
_LOGGER.warning(
@@ -215,7 +219,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator):
# The API seems to only return at most 10 results, so split the list in chunks of 10
# and merge it together.
for index in range(ceil(len(station_ids) / 10)):
data = await self._hass.async_add_executor_job(
data = await self.hass.async_add_executor_job(
pytankerkoenig.getPriceList,
self._api_key,
station_ids[index * 10 : (index + 1) * 10],
@@ -223,13 +227,11 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator):
_LOGGER.debug("Received data: %s", data)
if not data["ok"]:
_LOGGER.error(
"Error fetching data from tankerkoenig.de: %s", data["message"]
)
raise UpdateFailed(data["message"])
if "prices" not in data:
_LOGGER.error("Did not receive price information from tankerkoenig.de")
raise UpdateFailed("No prices in data")
raise UpdateFailed(
"Did not receive price information from tankerkoenig.de"
)
prices.update(data["prices"])
return prices
@@ -244,3 +246,20 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator):
self.stations[station_id] = station
_LOGGER.debug("add_station called for station: %s", station)
class TankerkoenigCoordinatorEntity(CoordinatorEntity):
"""Tankerkoenig base entity."""
def __init__(
self, coordinator: TankerkoenigDataUpdateCoordinator, station: dict
) -> None:
"""Initialize the Tankerkoenig base entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(ATTR_ID, station["id"])},
name=f"{station['brand']} {station['street']} {station['houseNumber']}",
model=station["brand"],
configuration_url="https://www.tankerkoenig.de",
entry_type=DeviceEntryType.SERVICE,
)

View File

@@ -8,13 +8,11 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ID, ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import TankerkoenigDataUpdateCoordinator
from . import TankerkoenigCoordinatorEntity, TankerkoenigDataUpdateCoordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -25,7 +23,7 @@ async def async_setup_entry(
) -> None:
"""Set up the tankerkoenig binary sensors."""
coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.unique_id]
coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
stations = coordinator.stations.values()
entities = []
@@ -41,7 +39,7 @@ async def async_setup_entry(
async_add_entities(entities)
class StationOpenBinarySensorEntity(CoordinatorEntity, BinarySensorEntity):
class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorEntity):
"""Shows if a station is open or closed."""
_attr_device_class = BinarySensorDeviceClass.DOOR
@@ -53,18 +51,12 @@ class StationOpenBinarySensorEntity(CoordinatorEntity, BinarySensorEntity):
show_on_map: bool,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
super().__init__(coordinator, station)
self._station_id = station["id"]
self._attr_name = (
f"{station['brand']} {station['street']} {station['houseNumber']} status"
)
self._attr_unique_id = f"{station['id']}_status"
self._attr_device_info = DeviceInfo(
identifiers={(ATTR_ID, station["id"])},
name=f"{station['brand']} {station['street']} {station['houseNumber']}",
model=station["brand"],
configuration_url="https://www.tankerkoenig.de",
)
if show_on_map:
self._attr_extra_state_attributes = {
ATTR_LATITUDE: station["lat"],

View File

@@ -1,6 +1,7 @@
"""Config flow for Tankerkoenig."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from pytankerkoenig import customException, getNearbyStations
@@ -17,7 +18,7 @@ from homeassistant.const import (
CONF_SHOW_ON_MAP,
LENGTH_KILOMETERS,
)
from homeassistant.core import callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import (
@@ -29,6 +30,24 @@ from homeassistant.helpers.selector import (
from .const import CONF_FUEL_TYPES, CONF_STATIONS, DEFAULT_RADIUS, DOMAIN, FUEL_TYPES
async def async_get_nearby_stations(
hass: HomeAssistant, data: Mapping[str, Any]
) -> dict[str, Any]:
"""Fetch nearby stations."""
try:
return await hass.async_add_executor_job(
getNearbyStations,
data[CONF_API_KEY],
data[CONF_LOCATION][CONF_LATITUDE],
data[CONF_LOCATION][CONF_LONGITUDE],
data[CONF_RADIUS],
"all",
"dist",
)
except customException as err:
return {"ok": False, "message": err, "exception": True}
class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
@@ -57,7 +76,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
selected_station_ids: list[str] = []
# add all nearby stations
nearby_stations = await self._get_nearby_stations(config)
nearby_stations = await async_get_nearby_stations(self.hass, config)
for station in nearby_stations.get("stations", []):
selected_station_ids.append(station["id"])
@@ -91,19 +110,17 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
)
self._abort_if_unique_id_configured()
data = await self._get_nearby_stations(user_input)
data = await async_get_nearby_stations(self.hass, user_input)
if not data.get("ok"):
return self._show_form_user(
user_input, errors={CONF_API_KEY: "invalid_auth"}
)
if stations := data.get("stations"):
for station in stations:
self._stations[
station["id"]
] = f"{station['brand']} {station['street']} {station['houseNumber']} - ({station['dist']}km)"
else:
if len(stations := data.get("stations", [])) == 0:
return self._show_form_user(user_input, errors={CONF_RADIUS: "no_stations"})
for station in stations:
self._stations[
station["id"]
] = f"{station['brand']} {station['street']} {station['houseNumber']} - ({station['dist']}km)"
self._data = user_input
@@ -162,7 +179,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
CONF_RADIUS, default=user_input.get(CONF_RADIUS, DEFAULT_RADIUS)
): NumberSelector(
NumberSelectorConfig(
min=0.1,
min=1.0,
max=25,
step=0.1,
unit_of_measurement=LENGTH_KILOMETERS,
@@ -182,21 +199,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
options=options,
)
async def _get_nearby_stations(self, data: dict[str, Any]) -> dict[str, Any]:
"""Fetch nearby stations."""
try:
return await self.hass.async_add_executor_job(
getNearbyStations,
data[CONF_API_KEY],
data[CONF_LOCATION][CONF_LATITUDE],
data[CONF_LOCATION][CONF_LONGITUDE],
data[CONF_RADIUS],
"all",
"dist",
)
except customException as err:
return {"ok": False, "message": err, "exception": True}
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle an options flow."""
@@ -204,14 +206,36 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
self._stations: dict[str, str] = {}
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle options flow."""
if user_input is not None:
self.hass.config_entries.async_update_entry(
self.config_entry,
data={
**self.config_entry.data,
CONF_STATIONS: user_input.pop(CONF_STATIONS),
},
)
return self.async_create_entry(title="", data=user_input)
nearby_stations = await async_get_nearby_stations(
self.hass, self.config_entry.data
)
if stations := nearby_stations.get("stations"):
for station in stations:
self._stations[
station["id"]
] = f"{station['brand']} {station['street']} {station['houseNumber']} - ({station['dist']}km)"
# add possible extra selected stations from import
for selected_station in self.config_entry.data[CONF_STATIONS]:
if selected_station not in self._stations:
self._stations[selected_station] = f"id: {selected_station}"
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
@@ -220,6 +244,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
CONF_SHOW_ON_MAP,
default=self.config_entry.options[CONF_SHOW_ON_MAP],
): bool,
vol.Required(
CONF_STATIONS, default=self.config_entry.data[CONF_STATIONS]
): cv.multi_select(self._stations),
}
),
)

View File

@@ -7,17 +7,14 @@ from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ATTRIBUTION,
ATTR_ID,
ATTR_LATITUDE,
ATTR_LONGITUDE,
CURRENCY_EURO,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import TankerkoenigDataUpdateCoordinator
from . import TankerkoenigCoordinatorEntity, TankerkoenigDataUpdateCoordinator
from .const import (
ATTR_BRAND,
ATTR_CITY,
@@ -39,7 +36,7 @@ async def async_setup_entry(
) -> None:
"""Set up the tankerkoenig sensors."""
coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.unique_id]
coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
stations = coordinator.stations.values()
entities = []
@@ -62,7 +59,7 @@ async def async_setup_entry(
async_add_entities(entities)
class FuelPriceSensor(CoordinatorEntity, SensorEntity):
class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity):
"""Contains prices for fuel in a given station."""
_attr_state_class = SensorStateClass.MEASUREMENT
@@ -70,19 +67,12 @@ class FuelPriceSensor(CoordinatorEntity, SensorEntity):
def __init__(self, fuel_type, station, coordinator, show_on_map):
"""Initialize the sensor."""
super().__init__(coordinator)
super().__init__(coordinator, station)
self._station_id = station["id"]
self._fuel_type = fuel_type
self._attr_name = f"{station['brand']} {station['street']} {station['houseNumber']} {FUEL_TYPES[fuel_type]}"
self._attr_native_unit_of_measurement = CURRENCY_EURO
self._attr_unique_id = f"{station['id']}_{fuel_type}"
self._attr_device_info = DeviceInfo(
identifiers={(ATTR_ID, station["id"])},
name=f"{station['brand']} {station['street']} {station['houseNumber']}",
model=station["brand"],
configuration_url="https://www.tankerkoenig.de",
)
attrs = {
ATTR_ATTRIBUTION: ATTRIBUTION,
ATTR_BRAND: station["brand"],

View File

@@ -32,7 +32,7 @@
"init": {
"title": "Tankerkoenig options",
"data": {
"scan_interval": "Update Interval",
"stations": "Stations",
"show_on_map": "Show stations on map"
}
}

View File

@@ -31,8 +31,8 @@
"step": {
"init": {
"data": {
"scan_interval": "Update Interval",
"show_on_map": "Show stations on map"
"show_on_map": "Show stations on map",
"stations": "Stations"
},
"title": "Tankerkoenig options"
}

View File

@@ -3,7 +3,7 @@
"name": "Tasmota",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tasmota",
"requirements": ["hatasmota==0.5.0"],
"requirements": ["hatasmota==0.5.1"],
"dependencies": ["mqtt"],
"mqtt": ["tasmota/discovery/#"],
"codeowners": ["@emontnemery"],

View File

@@ -21,6 +21,7 @@ import yarl
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.media_player.const import (
ATTR_MEDIA_ANNOUNCE,
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
DOMAIN as DOMAIN_MP,
@@ -224,6 +225,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
str(yarl.URL.build(path=p_type, query=params)),
),
ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC,
ATTR_MEDIA_ANNOUNCE: True,
},
blocking=True,
context=service.context,

View File

@@ -99,6 +99,13 @@ PAUSED = "paused"
COLLECTING = "collecting"
def validate_is_number(value):
"""Validate value is a number."""
if is_number(value):
return value
raise vol.Invalid("Value is not a number")
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@@ -167,7 +174,7 @@ async def async_setup_entry(
platform.async_register_entity_service(
SERVICE_CALIBRATE_METER,
{vol.Required(ATTR_VALUE): vol.Coerce(Decimal)},
{vol.Required(ATTR_VALUE): validate_is_number},
"async_calibrate",
)
@@ -244,7 +251,7 @@ async def async_setup_platform(
platform.async_register_entity_service(
SERVICE_CALIBRATE_METER,
{vol.Required(ATTR_VALUE): vol.Coerce(Decimal)},
{vol.Required(ATTR_VALUE): validate_is_number},
"async_calibrate",
)
@@ -446,8 +453,8 @@ class UtilityMeterSensor(RestoreSensor):
async def async_calibrate(self, value):
"""Calibrate the Utility Meter with a given value."""
_LOGGER.debug("Calibrate %s = %s", self._name, value)
self._state = value
_LOGGER.debug("Calibrate %s = %s type(%s)", self._name, value, type(value))
self._state = Decimal(str(value))
self.async_write_ha_state()
async def async_added_to_hass(self):

View File

@@ -94,8 +94,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
zones=zones,
)
@callback
def shutdown(event):
"""Close the WS66i connection to the amplifier and save snapshots."""
"""Close the WS66i connection to the amplifier."""
ws66i.close()
entry.async_on_unload(entry.add_update_listener(_update_listener))
@@ -119,6 +120,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
async def _update_listener(hass: HomeAssistant, entry: ConfigEntry):
async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -1,5 +1,6 @@
"""Config flow for WS66i 6-Zone Amplifier integration."""
import logging
from typing import Any
from pyws66i import WS66i, get_ws66i
import voluptuous as vol
@@ -50,22 +51,34 @@ def _sources_from_config(data):
}
async def validate_input(hass: core.HomeAssistant, input_data):
"""Validate the user input allows us to connect.
def _verify_connection(ws66i: WS66i) -> bool:
"""Verify a connection can be made to the WS66i."""
try:
ws66i.open()
except ConnectionError as err:
raise CannotConnect from err
# Connection successful. Verify correct port was opened
# Test on FIRST_ZONE because this zone will always be valid
ret_val = ws66i.zone_status(FIRST_ZONE)
ws66i.close()
return bool(ret_val)
async def validate_input(
hass: core.HomeAssistant, input_data: dict[str, Any]
) -> dict[str, Any]:
"""Validate the user input.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
ws66i: WS66i = get_ws66i(input_data[CONF_IP_ADDRESS])
await hass.async_add_executor_job(ws66i.open)
# No exception. run a simple test to make sure we opened correct port
# Test on FIRST_ZONE because this zone will always be valid
ret_val = await hass.async_add_executor_job(ws66i.zone_status, FIRST_ZONE)
if ret_val is None:
ws66i.close()
raise ConnectionError("Not a valid WS66i connection")
# Validation done. No issues. Close the connection
ws66i.close()
is_valid: bool = await hass.async_add_executor_job(_verify_connection, ws66i)
if not is_valid:
raise CannotConnect("Not a valid WS66i connection")
# Return info that you want to store in the config entry.
return {CONF_IP_ADDRESS: input_data[CONF_IP_ADDRESS]}
@@ -82,17 +95,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
# Data is valid. Add default values for options flow.
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# Data is valid. Create a config entry.
return self.async_create_entry(
title="WS66i Amp",
data=info,
options={CONF_SOURCES: INIT_OPTIONS_DEFAULT},
)
except ConnectionError:
errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors

View File

@@ -1,4 +1,5 @@
"""Constants for the Soundavo WS66i 6-Zone Amplifier Media Player component."""
from datetime import timedelta
DOMAIN = "ws66i"
@@ -20,5 +21,6 @@ INIT_OPTIONS_DEFAULT = {
"6": "Source 6",
}
SERVICE_SNAPSHOT = "snapshot"
SERVICE_RESTORE = "restore"
POLL_INTERVAL = timedelta(seconds=30)
MAX_VOL = 38

View File

@@ -1,7 +1,6 @@
"""Coordinator for WS66i."""
from __future__ import annotations
from datetime import timedelta
import logging
from pyws66i import WS66i, ZoneStatus
@@ -9,12 +8,12 @@ from pyws66i import WS66i, ZoneStatus
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import POLL_INTERVAL
_LOGGER = logging.getLogger(__name__)
POLL_INTERVAL = timedelta(seconds=30)
class Ws66iDataUpdateCoordinator(DataUpdateCoordinator):
class Ws66iDataUpdateCoordinator(DataUpdateCoordinator[list[ZoneStatus]]):
"""DataUpdateCoordinator to gather data for WS66i Zones."""
def __init__(
@@ -43,11 +42,9 @@ class Ws66iDataUpdateCoordinator(DataUpdateCoordinator):
data.append(data_zone)
# HA will call my entity's _handle_coordinator_update()
return data
async def _async_update_data(self) -> list[ZoneStatus]:
"""Fetch data for each of the zones."""
# HA will call my entity's _handle_coordinator_update()
# The data I pass back here can be accessed through coordinator.data.
# The data that is returned here can be accessed through coordinator.data.
return await self.hass.async_add_executor_job(self._update_all_zones)

View File

@@ -1,6 +1,4 @@
"""Support for interfacing with WS66i 6 zone home audio controller."""
from copy import deepcopy
from pyws66i import WS66i, ZoneStatus
from homeassistant.components.media_player import (
@@ -10,22 +8,16 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, SERVICE_RESTORE, SERVICE_SNAPSHOT
from .const import DOMAIN, MAX_VOL
from .coordinator import Ws66iDataUpdateCoordinator
from .models import Ws66iData
PARALLEL_UPDATES = 1
MAX_VOL = 38
async def async_setup_entry(
hass: HomeAssistant,
@@ -48,23 +40,8 @@ async def async_setup_entry(
for idx, zone_id in enumerate(ws66i_data.zones)
)
# Set up services
platform = async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SNAPSHOT,
{},
"snapshot",
)
platform.async_register_entity_service(
SERVICE_RESTORE,
{},
"async_restore",
)
class Ws66iZone(CoordinatorEntity, MediaPlayerEntity):
class Ws66iZone(CoordinatorEntity[Ws66iDataUpdateCoordinator], MediaPlayerEntity):
"""Representation of a WS66i amplifier zone."""
def __init__(
@@ -82,8 +59,6 @@ class Ws66iZone(CoordinatorEntity, MediaPlayerEntity):
self._ws66i_data: Ws66iData = ws66i_data
self._zone_id: int = zone_id
self._zone_id_idx: int = data_idx
self._coordinator = coordinator
self._snapshot: ZoneStatus = None
self._status: ZoneStatus = coordinator.data[data_idx]
self._attr_source_list = ws66i_data.sources.name_list
self._attr_unique_id = f"{entry_id}_{self._zone_id}"
@@ -131,20 +106,6 @@ class Ws66iZone(CoordinatorEntity, MediaPlayerEntity):
self._set_attrs_from_status()
self.async_write_ha_state()
@callback
def snapshot(self):
"""Save zone's current state."""
self._snapshot = deepcopy(self._status)
async def async_restore(self):
"""Restore saved state."""
if not self._snapshot:
raise HomeAssistantError("There is no snapshot to restore")
await self.hass.async_add_executor_job(self._ws66i.restore_zone, self._snapshot)
self._status = self._snapshot
self._async_update_attrs_write_ha_state()
async def async_select_source(self, source):
"""Set input source."""
idx = self._ws66i_data.sources.name_id[source]
@@ -180,24 +141,30 @@ class Ws66iZone(CoordinatorEntity, MediaPlayerEntity):
async def async_set_volume_level(self, volume):
"""Set volume level, range 0..1."""
await self.hass.async_add_executor_job(
self._ws66i.set_volume, self._zone_id, int(volume * MAX_VOL)
)
self._status.volume = int(volume * MAX_VOL)
await self.hass.async_add_executor_job(self._set_volume, int(volume * MAX_VOL))
self._async_update_attrs_write_ha_state()
async def async_volume_up(self):
"""Volume up the media player."""
await self.hass.async_add_executor_job(
self._ws66i.set_volume, self._zone_id, min(self._status.volume + 1, MAX_VOL)
self._set_volume, min(self._status.volume + 1, MAX_VOL)
)
self._status.volume = min(self._status.volume + 1, MAX_VOL)
self._async_update_attrs_write_ha_state()
async def async_volume_down(self):
"""Volume down media player."""
await self.hass.async_add_executor_job(
self._ws66i.set_volume, self._zone_id, max(self._status.volume - 1, 0)
self._set_volume, max(self._status.volume - 1, 0)
)
self._status.volume = max(self._status.volume - 1, 0)
self._async_update_attrs_write_ha_state()
def _set_volume(self, volume: int) -> None:
"""Set the volume of the media player."""
# Can't set a new volume level when this zone is muted.
# Follow behavior of keypads, where zone is unmuted when volume changes.
if self._status.mute:
self._ws66i.set_mute(self._zone_id, False)
self._status.mute = False
self._ws66i.set_volume(self._zone_id, volume)
self._status.volume = volume

View File

@@ -7,8 +7,6 @@ from pyws66i import WS66i
from .coordinator import Ws66iDataUpdateCoordinator
# A dataclass is basically a struct in C/C++
@dataclass
class SourceRep:

View File

@@ -1,15 +0,0 @@
snapshot:
name: Snapshot
description: Take a snapshot of the media player zone.
target:
entity:
integration: ws66i
domain: media_player
restore:
name: Restore
description: Restore a snapshot of the media player zone.
target:
entity:
integration: ws66i
domain: media_player

View File

@@ -11,9 +11,6 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"options": {

View File

@@ -1,8 +1,5 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured"
},
"error": {
"cannot_connect": "Failed to connect",
"unknown": "Unexpected error"

View File

@@ -1,25 +1,28 @@
"""The yolink integration."""
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
import async_timeout
from yolink.client import YoLinkClient
from yolink.device import YoLinkDevice
from yolink.exception import YoLinkAuthFailError, YoLinkClientError
from yolink.model import BRDP
from yolink.mqtt_client import MqttClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from . import api
from .const import ATTR_CLIENT, ATTR_COORDINATOR, ATTR_MQTT_CLIENT, DOMAIN
from .const import ATTR_CLIENT, ATTR_COORDINATORS, ATTR_DEVICE, ATTR_MQTT_CLIENT, DOMAIN
from .coordinator import YoLinkCoordinator
SCAN_INTERVAL = timedelta(minutes=5)
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SIREN, Platform.SWITCH]
@@ -41,18 +44,63 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
yolink_http_client = YoLinkClient(auth_mgr)
yolink_mqtt_client = MqttClient(auth_mgr)
coordinator = YoLinkCoordinator(hass, yolink_http_client, yolink_mqtt_client)
await coordinator.init_coordinator()
def on_message_callback(message: tuple[str, BRDP]) -> None:
data = message[1]
device_id = message[0]
if data.event is None:
return
event_param = data.event.split(".")
event_type = event_param[len(event_param) - 1]
if event_type not in (
"Report",
"Alert",
"StatusChange",
"getState",
):
return
resolved_state = data.data
if resolved_state is None:
return
entry_data = hass.data[DOMAIN].get(entry.entry_id)
if entry_data is None:
return
device_coordinators = entry_data.get(ATTR_COORDINATORS)
if device_coordinators is None:
return
device_coordinator = device_coordinators.get(device_id)
if device_coordinator is None:
return
device_coordinator.async_set_updated_data(resolved_state)
try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady as ex:
_LOGGER.error("Fetching initial data failed: %s", ex)
async with async_timeout.timeout(10):
device_response = await yolink_http_client.get_auth_devices()
home_info = await yolink_http_client.get_general_info()
await yolink_mqtt_client.init_home_connection(
home_info.data["id"], on_message_callback
)
except YoLinkAuthFailError as yl_auth_err:
raise ConfigEntryAuthFailed from yl_auth_err
except (YoLinkClientError, asyncio.TimeoutError) as err:
raise ConfigEntryNotReady from err
hass.data[DOMAIN][entry.entry_id] = {
ATTR_CLIENT: yolink_http_client,
ATTR_MQTT_CLIENT: yolink_mqtt_client,
ATTR_COORDINATOR: coordinator,
}
auth_devices = device_response.data[ATTR_DEVICE]
device_coordinators = {}
for device_info in auth_devices:
device = YoLinkDevice(device_info, yolink_http_client)
device_coordinator = YoLinkCoordinator(hass, device)
try:
await device_coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
# Not failure by fetching device state
device_coordinator.data = {}
device_coordinators[device.device_id] = device_coordinator
hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATORS] = device_coordinators
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from yolink.device import YoLinkDevice
@@ -16,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
ATTR_COORDINATOR,
ATTR_COORDINATORS,
ATTR_DEVICE_DOOR_SENSOR,
ATTR_DEVICE_LEAK_SENSOR,
ATTR_DEVICE_MOTION_SENSOR,
@@ -32,7 +33,7 @@ class YoLinkBinarySensorEntityDescription(BinarySensorEntityDescription):
exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True
state_key: str = "state"
value: Callable[[str], bool | None] = lambda _: None
value: Callable[[Any], bool | None] = lambda _: None
SENSOR_DEVICE_TYPE = [
@@ -47,14 +48,14 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = (
icon="mdi:door",
device_class=BinarySensorDeviceClass.DOOR,
name="State",
value=lambda value: value == "open",
value=lambda value: value == "open" if value is not None else None,
exists_fn=lambda device: device.device_type in [ATTR_DEVICE_DOOR_SENSOR],
),
YoLinkBinarySensorEntityDescription(
key="motion_state",
device_class=BinarySensorDeviceClass.MOTION,
name="Motion",
value=lambda value: value == "alert",
value=lambda value: value == "alert" if value is not None else None,
exists_fn=lambda device: device.device_type in [ATTR_DEVICE_MOTION_SENSOR],
),
YoLinkBinarySensorEntityDescription(
@@ -62,7 +63,7 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = (
name="Leak",
icon="mdi:water",
device_class=BinarySensorDeviceClass.MOISTURE,
value=lambda value: value == "alert",
value=lambda value: value == "alert" if value is not None else None,
exists_fn=lambda device: device.device_type in [ATTR_DEVICE_LEAK_SENSOR],
),
)
@@ -74,18 +75,20 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up YoLink Sensor from a config entry."""
coordinator = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATOR]
sensor_devices = [
device
for device in coordinator.yl_devices
if device.device_type in SENSOR_DEVICE_TYPE
device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS]
binary_sensor_device_coordinators = [
device_coordinator
for device_coordinator in device_coordinators.values()
if device_coordinator.device.device_type in SENSOR_DEVICE_TYPE
]
entities = []
for sensor_device in sensor_devices:
for binary_sensor_device_coordinator in binary_sensor_device_coordinators:
for description in SENSOR_TYPES:
if description.exists_fn(sensor_device):
if description.exists_fn(binary_sensor_device_coordinator.device):
entities.append(
YoLinkBinarySensorEntity(coordinator, description, sensor_device)
YoLinkBinarySensorEntity(
binary_sensor_device_coordinator, description
)
)
async_add_entities(entities)
@@ -99,18 +102,21 @@ class YoLinkBinarySensorEntity(YoLinkEntity, BinarySensorEntity):
self,
coordinator: YoLinkCoordinator,
description: YoLinkBinarySensorEntityDescription,
device: YoLinkDevice,
) -> None:
"""Init YoLink Sensor."""
super().__init__(coordinator, device)
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{device.device_id} {self.entity_description.key}"
self._attr_name = f"{device.device_name} ({self.entity_description.name})"
self._attr_unique_id = (
f"{coordinator.device.device_id} {self.entity_description.key}"
)
self._attr_name = (
f"{coordinator.device.device_name} ({self.entity_description.name})"
)
@callback
def update_entity_state(self, state: dict) -> None:
def update_entity_state(self, state: dict[str, Any]) -> None:
"""Update HA Entity State."""
self._attr_is_on = self.entity_description.value(
state[self.entity_description.state_key]
state.get(self.entity_description.state_key)
)
self.async_write_ha_state()

View File

@@ -5,7 +5,7 @@ MANUFACTURER = "YoLink"
HOME_ID = "homeId"
HOME_SUBSCRIPTION = "home_subscription"
ATTR_PLATFORM_SENSOR = "sensor"
ATTR_COORDINATOR = "coordinator"
ATTR_COORDINATORS = "coordinators"
ATTR_DEVICE = "devices"
ATTR_DEVICE_TYPE = "type"
ATTR_DEVICE_NAME = "name"

View File

@@ -1,22 +1,18 @@
"""YoLink DataUpdateCoordinator."""
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
import async_timeout
from yolink.client import YoLinkClient
from yolink.device import YoLinkDevice
from yolink.exception import YoLinkAuthFailError, YoLinkClientError
from yolink.model import BRDP
from yolink.mqtt_client import MqttClient
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import ATTR_DEVICE, ATTR_DEVICE_STATE, DOMAIN
from .const import ATTR_DEVICE_STATE, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -24,9 +20,7 @@ _LOGGER = logging.getLogger(__name__)
class YoLinkCoordinator(DataUpdateCoordinator[dict]):
"""YoLink DataUpdateCoordinator."""
def __init__(
self, hass: HomeAssistant, yl_client: YoLinkClient, yl_mqtt_client: MqttClient
) -> None:
def __init__(self, hass: HomeAssistant, device: YoLinkDevice) -> None:
"""Init YoLink DataUpdateCoordinator.
fetch state every 30 minutes base on yolink device heartbeat interval
@@ -35,75 +29,17 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]):
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30)
)
self._client = yl_client
self._mqtt_client = yl_mqtt_client
self.yl_devices: list[YoLinkDevice] = []
self.data = {}
self.device = device
def on_message_callback(self, message: tuple[str, BRDP]):
"""On message callback."""
data = message[1]
if data.event is None:
return
event_param = data.event.split(".")
event_type = event_param[len(event_param) - 1]
if event_type not in (
"Report",
"Alert",
"StatusChange",
"getState",
):
return
resolved_state = data.data
if resolved_state is None:
return
self.data[message[0]] = resolved_state
self.async_set_updated_data(self.data)
async def init_coordinator(self):
"""Init coordinator."""
async def _async_update_data(self) -> dict:
"""Fetch device state."""
try:
async with async_timeout.timeout(10):
home_info = await self._client.get_general_info()
await self._mqtt_client.init_home_connection(
home_info.data["id"], self.on_message_callback
)
async with async_timeout.timeout(10):
device_response = await self._client.get_auth_devices()
except YoLinkAuthFailError as yl_auth_err:
raise ConfigEntryAuthFailed from yl_auth_err
except (YoLinkClientError, asyncio.TimeoutError) as err:
raise ConfigEntryNotReady from err
yl_devices: list[YoLinkDevice] = []
for device_info in device_response.data[ATTR_DEVICE]:
yl_devices.append(YoLinkDevice(device_info, self._client))
self.yl_devices = yl_devices
async def fetch_device_state(self, device: YoLinkDevice):
"""Fetch Device State."""
try:
async with async_timeout.timeout(10):
device_state_resp = await device.fetch_state_with_api()
if ATTR_DEVICE_STATE in device_state_resp.data:
self.data[device.device_id] = device_state_resp.data[
ATTR_DEVICE_STATE
]
device_state_resp = await self.device.fetch_state_with_api()
except YoLinkAuthFailError as yl_auth_err:
raise ConfigEntryAuthFailed from yl_auth_err
except YoLinkClientError as yl_client_err:
raise UpdateFailed(
f"Error communicating with API: {yl_client_err}"
) from yl_client_err
async def _async_update_data(self) -> dict:
fetch_tasks = []
for yl_device in self.yl_devices:
fetch_tasks.append(self.fetch_device_state(yl_device))
if fetch_tasks:
await asyncio.gather(*fetch_tasks)
return self.data
raise UpdateFailed from yl_client_err
if ATTR_DEVICE_STATE in device_state_resp.data:
return device_state_resp.data[ATTR_DEVICE_STATE]
return {}

View File

@@ -3,8 +3,6 @@ from __future__ import annotations
from abc import abstractmethod
from yolink.device import YoLinkDevice
from homeassistant.core import callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -19,20 +17,24 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]):
def __init__(
self,
coordinator: YoLinkCoordinator,
device_info: YoLinkDevice,
) -> None:
"""Init YoLink Entity."""
super().__init__(coordinator)
self.device = device_info
@property
def device_id(self) -> str:
"""Return the device id of the YoLink device."""
return self.device.device_id
return self.coordinator.device.device_id
async def async_added_to_hass(self) -> None:
"""Update state."""
await super().async_added_to_hass()
return self._handle_coordinator_update()
@callback
def _handle_coordinator_update(self) -> None:
data = self.coordinator.data.get(self.device.device_id)
"""Update state."""
data = self.coordinator.data
if data is not None:
self.update_entity_state(data)
@@ -40,10 +42,10 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]):
def device_info(self) -> DeviceInfo:
"""Return the device info for HA."""
return DeviceInfo(
identifiers={(DOMAIN, self.device.device_id)},
identifiers={(DOMAIN, self.coordinator.device.device_id)},
manufacturer=MANUFACTURER,
model=self.device.device_type,
name=self.device.device_name,
model=self.coordinator.device.device_type,
name=self.coordinator.device.device_name,
)
@callback

View File

@@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import percentage
from .const import (
ATTR_COORDINATOR,
ATTR_COORDINATORS,
ATTR_DEVICE_DOOR_SENSOR,
ATTR_DEVICE_MOTION_SENSOR,
ATTR_DEVICE_TH_SENSOR,
@@ -54,7 +54,9 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
value=lambda value: percentage.ordered_list_item_to_percentage(
[1, 2, 3, 4], value
),
)
if value is not None
else None,
exists_fn=lambda device: device.device_type
in [ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_MOTION_SENSOR],
),
@@ -89,18 +91,21 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up YoLink Sensor from a config entry."""
coordinator = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATOR]
sensor_devices = [
device
for device in coordinator.yl_devices
if device.device_type in SENSOR_DEVICE_TYPE
device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS]
sensor_device_coordinators = [
device_coordinator
for device_coordinator in device_coordinators.values()
if device_coordinator.device.device_type in SENSOR_DEVICE_TYPE
]
entities = []
for sensor_device in sensor_devices:
for sensor_device_coordinator in sensor_device_coordinators:
for description in SENSOR_TYPES:
if description.exists_fn(sensor_device):
if description.exists_fn(sensor_device_coordinator.device):
entities.append(
YoLinkSensorEntity(coordinator, description, sensor_device)
YoLinkSensorEntity(
sensor_device_coordinator,
description,
)
)
async_add_entities(entities)
@@ -114,18 +119,21 @@ class YoLinkSensorEntity(YoLinkEntity, SensorEntity):
self,
coordinator: YoLinkCoordinator,
description: YoLinkSensorEntityDescription,
device: YoLinkDevice,
) -> None:
"""Init YoLink Sensor."""
super().__init__(coordinator, device)
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{device.device_id} {self.entity_description.key}"
self._attr_name = f"{device.device_name} ({self.entity_description.name})"
self._attr_unique_id = (
f"{coordinator.device.device_id} {self.entity_description.key}"
)
self._attr_name = (
f"{coordinator.device.device_name} ({self.entity_description.name})"
)
@callback
def update_entity_state(self, state: dict) -> None:
"""Update HA Entity State."""
self._attr_native_value = self.entity_description.value(
state[self.entity_description.key]
state.get(self.entity_description.key)
)
self.async_write_ha_state()

View File

@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ATTR_COORDINATOR, ATTR_DEVICE_SIREN, DOMAIN
from .const import ATTR_COORDINATORS, ATTR_DEVICE_SIREN, DOMAIN
from .coordinator import YoLinkCoordinator
from .entity import YoLinkEntity
@@ -28,14 +28,14 @@ class YoLinkSirenEntityDescription(SirenEntityDescription):
"""YoLink SirenEntityDescription."""
exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True
value: Callable[[str], bool | None] = lambda _: None
value: Callable[[Any], bool | None] = lambda _: None
DEVICE_TYPES: tuple[YoLinkSirenEntityDescription, ...] = (
YoLinkSirenEntityDescription(
key="state",
name="State",
value=lambda value: value == "alert",
value=lambda value: value == "alert" if value is not None else None,
exists_fn=lambda device: device.device_type in [ATTR_DEVICE_SIREN],
),
)
@@ -49,16 +49,20 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up YoLink siren from a config entry."""
coordinator = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATOR]
devices = [
device for device in coordinator.yl_devices if device.device_type in DEVICE_TYPE
device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS]
siren_device_coordinators = [
device_coordinator
for device_coordinator in device_coordinators.values()
if device_coordinator.device.device_type in DEVICE_TYPE
]
entities = []
for device in devices:
for siren_device_coordinator in siren_device_coordinators:
for description in DEVICE_TYPES:
if description.exists_fn(device):
if description.exists_fn(siren_device_coordinator.device):
entities.append(
YoLinkSirenEntity(config_entry, coordinator, description, device)
YoLinkSirenEntity(
config_entry, siren_device_coordinator, description
)
)
async_add_entities(entities)
@@ -73,23 +77,26 @@ class YoLinkSirenEntity(YoLinkEntity, SirenEntity):
config_entry: ConfigEntry,
coordinator: YoLinkCoordinator,
description: YoLinkSirenEntityDescription,
device: YoLinkDevice,
) -> None:
"""Init YoLink Siren."""
super().__init__(coordinator, device)
super().__init__(coordinator)
self.config_entry = config_entry
self.entity_description = description
self._attr_unique_id = f"{device.device_id} {self.entity_description.key}"
self._attr_name = f"{device.device_name} ({self.entity_description.name})"
self._attr_unique_id = (
f"{coordinator.device.device_id} {self.entity_description.key}"
)
self._attr_name = (
f"{coordinator.device.device_name} ({self.entity_description.name})"
)
self._attr_supported_features = (
SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF
)
@callback
def update_entity_state(self, state: dict) -> None:
def update_entity_state(self, state: dict[str, Any]) -> None:
"""Update HA Entity State."""
self._attr_is_on = self.entity_description.value(
state[self.entity_description.key]
state.get(self.entity_description.key)
)
self.async_write_ha_state()
@@ -97,7 +104,7 @@ class YoLinkSirenEntity(YoLinkEntity, SirenEntity):
"""Call setState api to change siren state."""
try:
# call_device_http_api will check result, fail by raise YoLinkClientError
await self.device.call_device_http_api(
await self.coordinator.device.call_device_http_api(
"setState", {"state": {"alarm": state}}
)
except YoLinkAuthFailError as yl_auth_err:

View File

@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ATTR_COORDINATOR, ATTR_DEVICE_OUTLET, DOMAIN
from .const import ATTR_COORDINATORS, ATTR_DEVICE_OUTLET, DOMAIN
from .coordinator import YoLinkCoordinator
from .entity import YoLinkEntity
@@ -28,7 +28,7 @@ class YoLinkSwitchEntityDescription(SwitchEntityDescription):
"""YoLink SwitchEntityDescription."""
exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True
value: Callable[[str], bool | None] = lambda _: None
value: Callable[[Any], bool | None] = lambda _: None
DEVICE_TYPES: tuple[YoLinkSwitchEntityDescription, ...] = (
@@ -36,7 +36,7 @@ DEVICE_TYPES: tuple[YoLinkSwitchEntityDescription, ...] = (
key="state",
device_class=SwitchDeviceClass.OUTLET,
name="State",
value=lambda value: value == "open",
value=lambda value: value == "open" if value is not None else None,
exists_fn=lambda device: device.device_type in [ATTR_DEVICE_OUTLET],
),
)
@@ -50,16 +50,20 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up YoLink Sensor from a config entry."""
coordinator = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATOR]
devices = [
device for device in coordinator.yl_devices if device.device_type in DEVICE_TYPE
device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS]
switch_device_coordinators = [
device_coordinator
for device_coordinator in device_coordinators.values()
if device_coordinator.device.device_type in DEVICE_TYPE
]
entities = []
for device in devices:
for switch_device_coordinator in switch_device_coordinators:
for description in DEVICE_TYPES:
if description.exists_fn(device):
if description.exists_fn(switch_device_coordinator.device):
entities.append(
YoLinkSwitchEntity(config_entry, coordinator, description, device)
YoLinkSwitchEntity(
config_entry, switch_device_coordinator, description
)
)
async_add_entities(entities)
@@ -74,20 +78,23 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity):
config_entry: ConfigEntry,
coordinator: YoLinkCoordinator,
description: YoLinkSwitchEntityDescription,
device: YoLinkDevice,
) -> None:
"""Init YoLink Outlet."""
super().__init__(coordinator, device)
super().__init__(coordinator)
self.config_entry = config_entry
self.entity_description = description
self._attr_unique_id = f"{device.device_id} {self.entity_description.key}"
self._attr_name = f"{device.device_name} ({self.entity_description.name})"
self._attr_unique_id = (
f"{coordinator.device.device_id} {self.entity_description.key}"
)
self._attr_name = (
f"{coordinator.device.device_name} ({self.entity_description.name})"
)
@callback
def update_entity_state(self, state: dict) -> None:
def update_entity_state(self, state: dict[str, Any]) -> None:
"""Update HA Entity State."""
self._attr_is_on = self.entity_description.value(
state[self.entity_description.key]
state.get(self.entity_description.key)
)
self.async_write_ha_state()
@@ -95,7 +102,9 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity):
"""Call setState api to change outlet state."""
try:
# call_device_http_api will check result, fail by raise YoLinkClientError
await self.device.call_device_http_api("setState", {"state": state})
await self.coordinator.device.call_device_http_api(
"setState", {"state": state}
)
except YoLinkAuthFailError as yl_auth_err:
self.config_entry.async_start_reauth(self.hass)
raise HomeAssistantError(yl_auth_err) from yl_auth_err

View File

@@ -7,7 +7,7 @@
"bellows==0.30.0",
"pyserial==3.5",
"pyserial-asyncio==0.6",
"zha-quirks==0.0.74",
"zha-quirks==0.0.75",
"zigpy-deconz==0.16.0",
"zigpy==0.45.1",
"zigpy-xbee==0.14.0",

View File

@@ -1231,7 +1231,7 @@ async def websocket_replace_failed_node(
try:
result = await controller.async_replace_failed_node(
node_id,
controller.nodes[node_id],
INCLUSION_STRATEGY_NOT_SMART_START[inclusion_strategy.value],
force_security=force_security,
provisioning=provisioning,
@@ -1290,11 +1290,8 @@ async def websocket_remove_failed_node(
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [controller.on("node removed", node_removed)]
result = await controller.async_remove_failed_node(node.node_id)
connection.send_result(
msg[ID],
result,
)
await controller.async_remove_failed_node(node)
connection.send_result(msg[ID])
@websocket_api.require_admin
@@ -1416,7 +1413,7 @@ async def websocket_heal_node(
assert driver is not None # The node comes from the driver instance.
controller = driver.controller
result = await controller.async_heal_node(node.node_id)
result = await controller.async_heal_node(node)
connection.send_result(
msg[ID],
result,

View File

@@ -66,7 +66,7 @@ ZW_HVAC_MODE_MAP: dict[int, HVACMode] = {
ThermostatMode.AUTO: HVACMode.HEAT_COOL,
ThermostatMode.AUXILIARY: HVACMode.HEAT,
ThermostatMode.FAN: HVACMode.FAN_ONLY,
ThermostatMode.FURNANCE: HVACMode.HEAT,
ThermostatMode.FURNACE: HVACMode.HEAT,
ThermostatMode.DRY: HVACMode.DRY,
ThermostatMode.AUTO_CHANGE_OVER: HVACMode.HEAT_COOL,
ThermostatMode.HEATING_ECON: HVACMode.HEAT,

View File

@@ -3,7 +3,7 @@
"name": "Z-Wave JS",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zwave_js",
"requirements": ["zwave-js-server-python==0.37.0"],
"requirements": ["zwave-js-server-python==0.37.1"],
"codeowners": ["@home-assistant/z-wave"],
"dependencies": ["usb", "http", "websocket_api"],
"iot_class": "local_push",

View File

@@ -8,7 +8,7 @@ import voluptuous as vol
from zwave_js_server.client import Client
from zwave_js_server.model.controller import CONTROLLER_EVENT_MODEL_MAP
from zwave_js_server.model.driver import DRIVER_EVENT_MODEL_MAP
from zwave_js_server.model.node import NODE_EVENT_MODEL_MAP, Node
from zwave_js_server.model.node import NODE_EVENT_MODEL_MAP
from homeassistant.components.automation import (
AutomationActionType,
@@ -20,7 +20,6 @@ from homeassistant.components.zwave_js.const import (
ATTR_EVENT_DATA,
ATTR_EVENT_SOURCE,
ATTR_NODE_ID,
ATTR_NODES,
ATTR_PARTIAL_DICT_MATCH,
DATA_CLIENT,
DOMAIN,
@@ -116,22 +115,20 @@ async def async_validate_trigger_config(
"""Validate config."""
config = TRIGGER_SCHEMA(config)
if ATTR_CONFIG_ENTRY_ID in config:
entry_id = config[ATTR_CONFIG_ENTRY_ID]
if hass.config_entries.async_get_entry(entry_id) is None:
raise vol.Invalid(f"Config entry '{entry_id}' not found")
if async_bypass_dynamic_config_validation(hass, config):
return config
if config[ATTR_EVENT_SOURCE] == "node":
config[ATTR_NODES] = async_get_nodes_from_targets(hass, config)
if not config[ATTR_NODES]:
raise vol.Invalid(
f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s."
)
if ATTR_CONFIG_ENTRY_ID not in config:
return config
entry_id = config[ATTR_CONFIG_ENTRY_ID]
if hass.config_entries.async_get_entry(entry_id) is None:
raise vol.Invalid(f"Config entry '{entry_id}' not found")
if config[ATTR_EVENT_SOURCE] == "node" and not async_get_nodes_from_targets(
hass, config
):
raise vol.Invalid(
f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s."
)
return config
@@ -145,7 +142,12 @@ async def async_attach_trigger(
platform_type: str = PLATFORM_TYPE,
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
nodes: set[Node] = config.get(ATTR_NODES, {})
dev_reg = dr.async_get(hass)
nodes = async_get_nodes_from_targets(hass, config, dev_reg=dev_reg)
if config[ATTR_EVENT_SOURCE] == "node" and not nodes:
raise ValueError(
f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s."
)
event_source = config[ATTR_EVENT_SOURCE]
event_name = config[ATTR_EVENT]
@@ -200,8 +202,6 @@ async def async_attach_trigger(
hass.async_run_hass_job(job, {"trigger": payload})
dev_reg = dr.async_get(hass)
if not nodes:
entry_id = config[ATTR_CONFIG_ENTRY_ID]
client: Client = hass.data[DOMAIN][entry_id][DATA_CLIENT]

View File

@@ -5,7 +5,6 @@ import functools
import voluptuous as vol
from zwave_js_server.const import CommandClass
from zwave_js_server.model.node import Node
from zwave_js_server.model.value import Value, get_value_id
from homeassistant.components.automation import (
@@ -20,7 +19,6 @@ from homeassistant.components.zwave_js.const import (
ATTR_CURRENT_VALUE_RAW,
ATTR_ENDPOINT,
ATTR_NODE_ID,
ATTR_NODES,
ATTR_PREVIOUS_VALUE,
ATTR_PREVIOUS_VALUE_RAW,
ATTR_PROPERTY,
@@ -79,8 +77,7 @@ async def async_validate_trigger_config(
if async_bypass_dynamic_config_validation(hass, config):
return config
config[ATTR_NODES] = async_get_nodes_from_targets(hass, config)
if not config[ATTR_NODES]:
if not async_get_nodes_from_targets(hass, config):
raise vol.Invalid(
f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s."
)
@@ -96,7 +93,11 @@ async def async_attach_trigger(
platform_type: str = PLATFORM_TYPE,
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
nodes: set[Node] = config[ATTR_NODES]
dev_reg = dr.async_get(hass)
if not (nodes := async_get_nodes_from_targets(hass, config, dev_reg=dev_reg)):
raise ValueError(
f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s."
)
from_value = config[ATTR_FROM]
to_value = config[ATTR_TO]
@@ -163,7 +164,6 @@ async def async_attach_trigger(
hass.async_run_hass_job(job, {"trigger": payload})
dev_reg = dr.async_get(hass)
for node in nodes:
driver = node.client.driver
assert driver is not None # The node comes from the driver.

View File

@@ -186,6 +186,7 @@ class ConfigEntry:
"reason",
"_async_cancel_retry_setup",
"_on_unload",
"reload_lock",
)
def __init__(
@@ -275,6 +276,9 @@ class ConfigEntry:
# Hold list for functions to call on unload.
self._on_unload: list[CALLBACK_TYPE] | None = None
# Reload lock to prevent conflicting reloads
self.reload_lock = asyncio.Lock()
async def async_setup(
self,
hass: HomeAssistant,
@@ -1005,12 +1009,13 @@ class ConfigEntries:
if (entry := self.async_get_entry(entry_id)) is None:
raise UnknownEntry
unload_result = await self.async_unload(entry_id)
async with entry.reload_lock:
unload_result = await self.async_unload(entry_id)
if not unload_result or entry.disabled_by:
return unload_result
if not unload_result or entry.disabled_by:
return unload_result
return await self.async_setup(entry_id)
return await self.async_setup(entry_id)
async def async_set_disabled_by(
self, entry_id: str, disabled_by: ConfigEntryDisabler | None

View File

@@ -7,7 +7,7 @@ from .backports.enum import StrEnum
MAJOR_VERSION: Final = 2022
MINOR_VERSION: Final = 6
PATCH_VERSION: Final = "0b2"
PATCH_VERSION: Final = "0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0)

View File

@@ -829,6 +829,12 @@ def zone(
else:
entity_id = entity.entity_id
if entity.state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
return False
latitude = entity.attributes.get(ATTR_LATITUDE)
longitude = entity.attributes.get(ATTR_LONGITUDE)

View File

@@ -271,9 +271,10 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
) -> FlowResult:
"""Create an entry for auth."""
# Flow has been triggered by external data
if user_input:
if user_input is not None:
self.external_data = user_input
return self.async_external_step_done(next_step_id="creation")
next_step = "authorize_rejected" if "error" in user_input else "creation"
return self.async_external_step_done(next_step_id=next_step)
try:
async with async_timeout.timeout(10):
@@ -311,6 +312,13 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
{"auth_implementation": self.flow_impl.domain, "token": token}
)
async def async_step_authorize_rejected(self, data: None = None) -> FlowResult:
"""Step to handle flow rejection."""
return self.async_abort(
reason="user_rejected_authorize",
description_placeholders={"error": self.external_data["error"]},
)
async def async_oauth_create_entry(self, data: dict) -> FlowResult:
"""Create an entry for the flow.
@@ -400,10 +408,8 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView):
async def get(self, request: web.Request) -> web.Response:
"""Receive authorization code."""
if "code" not in request.query or "state" not in request.query:
return web.Response(
text=f"Missing code or state parameter in {request.url}"
)
if "state" not in request.query:
return web.Response(text="Missing state parameter")
hass = request.app["hass"]
@@ -412,9 +418,17 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView):
if state is None:
return web.Response(text="Invalid state")
user_input: dict[str, Any] = {"state": state}
if "code" in request.query:
user_input["code"] = request.query["code"]
elif "error" in request.query:
user_input["error"] = request.query["error"]
else:
return web.Response(text="Missing code or error parameter")
await hass.config_entries.flow.async_configure(
flow_id=state["flow_id"],
user_input={"state": state, "code": request.query["code"]},
flow_id=state["flow_id"], user_input=user_input
)
return web.Response(
@@ -479,13 +493,13 @@ async def async_oauth2_request(
This method will not refresh tokens. Use OAuth2 session for that.
"""
session = async_get_clientsession(hass)
headers = kwargs.pop("headers", {})
return await session.request(
method,
url,
**kwargs,
headers={
**(kwargs.get("headers") or {}),
**headers,
"authorization": f"Bearer {token['access_token']}",
},
)

View File

@@ -8,14 +8,14 @@ async-upnp-client==0.30.1
async_timeout==4.0.2
atomicwrites==1.4.0
attrs==21.2.0
awesomeversion==22.5.1
awesomeversion==22.5.2
bcrypt==3.1.7
certifi>=2021.5.30
ciso8601==2.2.0
cryptography==36.0.2
fnvhash==0.1.0
hass-nabucasa==0.54.0
home-assistant-frontend==20220526.0
home-assistant-frontend==20220531.0
httpx==0.23.0
ifaddr==0.1.7
jinja2==3.1.2
@@ -29,7 +29,7 @@ pyudev==0.22.0
pyyaml==6.0
requests==2.27.1
scapy==2.4.5
sqlalchemy==1.4.36
sqlalchemy==1.4.37
typing-extensions>=3.10.0.2,<5.0
voluptuous-serialize==2.5.0
voluptuous==0.13.1
@@ -106,3 +106,7 @@ authlib<1.0
# Pin backoff for compatibility until most libraries have been updated
# https://github.com/home-assistant/core/pull/70817
backoff<2.0
# Breaking change in version
# https://github.com/samuelcolvin/pydantic/issues/4092
pydantic!=1.9.1

View File

@@ -74,6 +74,7 @@
"oauth2_missing_credentials": "The integration requires application credentials.",
"oauth2_authorize_url_timeout": "Timeout generating authorize URL.",
"oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})",
"oauth2_user_rejected_authorize": "Account linking rejected: {error}",
"reauth_successful": "Re-authentication was successful",
"unknown_authorize_url_generation": "Unknown error generating an authorize URL.",
"cloud_not_connected": "Not connected to Home Assistant Cloud."

View File

@@ -6,7 +6,7 @@ astral==2.2
async_timeout==4.0.2
attrs==21.2.0
atomicwrites==1.4.0
awesomeversion==22.5.1
awesomeversion==22.5.2
bcrypt==3.1.7
certifi>=2021.5.30
ciso8601==2.2.0

View File

@@ -394,7 +394,7 @@ beautifulsoup4==4.11.1
bellows==0.30.0
# homeassistant.components.bmw_connected_drive
bimmer_connected==0.9.0
bimmer_connected==0.9.3
# homeassistant.components.bizkaibus
bizkaibus==0.1.1
@@ -795,7 +795,7 @@ hass-nabucasa==0.54.0
hass_splunk==0.1.1
# homeassistant.components.tasmota
hatasmota==0.5.0
hatasmota==0.5.1
# homeassistant.components.jewish_calendar
hdate==0.10.4
@@ -822,7 +822,7 @@ hole==0.7.0
holidays==0.13
# homeassistant.components.frontend
home-assistant-frontend==20220526.0
home-assistant-frontend==20220531.0
# homeassistant.components.home_connect
homeconnect==0.7.0
@@ -1038,7 +1038,7 @@ mitemp_bt==0.0.5
moehlenhoff-alpha2==1.2.1
# homeassistant.components.motion_blinds
motionblinds==0.6.7
motionblinds==0.6.8
# homeassistant.components.motioneye
motioneye-client==0.3.12
@@ -1236,7 +1236,7 @@ pillow==9.1.1
pizzapi==0.0.3
# homeassistant.components.plex
plexapi==4.11.1
plexapi==4.11.2
# homeassistant.components.plex
plexauth==0.0.6
@@ -1538,7 +1538,7 @@ pyheos==0.7.2
pyhik==0.3.0
# homeassistant.components.hive
pyhiveapi==0.4.2
pyhiveapi==0.5.4
# homeassistant.components.homematic
pyhomematic==0.1.77
@@ -1550,7 +1550,7 @@ pyhomeworks==0.0.6
pyialarm==1.9.0
# homeassistant.components.ialarm_xr
pyialarmxr==1.0.13
pyialarmxr==1.0.18
# homeassistant.components.icloud
pyicloud==1.0.0
@@ -2065,7 +2065,7 @@ raincloudy==0.0.7
raspyrfm-client==1.2.8
# homeassistant.components.rainmachine
regenmaschine==2022.05.0
regenmaschine==2022.05.1
# homeassistant.components.renault
renault-api==0.1.11
@@ -2168,7 +2168,7 @@ simplehound==0.3
simplepush==1.1.4
# homeassistant.components.simplisafe
simplisafe-python==2022.05.1
simplisafe-python==2022.05.2
# homeassistant.components.sisyphus
sisyphus-control==3.1.2
@@ -2223,7 +2223,7 @@ spotipy==2.19.0
# homeassistant.components.recorder
# homeassistant.components.sql
sqlalchemy==1.4.36
sqlalchemy==1.4.37
# homeassistant.components.srp_energy
srpenergy==1.3.6
@@ -2501,7 +2501,7 @@ zengge==0.2
zeroconf==0.38.6
# homeassistant.components.zha
zha-quirks==0.0.74
zha-quirks==0.0.75
# homeassistant.components.zhong_hong
zhong_hong_hvac==1.0.9
@@ -2528,7 +2528,7 @@ zigpy==0.45.1
zm-py==0.5.2
# homeassistant.components.zwave_js
zwave-js-server-python==0.37.0
zwave-js-server-python==0.37.1
# homeassistant.components.zwave_me
zwave_me_ws==0.2.4

View File

@@ -309,7 +309,7 @@ beautifulsoup4==4.11.1
bellows==0.30.0
# homeassistant.components.bmw_connected_drive
bimmer_connected==0.9.0
bimmer_connected==0.9.3
# homeassistant.components.blebox
blebox_uniapi==1.3.3
@@ -571,7 +571,7 @@ hangups==0.4.18
hass-nabucasa==0.54.0
# homeassistant.components.tasmota
hatasmota==0.5.0
hatasmota==0.5.1
# homeassistant.components.jewish_calendar
hdate==0.10.4
@@ -589,7 +589,7 @@ hole==0.7.0
holidays==0.13
# homeassistant.components.frontend
home-assistant-frontend==20220526.0
home-assistant-frontend==20220531.0
# homeassistant.components.home_connect
homeconnect==0.7.0
@@ -715,7 +715,7 @@ minio==5.0.10
moehlenhoff-alpha2==1.2.1
# homeassistant.components.motion_blinds
motionblinds==0.6.7
motionblinds==0.6.8
# homeassistant.components.motioneye
motioneye-client==0.3.12
@@ -838,7 +838,7 @@ pilight==0.1.1
pillow==9.1.1
# homeassistant.components.plex
plexapi==4.11.1
plexapi==4.11.2
# homeassistant.components.plex
plexauth==0.0.6
@@ -1029,7 +1029,7 @@ pyhaversion==22.4.1
pyheos==0.7.2
# homeassistant.components.hive
pyhiveapi==0.4.2
pyhiveapi==0.5.4
# homeassistant.components.homematic
pyhomematic==0.1.77
@@ -1038,7 +1038,7 @@ pyhomematic==0.1.77
pyialarm==1.9.0
# homeassistant.components.ialarm_xr
pyialarmxr==1.0.13
pyialarmxr==1.0.18
# homeassistant.components.icloud
pyicloud==1.0.0
@@ -1364,7 +1364,7 @@ rachiopy==1.0.3
radios==0.1.1
# homeassistant.components.rainmachine
regenmaschine==2022.05.0
regenmaschine==2022.05.1
# homeassistant.components.renault
renault-api==0.1.11
@@ -1425,7 +1425,7 @@ sharkiq==0.0.1
simplehound==0.3
# homeassistant.components.simplisafe
simplisafe-python==2022.05.1
simplisafe-python==2022.05.2
# homeassistant.components.slack
slackclient==2.5.0
@@ -1465,7 +1465,7 @@ spotipy==2.19.0
# homeassistant.components.recorder
# homeassistant.components.sql
sqlalchemy==1.4.36
sqlalchemy==1.4.37
# homeassistant.components.srp_energy
srpenergy==1.3.6
@@ -1647,7 +1647,7 @@ youless-api==0.16
zeroconf==0.38.6
# homeassistant.components.zha
zha-quirks==0.0.74
zha-quirks==0.0.75
# homeassistant.components.zha
zigpy-deconz==0.16.0
@@ -1665,7 +1665,7 @@ zigpy-znp==0.7.0
zigpy==0.45.1
# homeassistant.components.zwave_js
zwave-js-server-python==0.37.0
zwave-js-server-python==0.37.1
# homeassistant.components.zwave_me
zwave_me_ws==0.2.4

View File

@@ -122,6 +122,10 @@ authlib<1.0
# Pin backoff for compatibility until most libraries have been updated
# https://github.com/home-assistant/core/pull/70817
backoff<2.0
# Breaking change in version
# https://github.com/samuelcolvin/pydantic/issues/4092
pydantic!=1.9.1
"""
IGNORE_PRE_COMMIT_HOOK_ID = (

View File

@@ -188,6 +188,7 @@ def _custom_tasks(template, info: Info) -> None:
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

Some files were not shown because too many files have changed in this diff Show More