Compare commits

...

79 Commits

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

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

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

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

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

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

* use entry_id instead of unique_id

* remove not needed `_hass` property

* fix skiping failing stations

* remove not neccessary error log

* set DeviceEntryType.SERVICE

* fix use entry_id instead of unique_id

* apply suggestions on tests

* add return value also to other tests

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

* update comments

* Switch to ValueError

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

* fixing after MartinHjelmare review conversion alarm state to hass state

* fixing after MartinHjelmare review conversion alarm state to hass state

* manage the status in the alarm control

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

* Suggest change

* suggest fix

* fix

* fix

* Fix suggest

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

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

* Unmute WS66i Zone when volume changes

* Raise CannotConnect instead of ConnectionError in validation method

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

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

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

* Make sure plex creates mocks in the event loop

* drop reload_lock, already inherits
2022-05-28 20:46:34 -07:00
Joakim Sørensen
3a06b5f320 Bump awesomeversion from 22.5.1 to 22.5.2 (#72624) 2022-05-28 20:46:12 -07:00
J. Nick Koston
c45dc49270 Escape % and _ in history/logbook entity_globs, and use ? as _ (#72623)
Co-authored-by: pyos <pyos100500@gmail.com>
2022-05-28 20:45:14 -07:00
Allen Porter
301f7647d1 Defer google calendar integration reload to a task to avoid races of reload during setup (#72608) 2022-05-28 20:45:13 -07:00
Allen Porter
79340f85d2 Don't import google calendar user pref for disabling new entities (#72652) 2022-05-28 20:44:50 -07:00
Paulus Schoutsen
afcc8679dd Handle OAuth2 rejection (#72040) 2022-05-28 20:43:48 -07:00
Paulus Schoutsen
e974a432aa Bumped version to 2022.6.0b2 2022-05-27 11:38:00 -07:00
J. Nick Koston
13f953f49d Add explict type casts for postgresql filters (#72615) 2022-05-27 11:36:44 -07:00
Aaron Bach
38c085f869 Bump regenmaschine to 2022.05.0 (#72613) 2022-05-27 11:36:43 -07:00
J. Nick Koston
2e2fa208a8 Fix recorder system health when the db_url is lacking a hostname (#72612) 2022-05-27 11:36:42 -07:00
Paulus Schoutsen
07c7081ade Revert "Add service entity context (#71558)" (#72610) 2022-05-27 11:36:42 -07:00
xLarry
27908af61e Bump laundrify_aio to v1.1.2 (#72605) 2022-05-27 11:36:41 -07:00
Erik Montnemery
087c0b59ed Update integrations to pass target player when resolving media (#72597) 2022-05-27 11:36:40 -07:00
Erik Montnemery
ad65295201 Require passing target player when resolving media (#72593) 2022-05-27 11:36:39 -07:00
Erik Montnemery
cc53ad12b3 Simplify MQTT PLATFORM_CONFIG_SCHEMA_BASE (#72589) 2022-05-27 11:36:39 -07:00
J. Nick Koston
319275bbbd Revert "Remove sqlite 3.34.1 downgrade workaround by reverting "Downgrade sqlite-libs on docker image (#55591)" (#72342)" (#72578) 2022-05-27 11:36:38 -07:00
Allen Porter
a35edc6751 Reduce the scope of the google calendar track deprecation (#72575) 2022-05-27 11:36:37 -07:00
uvjustin
0d03b85029 Bump httpx to 0.23.0 (#72573)
Co-authored-by: J. Nick Koston <nick@koston.org>
2022-05-27 11:36:05 -07:00
J. Nick Koston
9b779082d5 Fix memory leak when firing state_changed events (#72571) 2022-05-27 11:33:40 -07:00
Joakim Plate
828afd8c05 fjaraskupan: Don't set hardware filters for service id (#72569) 2022-05-27 11:33:39 -07:00
Paulus Schoutsen
bd02c9e5b3 Attach SSL context to SMTP notify and IMAP sensor (#72568) 2022-05-27 11:33:38 -07:00
Bram Kragten
16ab7f2bb1 Update frontend to 20220526.0 (#72567) 2022-05-27 11:33:37 -07:00
Paulus Schoutsen
f8e300ffbe Include provider type in auth token response (#72560) 2022-05-27 11:33:37 -07:00
Raman Gupta
370e4c53f3 Add logbook entries for zwave_js events (#72508)
* Add logbook entries for zwave_js events

* Fix test

* Update homeassistant/components/zwave_js/logbook.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zwave_js/logbook.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zwave_js/logbook.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zwave_js/logbook.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* black

* Remove value updated event

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2022-05-27 11:33:36 -07:00
185 changed files with 3178 additions and 1276 deletions

View File

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

View File

@@ -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 /

View File

@@ -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

View File

@@ -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",
},
)

View File

@@ -24,10 +24,7 @@ from homeassistant.components.media_player import (
from homeassistant.components.media_player.browse_media import (
async_process_play_media_url,
)
from homeassistant.components.media_player.const import (
ATTR_MEDIA_ENQUEUE,
MEDIA_TYPE_MUSIC,
)
from homeassistant.components.media_player.const import MEDIA_TYPE_MUSIC
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_HOST,
@@ -1023,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):

View File

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

View File

@@ -2,7 +2,7 @@
"domain": "bmw_connected_drive",
"name": "BMW Connected Drive",
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"requirements": ["bimmer_connected==0.9.0"],
"requirements": ["bimmer_connected==0.9.3"],
"codeowners": ["@gerard33", "@rikroe"],
"config_flow": true,
"iot_class": "cloud_polling",

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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, {})

View File

@@ -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):

View File

@@ -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:

View File

@@ -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",

View File

@@ -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(

View File

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

View File

@@ -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]

View File

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

View File

@@ -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:

View File

@@ -12,6 +12,7 @@ from typing_extensions import ParamSpec
from homeassistant.components import media_source
from homeassistant.components.media_player import (
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityFeature,
)
@@ -73,6 +74,14 @@ CONTROL_TO_SUPPORT = {
heos_const.CONTROL_PLAY_NEXT: MediaPlayerEntityFeature.NEXT_TRACK,
}
HA_HEOS_ENQUEUE_MAP = {
None: heos_const.ADD_QUEUE_REPLACE_AND_PLAY,
MediaPlayerEnqueue.ADD: heos_const.ADD_QUEUE_ADD_TO_END,
MediaPlayerEnqueue.REPLACE: heos_const.ADD_QUEUE_REPLACE_AND_PLAY,
MediaPlayerEnqueue.NEXT: heos_const.ADD_QUEUE_PLAY_NEXT,
MediaPlayerEnqueue.PLAY: heos_const.ADD_QUEUE_PLAY_NOW,
}
_LOGGER = logging.getLogger(__name__)
@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:

View File

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

View File

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

View File

@@ -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()

View File

@@ -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"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

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

View File

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

View File

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

View File

@@ -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:

View File

@@ -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)

View File

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

View File

@@ -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:

View File

@@ -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:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
)

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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] = {}

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

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

View File

@@ -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)

View File

@@ -18,12 +18,14 @@ import voluptuous as vol
from homeassistant.components import media_source, spotify
from homeassistant.components.media_player import (
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityFeature,
async_process_play_media_url,
)
from homeassistant.components.media_player.const import (
ATTR_INPUT_SOURCE,
ATTR_MEDIA_ANNOUNCE,
ATTR_MEDIA_ENQUEUE,
MEDIA_TYPE_ALBUM,
MEDIA_TYPE_ARTIST,
@@ -526,7 +528,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
self.coordinator.soco.clear_queue()
@soco_error()
def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None:
def play_media( # noqa: C901
self, media_type: str, media_id: str, **kwargs: Any
) -> None:
"""
Send the play_media command to the media player.
@@ -537,9 +541,13 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
If media_type is "playlist", media_id should be a Sonos
Playlist name. Otherwise, media_id should be a URI.
If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue.
"""
# Use 'replace' as the default enqueue option
enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE)
if kwargs.get(ATTR_MEDIA_ANNOUNCE):
# Temporary workaround until announce support is added
enqueue = MediaPlayerEnqueue.PLAY
if spotify.is_spotify_media_type(media_type):
media_type = spotify.resolve_spotify_media_type(media_type)
media_id = spotify.spotify_uri_from_media_browser_url(media_id)
@@ -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:"):

View File

@@ -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(

View File

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

View File

@@ -10,6 +10,7 @@ import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityFeature,
)
@@ -469,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:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:

View File

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

View File

@@ -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:

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

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