forked from home-assistant/core
Compare commits
61 Commits
2022.6.0b2
...
2022.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d75b0776f | ||
|
|
39da7a93ec | ||
|
|
bf47d86d30 | ||
|
|
2f3359f376 | ||
|
|
1139136365 | ||
|
|
9e723f9b6d | ||
|
|
9bd2e3ad7c | ||
|
|
384cb44d15 | ||
|
|
1274448de1 | ||
|
|
354149e43c | ||
|
|
17a3c62821 | ||
|
|
668f56f103 | ||
|
|
0db9863746 | ||
|
|
e60dc1b503 | ||
|
|
8606447848 | ||
|
|
de0c672cc2 | ||
|
|
c3acdcb2c8 | ||
|
|
9effb78a7f | ||
|
|
647df29a00 | ||
|
|
a54a5b2d20 | ||
|
|
f4d280b59d | ||
|
|
d268c828ee | ||
|
|
82ed6869d0 | ||
|
|
6b3a284135 | ||
|
|
ca8c750a5a | ||
|
|
7c2f73ddba | ||
|
|
1b2cb4eab7 | ||
|
|
4bf5132a06 | ||
|
|
6e06b6c9ed | ||
|
|
103f324c52 | ||
|
|
48d36e49f0 | ||
|
|
a4e2d31a19 | ||
|
|
15bdfb2a45 | ||
|
|
b842c76fbd | ||
|
|
a98528c93f | ||
|
|
a202ffe4c1 | ||
|
|
77e4c86c07 | ||
|
|
72a79736a6 | ||
|
|
2809592e71 | ||
|
|
da7446bf52 | ||
|
|
2942986a7b | ||
|
|
67ef3229fd | ||
|
|
952433d16e | ||
|
|
6f01c13845 | ||
|
|
f8b7527bf0 | ||
|
|
f039aac31c | ||
|
|
c62692dff1 | ||
|
|
4b524c0776 | ||
|
|
f41b2fa2cf | ||
|
|
ce4825c9e2 | ||
|
|
6bf6a0f7bc | ||
|
|
f33517ef2c | ||
|
|
da62e2cc23 | ||
|
|
b360f0280b | ||
|
|
50eaf2f475 | ||
|
|
bd222a1fe0 | ||
|
|
3a06b5f320 | ||
|
|
c45dc49270 | ||
|
|
301f7647d1 | ||
|
|
79340f85d2 | ||
|
|
afcc8679dd |
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"timeout": "Timeout establishing connection",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"step": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -7,6 +7,7 @@ class RainMachineDescriptionMixinApiCategory:
|
||||
"""Define an entity description mixin for binary and regular sensors."""
|
||||
|
||||
api_category: str
|
||||
data_key: str
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
14
homeassistant/components/rainmachine/util.py
Normal file
14
homeassistant/components/rainmachine/util.py
Normal 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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:"):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"init": {
|
||||
"title": "Tankerkoenig options",
|
||||
"data": {
|
||||
"scan_interval": "Update Interval",
|
||||
"stations": "Stations",
|
||||
"show_on_map": "Show stations on map"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,8 +7,6 @@ from pyws66i import WS66i
|
||||
|
||||
from .coordinator import Ws66iDataUpdateCoordinator
|
||||
|
||||
# A dataclass is basically a struct in C/C++
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceRep:
|
||||
|
||||
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"unknown": "Unexpected error"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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']}",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user