forked from home-assistant/core
Compare commits
79 Commits
2022.6.0b1
...
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 | ||
|
|
e974a432aa | ||
|
|
13f953f49d | ||
|
|
38c085f869 | ||
|
|
2e2fa208a8 | ||
|
|
07c7081ade | ||
|
|
27908af61e | ||
|
|
087c0b59ed | ||
|
|
ad65295201 | ||
|
|
cc53ad12b3 | ||
|
|
319275bbbd | ||
|
|
a35edc6751 | ||
|
|
0d03b85029 | ||
|
|
9b779082d5 | ||
|
|
828afd8c05 | ||
|
|
bd02c9e5b3 | ||
|
|
16ab7f2bb1 | ||
|
|
f8e300ffbe | ||
|
|
370e4c53f3 |
@@ -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
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -25,6 +25,21 @@ RUN \
|
||||
-e ./homeassistant --use-deprecated=legacy-resolver \
|
||||
&& python3 -m compileall homeassistant/homeassistant
|
||||
|
||||
# Fix Bug with Alpine 3.14 and sqlite 3.35
|
||||
# https://gitlab.alpinelinux.org/alpine/aports/-/issues/12524
|
||||
ARG BUILD_ARCH
|
||||
RUN \
|
||||
if [ "${BUILD_ARCH}" = "amd64" ]; then \
|
||||
export APK_ARCH=x86_64; \
|
||||
elif [ "${BUILD_ARCH}" = "i386" ]; then \
|
||||
export APK_ARCH=x86; \
|
||||
else \
|
||||
export APK_ARCH=${BUILD_ARCH}; \
|
||||
fi \
|
||||
&& curl -O http://dl-cdn.alpinelinux.org/alpine/v3.13/main/${APK_ARCH}/sqlite-libs-3.34.1-r0.apk \
|
||||
&& apk add --no-cache sqlite-libs-3.34.1-r0.apk \
|
||||
&& rm -f sqlite-libs-3.34.1-r0.apk
|
||||
|
||||
# Home Assistant S6-Overlay
|
||||
COPY rootfs /
|
||||
|
||||
|
||||
@@ -284,7 +284,9 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
|
||||
await self.atv.apps.launch_app(media_id)
|
||||
|
||||
if media_source.is_media_source_id(media_id):
|
||||
play_item = await media_source.async_resolve_media(self.hass, media_id)
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = play_item.url
|
||||
media_type = MEDIA_TYPE_MUSIC
|
||||
|
||||
|
||||
@@ -19,13 +19,15 @@ Exchange the authorization code retrieved from the login flow for tokens.
|
||||
|
||||
Return value will be the access and refresh tokens. The access token will have
|
||||
a limited expiration. New access tokens can be requested using the refresh
|
||||
token.
|
||||
token. The value ha_auth_provider will contain the auth provider type that was
|
||||
used to authorize the refresh token.
|
||||
|
||||
{
|
||||
"access_token": "ABCDEFGH",
|
||||
"expires_in": 1800,
|
||||
"refresh_token": "IJKLMNOPQRST",
|
||||
"token_type": "Bearer"
|
||||
"token_type": "Bearer",
|
||||
"ha_auth_provider": "homeassistant"
|
||||
}
|
||||
|
||||
## Grant type refresh_token
|
||||
@@ -342,7 +344,12 @@ class TokenView(HomeAssistantView):
|
||||
"expires_in": int(
|
||||
refresh_token.access_token_expiration.total_seconds()
|
||||
),
|
||||
}
|
||||
"ha_auth_provider": credential.auth_provider_type,
|
||||
},
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache",
|
||||
},
|
||||
)
|
||||
|
||||
async def _async_handle_refresh_token(self, hass, data, remote_addr):
|
||||
@@ -399,7 +406,11 @@ class TokenView(HomeAssistantView):
|
||||
"expires_in": int(
|
||||
refresh_token.access_token_expiration.total_seconds()
|
||||
),
|
||||
}
|
||||
},
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,25 +1020,20 @@ 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
|
||||
|
||||
if media_source.is_media_source_id(media_id):
|
||||
play_item = await media_source.async_resolve_media(self.hass, media_id)
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = play_item.url
|
||||
|
||||
media_id = async_process_play_media_url(self.hass, media_id)
|
||||
|
||||
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
|
||||
|
||||
@@ -605,7 +605,9 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
"""Play a piece of media."""
|
||||
# Handle media_source
|
||||
if media_source.is_media_source_id(media_id):
|
||||
sourced_media = await media_source.async_resolve_media(self.hass, media_id)
|
||||
sourced_media = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_type = sourced_media.mime_type
|
||||
media_id = sourced_media.url
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -597,7 +597,9 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
||||
|
||||
# If media is media_source, resolve it to url and MIME type, and maybe metadata
|
||||
if media_source.is_media_source_id(media_id):
|
||||
sourced_media = await media_source.async_resolve_media(self.hass, media_id)
|
||||
sourced_media = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_type = sourced_media.mime_type
|
||||
media_id = sourced_media.url
|
||||
_LOGGER.debug("sourced_media is %s", sourced_media)
|
||||
|
||||
@@ -95,7 +95,9 @@ class EsphomeMediaPlayer(
|
||||
) -> None:
|
||||
"""Send the play command with media url to the media player."""
|
||||
if media_source.is_media_source_id(media_id):
|
||||
sourced_media = await media_source.async_resolve_media(self.hass, media_id)
|
||||
sourced_media = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = sourced_media.url
|
||||
|
||||
media_id = async_process_play_media_url(self.hass, media_id)
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
from bleak import BleakScanner
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from fjaraskupan import UUID_SERVICE, Device, State, device_filter
|
||||
from fjaraskupan import DEVICE_NAME, Device, State, device_filter
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
@@ -90,7 +90,7 @@ class EntryState:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Fjäråskupan from a config entry."""
|
||||
|
||||
scanner = BleakScanner(filters={"UUIDs": [str(UUID_SERVICE)]})
|
||||
scanner = BleakScanner(filters={"Pattern": DEVICE_NAME, "DuplicateData": True})
|
||||
|
||||
state = EntryState(scanner, {})
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
@@ -7,7 +7,7 @@ import async_timeout
|
||||
from bleak import BleakScanner
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from fjaraskupan import UUID_SERVICE, device_filter
|
||||
from fjaraskupan import DEVICE_NAME, device_filter
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.config_entry_flow import register_discovery_flow
|
||||
@@ -27,7 +27,8 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
event.set()
|
||||
|
||||
async with BleakScanner(
|
||||
detection_callback=detection, filters={"UUIDs": [str(UUID_SERVICE)]}
|
||||
detection_callback=detection,
|
||||
filters={"Pattern": DEVICE_NAME, "DuplicateData": True},
|
||||
):
|
||||
try:
|
||||
async with async_timeout.timeout(CONST_WAIT_TIME):
|
||||
|
||||
@@ -666,7 +666,9 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
"""Play a URI."""
|
||||
if media_source.is_media_source_id(media_id):
|
||||
media_type = MEDIA_TYPE_MUSIC
|
||||
play_item = await media_source.async_resolve_media(self.hass, media_id)
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = play_item.url
|
||||
|
||||
if media_type == MEDIA_TYPE_MUSIC:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "frontend",
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": ["home-assistant-frontend==20220525.0"],
|
||||
"requirements": ["home-assistant-frontend==20220531.0"],
|
||||
"dependencies": [
|
||||
"api",
|
||||
"auth",
|
||||
|
||||
@@ -117,7 +117,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
|
||||
_SINGLE_CALSEARCH_CONFIG = vol.All(
|
||||
cv.deprecated(CONF_MAX_RESULTS),
|
||||
cv.deprecated(CONF_TRACK),
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
@@ -200,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
|
||||
@@ -234,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"
|
||||
)
|
||||
@@ -248,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:
|
||||
@@ -287,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(
|
||||
|
||||
@@ -93,6 +93,11 @@ def _async_setup_entities(
|
||||
num_entities = len(disc_info[CONF_ENTITIES])
|
||||
for data in disc_info[CONF_ENTITIES]:
|
||||
entity_enabled = data.get(CONF_TRACK, True)
|
||||
if not entity_enabled:
|
||||
_LOGGER.warning(
|
||||
"The 'track' option in google_calendars.yaml has been deprecated. The setting "
|
||||
"has been imported to the UI, and should now be removed from google_calendars.yaml"
|
||||
)
|
||||
entity_name = data[CONF_DEVICE_ID]
|
||||
entity_id = generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass)
|
||||
calendar_id = disc_info[CONF_CAL_ID]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -96,7 +96,9 @@ class GstreamerDevice(MediaPlayerEntity):
|
||||
"""Play media."""
|
||||
# Handle media_source
|
||||
if media_source.is_media_source_id(media_id):
|
||||
sourced_media = await media_source.async_resolve_media(self.hass, media_id)
|
||||
sourced_media = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = sourced_media.url
|
||||
|
||||
elif media_type != MEDIA_TYPE_MUSIC:
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -192,7 +201,9 @@ class HeosMediaPlayer(MediaPlayerEntity):
|
||||
"""Play a piece of media."""
|
||||
if media_source.is_media_source_id(media_id):
|
||||
media_type = MEDIA_TYPE_URL
|
||||
play_item = await media_source.async_resolve_media(self.hass, media_id)
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = play_item.url
|
||||
|
||||
if media_type in (MEDIA_TYPE_URL, MEDIA_TYPE_MUSIC):
|
||||
@@ -222,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": {
|
||||
|
||||
@@ -17,12 +17,14 @@ from homeassistant.const import (
|
||||
CONF_PORT,
|
||||
CONF_USERNAME,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
CONF_VERIFY_SSL,
|
||||
CONTENT_TYPE_TEXT_PLAIN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util.ssl import client_context
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,6 +48,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_FOLDER, default="INBOX"): cv.string,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -58,11 +61,12 @@ def setup_platform(
|
||||
) -> None:
|
||||
"""Set up the Email sensor platform."""
|
||||
reader = EmailReader(
|
||||
config.get(CONF_USERNAME),
|
||||
config.get(CONF_PASSWORD),
|
||||
config.get(CONF_SERVER),
|
||||
config.get(CONF_PORT),
|
||||
config.get(CONF_FOLDER),
|
||||
config[CONF_USERNAME],
|
||||
config[CONF_PASSWORD],
|
||||
config[CONF_SERVER],
|
||||
config[CONF_PORT],
|
||||
config[CONF_FOLDER],
|
||||
config[CONF_VERIFY_SSL],
|
||||
)
|
||||
|
||||
if (value_template := config.get(CONF_VALUE_TEMPLATE)) is not None:
|
||||
@@ -70,8 +74,8 @@ def setup_platform(
|
||||
sensor = EmailContentSensor(
|
||||
hass,
|
||||
reader,
|
||||
config.get(CONF_NAME) or config.get(CONF_USERNAME),
|
||||
config.get(CONF_SENDERS),
|
||||
config.get(CONF_NAME) or config[CONF_USERNAME],
|
||||
config[CONF_SENDERS],
|
||||
value_template,
|
||||
)
|
||||
|
||||
@@ -82,21 +86,25 @@ def setup_platform(
|
||||
class EmailReader:
|
||||
"""A class to read emails from an IMAP server."""
|
||||
|
||||
def __init__(self, user, password, server, port, folder):
|
||||
def __init__(self, user, password, server, port, folder, verify_ssl):
|
||||
"""Initialize the Email Reader."""
|
||||
self._user = user
|
||||
self._password = password
|
||||
self._server = server
|
||||
self._port = port
|
||||
self._folder = folder
|
||||
self._verify_ssl = verify_ssl
|
||||
self._last_id = None
|
||||
self._unread_ids = deque([])
|
||||
self.connection = None
|
||||
|
||||
def connect(self):
|
||||
"""Login and setup the connection."""
|
||||
ssl_context = client_context() if self._verify_ssl else None
|
||||
try:
|
||||
self.connection = imaplib.IMAP4_SSL(self._server, self._port)
|
||||
self.connection = imaplib.IMAP4_SSL(
|
||||
self._server, self._port, ssl_context=ssl_context
|
||||
)
|
||||
self.connection.login(self._user, self._password)
|
||||
return True
|
||||
except imaplib.IMAP4.error:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -713,7 +713,9 @@ class KodiEntity(MediaPlayerEntity):
|
||||
"""Send the play_media command to the media player."""
|
||||
if media_source.is_media_source_id(media_id):
|
||||
media_type = MEDIA_TYPE_URL
|
||||
play_item = await media_source.async_resolve_media(self.hass, media_id)
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = play_item.url
|
||||
|
||||
media_type_lower = media_type.lower()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "laundrify",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/laundrify",
|
||||
"requirements": ["laundrify_aio==1.1.1"],
|
||||
"requirements": ["laundrify_aio==1.1.2"],
|
||||
"codeowners": ["@xLarry"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -18,10 +18,11 @@ from homeassistant.components.media_player.browse_media import (
|
||||
)
|
||||
from homeassistant.components.websocket_api import ActiveConnection
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.frame import report
|
||||
from homeassistant.helpers.integration_platform import (
|
||||
async_process_integration_platforms,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
||||
from homeassistant.loader import bind_hass
|
||||
|
||||
from . import local_source
|
||||
@@ -80,15 +81,15 @@ async def _process_media_source_platform(
|
||||
|
||||
@callback
|
||||
def _get_media_item(
|
||||
hass: HomeAssistant, media_content_id: str | None
|
||||
hass: HomeAssistant, media_content_id: str | None, target_media_player: str | None
|
||||
) -> MediaSourceItem:
|
||||
"""Return media item."""
|
||||
if media_content_id:
|
||||
item = MediaSourceItem.from_uri(hass, media_content_id)
|
||||
item = MediaSourceItem.from_uri(hass, media_content_id, target_media_player)
|
||||
else:
|
||||
# We default to our own domain if its only one registered
|
||||
domain = None if len(hass.data[DOMAIN]) > 1 else DOMAIN
|
||||
return MediaSourceItem(hass, domain, "")
|
||||
return MediaSourceItem(hass, domain, "", target_media_player)
|
||||
|
||||
if item.domain is not None and item.domain not in hass.data[DOMAIN]:
|
||||
raise ValueError("Unknown media source")
|
||||
@@ -108,7 +109,7 @@ async def async_browse_media(
|
||||
raise BrowseError("Media Source not loaded")
|
||||
|
||||
try:
|
||||
item = await _get_media_item(hass, media_content_id).async_browse()
|
||||
item = await _get_media_item(hass, media_content_id, None).async_browse()
|
||||
except ValueError as err:
|
||||
raise BrowseError(str(err)) from err
|
||||
|
||||
@@ -124,13 +125,21 @@ async def async_browse_media(
|
||||
|
||||
|
||||
@bind_hass
|
||||
async def async_resolve_media(hass: HomeAssistant, media_content_id: str) -> PlayMedia:
|
||||
async def async_resolve_media(
|
||||
hass: HomeAssistant,
|
||||
media_content_id: str,
|
||||
target_media_player: str | None | UndefinedType = UNDEFINED,
|
||||
) -> PlayMedia:
|
||||
"""Get info to play media."""
|
||||
if DOMAIN not in hass.data:
|
||||
raise Unresolvable("Media Source not loaded")
|
||||
|
||||
if target_media_player is UNDEFINED:
|
||||
report("calls media_source.async_resolve_media without passing an entity_id")
|
||||
target_media_player = None
|
||||
|
||||
try:
|
||||
item = _get_media_item(hass, media_content_id)
|
||||
item = _get_media_item(hass, media_content_id, target_media_player)
|
||||
except ValueError as err:
|
||||
raise Unresolvable(str(err)) from err
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ class UploadMediaView(http.HomeAssistantView):
|
||||
raise web.HTTPBadRequest() from err
|
||||
|
||||
try:
|
||||
item = MediaSourceItem.from_uri(self.hass, data["media_content_id"])
|
||||
item = MediaSourceItem.from_uri(self.hass, data["media_content_id"], None)
|
||||
except ValueError as err:
|
||||
LOGGER.error("Received invalid upload data: %s", err)
|
||||
raise web.HTTPBadRequest() from err
|
||||
@@ -328,7 +328,7 @@ async def websocket_remove_media(
|
||||
) -> None:
|
||||
"""Remove media."""
|
||||
try:
|
||||
item = MediaSourceItem.from_uri(hass, msg["media_content_id"])
|
||||
item = MediaSourceItem.from_uri(hass, msg["media_content_id"], None)
|
||||
except ValueError as err:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
|
||||
return
|
||||
|
||||
@@ -50,6 +50,7 @@ class MediaSourceItem:
|
||||
hass: HomeAssistant
|
||||
domain: str | None
|
||||
identifier: str
|
||||
target_media_player: str | None
|
||||
|
||||
async def async_browse(self) -> BrowseMediaSource:
|
||||
"""Browse this item."""
|
||||
@@ -94,7 +95,9 @@ class MediaSourceItem:
|
||||
return cast(MediaSource, self.hass.data[DOMAIN][self.domain])
|
||||
|
||||
@classmethod
|
||||
def from_uri(cls, hass: HomeAssistant, uri: str) -> MediaSourceItem:
|
||||
def from_uri(
|
||||
cls, hass: HomeAssistant, uri: str, target_media_player: str | None
|
||||
) -> MediaSourceItem:
|
||||
"""Create an item from a uri."""
|
||||
if not (match := URI_SCHEME_REGEX.match(uri)):
|
||||
raise ValueError("Invalid media source URI")
|
||||
@@ -102,7 +105,7 @@ class MediaSourceItem:
|
||||
domain = match.group("domain")
|
||||
identifier = match.group("identifier")
|
||||
|
||||
return cls(hass, domain, identifier)
|
||||
return cls(hass, domain, identifier, target_media_player)
|
||||
|
||||
|
||||
class MediaSource(ABC):
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -453,7 +453,9 @@ class MpdDevice(MediaPlayerEntity):
|
||||
"""Send the media player the command for playing a playlist."""
|
||||
if media_source.is_media_source_id(media_id):
|
||||
media_type = MEDIA_TYPE_MUSIC
|
||||
play_item = await media_source.async_resolve_media(self.hass, media_id)
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = async_process_play_media_url(self.hass, play_item.url)
|
||||
|
||||
if media_type == MEDIA_TYPE_PLAYLIST:
|
||||
|
||||
@@ -190,26 +190,7 @@ MQTT_WILL_BIRTH_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema(
|
||||
{
|
||||
vol.Optional(Platform.ALARM_CONTROL_PANEL.value): cv.ensure_list,
|
||||
vol.Optional(Platform.BINARY_SENSOR.value): cv.ensure_list,
|
||||
vol.Optional(Platform.BUTTON.value): cv.ensure_list,
|
||||
vol.Optional(Platform.CAMERA.value): cv.ensure_list,
|
||||
vol.Optional(Platform.CLIMATE.value): cv.ensure_list,
|
||||
vol.Optional(Platform.COVER.value): cv.ensure_list,
|
||||
vol.Optional(Platform.DEVICE_TRACKER.value): cv.ensure_list,
|
||||
vol.Optional(Platform.FAN.value): cv.ensure_list,
|
||||
vol.Optional(Platform.HUMIDIFIER.value): cv.ensure_list,
|
||||
vol.Optional(Platform.LIGHT.value): cv.ensure_list,
|
||||
vol.Optional(Platform.LOCK.value): cv.ensure_list,
|
||||
vol.Optional(Platform.NUMBER.value): cv.ensure_list,
|
||||
vol.Optional(Platform.SCENE.value): cv.ensure_list,
|
||||
vol.Optional(Platform.SELECT.value): cv.ensure_list,
|
||||
vol.Optional(Platform.SIREN.value): cv.ensure_list,
|
||||
vol.Optional(Platform.SENSOR.value): cv.ensure_list,
|
||||
vol.Optional(Platform.SWITCH.value): cv.ensure_list,
|
||||
vol.Optional(Platform.VACUUM.value): cv.ensure_list,
|
||||
}
|
||||
{vol.Optional(platform.value): cv.ensure_list for platform in PLATFORMS}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend(
|
||||
@@ -704,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},
|
||||
|
||||
@@ -209,7 +209,9 @@ class OpenhomeDevice(MediaPlayerEntity):
|
||||
"""Send the play_media command to the media player."""
|
||||
if media_source.is_media_source_id(media_id):
|
||||
media_type = MEDIA_TYPE_MUSIC
|
||||
play_item = await media_source.async_resolve_media(self.hass, media_id)
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = play_item.url
|
||||
|
||||
if media_type != MEDIA_TYPE_MUSIC:
|
||||
|
||||
@@ -188,7 +188,9 @@ class PanasonicVieraTVEntity(MediaPlayerEntity):
|
||||
"""Play media."""
|
||||
if media_source.is_media_source_id(media_id):
|
||||
media_type = MEDIA_TYPE_URL
|
||||
play_item = await media_source.async_resolve_media(self.hass, media_id)
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = play_item.url
|
||||
|
||||
if media_type != MEDIA_TYPE_URL:
|
||||
|
||||
@@ -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.01.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
|
||||
@@ -5,7 +5,7 @@ from collections.abc import Callable, Iterable
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Column, not_, or_
|
||||
from sqlalchemy import JSON, Column, Text, cast, not_, or_
|
||||
from sqlalchemy.sql.elements import ClauseList
|
||||
|
||||
from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE
|
||||
@@ -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:
|
||||
@@ -110,8 +147,7 @@ class Filters:
|
||||
"""Generate the entity filter query."""
|
||||
_encoder = json.dumps
|
||||
return or_(
|
||||
(ENTITY_ID_IN_EVENT == _encoder(None))
|
||||
& (OLD_ENTITY_ID_IN_EVENT == _encoder(None)),
|
||||
(ENTITY_ID_IN_EVENT == JSON.NULL) & (OLD_ENTITY_ID_IN_EVENT == JSON.NULL),
|
||||
self._generate_filter_for_columns(
|
||||
(ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT), _encoder
|
||||
).self_group(),
|
||||
@@ -123,7 +159,9 @@ def _globs_to_like(
|
||||
) -> ClauseList:
|
||||
"""Translate glob to sql."""
|
||||
return or_(
|
||||
column.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
|
||||
)
|
||||
@@ -133,7 +171,7 @@ def _entity_matcher(
|
||||
entity_ids: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any]
|
||||
) -> ClauseList:
|
||||
return or_(
|
||||
column.in_([encoder(entity_id) for entity_id in entity_ids])
|
||||
cast(column, Text()).in_([encoder(entity_id) for entity_id in entity_ids])
|
||||
for column in columns
|
||||
)
|
||||
|
||||
@@ -142,5 +180,7 @@ def _domain_matcher(
|
||||
domains: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any]
|
||||
) -> ClauseList:
|
||||
return or_(
|
||||
column.like(encoder(f"{domain}.%")) for domain in domains for column in columns
|
||||
cast(column, Text()).like(encoder(f"{domain}.%"))
|
||||
for domain in domains
|
||||
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"
|
||||
|
||||
@@ -746,7 +746,7 @@ class LazyState(State):
|
||||
def context(self) -> Context: # type: ignore[override]
|
||||
"""State context."""
|
||||
if self._context is None:
|
||||
self._context = Context(id=None) # type: ignore[arg-type]
|
||||
self._context = Context(id=None)
|
||||
return self._context
|
||||
|
||||
@context.setter
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from yarl import URL
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from homeassistant.components import system_health
|
||||
from homeassistant.components.recorder.core import Recorder
|
||||
@@ -60,7 +59,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
instance = get_instance(hass)
|
||||
|
||||
run_history = instance.run_history
|
||||
database_name = URL(instance.db_url).path.lstrip("/")
|
||||
database_name = urlparse(instance.db_url).path.lstrip("/")
|
||||
db_engine_info = _async_get_db_engine_info(instance)
|
||||
db_stats: dict[str, Any] = {}
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -384,7 +384,9 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
||||
|
||||
# Handle media_source
|
||||
if media_source.is_media_source_id(media_id):
|
||||
sourced_media = await media_source.async_resolve_media(self.hass, media_id)
|
||||
sourced_media = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_type = MEDIA_TYPE_URL
|
||||
media_id = sourced_media.url
|
||||
mime_type = sourced_media.mime_type
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -180,7 +180,9 @@ class SlimProtoPlayer(MediaPlayerEntity):
|
||||
to_send_media_type: str | None = media_type
|
||||
# Handle media_source
|
||||
if media_source.is_media_source_id(media_id):
|
||||
sourced_media = await media_source.async_resolve_media(self.hass, media_id)
|
||||
sourced_media = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = sourced_media.url
|
||||
to_send_media_type = sourced_media.mime_type
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -25,10 +25,12 @@ from homeassistant.const import (
|
||||
CONF_SENDER,
|
||||
CONF_TIMEOUT,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.reload import setup_reload_service
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.util.ssl import client_context
|
||||
|
||||
from . import DOMAIN, PLATFORMS
|
||||
|
||||
@@ -65,6 +67,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
vol.Optional(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_SENDER_NAME): cv.string,
|
||||
vol.Optional(CONF_DEBUG, default=DEFAULT_DEBUG): cv.boolean,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -73,16 +76,17 @@ def get_service(hass, config, discovery_info=None):
|
||||
"""Get the mail notification service."""
|
||||
setup_reload_service(hass, DOMAIN, PLATFORMS)
|
||||
mail_service = MailNotificationService(
|
||||
config.get(CONF_SERVER),
|
||||
config.get(CONF_PORT),
|
||||
config.get(CONF_TIMEOUT),
|
||||
config.get(CONF_SENDER),
|
||||
config.get(CONF_ENCRYPTION),
|
||||
config[CONF_SERVER],
|
||||
config[CONF_PORT],
|
||||
config[CONF_TIMEOUT],
|
||||
config[CONF_SENDER],
|
||||
config[CONF_ENCRYPTION],
|
||||
config.get(CONF_USERNAME),
|
||||
config.get(CONF_PASSWORD),
|
||||
config.get(CONF_RECIPIENT),
|
||||
config[CONF_RECIPIENT],
|
||||
config.get(CONF_SENDER_NAME),
|
||||
config.get(CONF_DEBUG),
|
||||
config[CONF_DEBUG],
|
||||
config[CONF_VERIFY_SSL],
|
||||
)
|
||||
|
||||
if mail_service.connection_is_valid():
|
||||
@@ -106,6 +110,7 @@ class MailNotificationService(BaseNotificationService):
|
||||
recipients,
|
||||
sender_name,
|
||||
debug,
|
||||
verify_ssl,
|
||||
):
|
||||
"""Initialize the SMTP service."""
|
||||
self._server = server
|
||||
@@ -118,18 +123,25 @@ class MailNotificationService(BaseNotificationService):
|
||||
self.recipients = recipients
|
||||
self._sender_name = sender_name
|
||||
self.debug = debug
|
||||
self._verify_ssl = verify_ssl
|
||||
self.tries = 2
|
||||
|
||||
def connect(self):
|
||||
"""Connect/authenticate to SMTP Server."""
|
||||
ssl_context = client_context() if self._verify_ssl else None
|
||||
if self.encryption == "tls":
|
||||
mail = smtplib.SMTP_SSL(self._server, self._port, timeout=self._timeout)
|
||||
mail = smtplib.SMTP_SSL(
|
||||
self._server,
|
||||
self._port,
|
||||
timeout=self._timeout,
|
||||
context=ssl_context,
|
||||
)
|
||||
else:
|
||||
mail = smtplib.SMTP(self._server, self._port, timeout=self._timeout)
|
||||
mail.set_debuglevel(self.debug)
|
||||
mail.ehlo_or_helo_if_needed()
|
||||
if self.encryption == "starttls":
|
||||
mail.starttls()
|
||||
mail.starttls(context=ssl_context)
|
||||
mail.ehlo()
|
||||
if self.username and self.password:
|
||||
mail.login(self.username, self.password)
|
||||
|
||||
@@ -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)
|
||||
@@ -551,7 +559,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
|
||||
media_type = MEDIA_TYPE_MUSIC
|
||||
media_id = (
|
||||
run_coroutine_threadsafe(
|
||||
media_source.async_resolve_media(self.hass, media_id),
|
||||
media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
),
|
||||
self.hass.loop,
|
||||
)
|
||||
.result()
|
||||
@@ -573,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)
|
||||
@@ -583,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)
|
||||
@@ -593,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:"):
|
||||
|
||||
@@ -357,7 +357,9 @@ class SoundTouchDevice(MediaPlayerEntity):
|
||||
async def async_play_media(self, media_type, media_id, **kwargs):
|
||||
"""Play a piece of media."""
|
||||
if media_source.is_media_source_id(media_id):
|
||||
play_item = await media_source.async_resolve_media(self.hass, media_id)
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = async_process_play_media_url(self.hass, play_item.url)
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
|
||||
@@ -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,20 +470,23 @@ 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
|
||||
play_item = await media_source.async_resolve_media(self.hass, media_id)
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = play_item.url
|
||||
|
||||
if media_type in 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,
|
||||
|
||||
@@ -118,7 +118,9 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity):
|
||||
"""Play a piece of media."""
|
||||
if media_source.is_media_source_id(media_id):
|
||||
media_type = MEDIA_TYPE_MUSIC
|
||||
play_item = await media_source.async_resolve_media(self.hass, media_id)
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = async_process_play_media_url(self.hass, play_item.url)
|
||||
|
||||
if media_type != MEDIA_TYPE_MUSIC:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -168,7 +168,9 @@ class VlcDevice(MediaPlayerEntity):
|
||||
"""Play media from a URL or file."""
|
||||
# Handle media_source
|
||||
if media_source.is_media_source_id(media_id):
|
||||
sourced_media = await media_source.async_resolve_media(self.hass, media_id)
|
||||
sourced_media = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = sourced_media.url
|
||||
|
||||
elif media_type != MEDIA_TYPE_MUSIC:
|
||||
|
||||
@@ -296,7 +296,9 @@ class VlcDevice(MediaPlayerEntity):
|
||||
"""Play media from a URL or file."""
|
||||
# Handle media_source
|
||||
if media_source.is_media_source_id(media_id):
|
||||
sourced_media = await media_source.async_resolve_media(self.hass, media_id)
|
||||
sourced_media = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_type = sourced_media.mime_type
|
||||
media_id = sourced_media.url
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user