Skip to content

Commit

Permalink
[ 1.0.19 ] Changed all media_player.async_write_ha_state() calls to…
Browse files Browse the repository at this point in the history
… `schedule_update_ha_state(force_refresh=True)` calls due to HA 2024.5 release requirements. This fixes the issue of "Failed to call service X. Detected that custom integration 'Y' calls async_write_ha_state from a thread at Z. Please report it to the author of the 'Y' custom integration.".

  * Added more information to system health display (version, integration configs, etc).
  * Updated Python version from 3.11 to 3.12.3 due to HA 2024.5 release requirements.
  • Loading branch information
thlucas1 committed May 3, 2024
1 parent 3081aad commit 8facfcb
Show file tree
Hide file tree
Showing 9 changed files with 114 additions and 36 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ Change are listed in reverse chronological order (newest to oldest).

<span class="changelog">

###### [ 1.0.19 ] - 2024/05/03

* Changed all `media_player.async_write_ha_state()` calls to `schedule_update_ha_state(force_refresh=True)` calls due to HA 2024.5 release requirements. This fixes the issue of "Failed to call service X. Detected that custom integration 'Y' calls async_write_ha_state from a thread at Z. Please report it to the author of the 'Y' custom integration.".
* Added more information to system health display (version, integration configs, etc).
* Updated Python version from 3.11 to 3.12.3 due to HA 2024.5 release requirements.

###### [ 1.0.18 ] - 2024/04/25

* Updated various `media_player` services that control playback to verify a Spotify Connect Player device is active. If there is no active device, or the default device was specified (e.g. "*"), then we will force the configuration option default device to be used and transfer playback to it. If an active device was found, then we will use it without transferring playback for services that do not specify a `deviceId` argument. For services that can supply a `deviceId` argument, we will issue a transfer playback command if a device id (or name) was specified.
Expand Down
2 changes: 1 addition & 1 deletion custom_components/spotifyplus/browse_media.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Support for Spotify media browsing."""
from __future__ import annotations
from functools import partial
from enum import StrEnum
import logging
import base64
import os
Expand All @@ -10,7 +11,6 @@
from spotifywebapipython import SpotifyClient
from spotifywebapipython.models import *

from homeassistant.backports.enum import StrEnum
from homeassistant.components.media_player import (
BrowseError,
BrowseMedia,
Expand Down
2 changes: 1 addition & 1 deletion custom_components/spotifyplus/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@
"spotifywebapiPython==1.0.43",
"urllib3>=1.21.1,<1.27"
],
"version": "1.0.18",
"version": "1.0.19",
"zeroconf": [ "_spotify-connect._tcp.local." ]
}
49 changes: 29 additions & 20 deletions custom_components/spotifyplus/media_player.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
"""Support for interacting with Spotify Connect."""

# Important notes about HA State writes:
#
# `self.async_write_ha_state()` should always be used inside of the event loop (any method that is async itself or a callback).
# If you are in a `async def` method or one wrapped in `@callback`, use `async_write_ha_state` since you are inside of the event loop.

# `self.schedule_update_ha_state(force_refresh=True)` should be unsed when not inside of the event loop (e.g. for sync functions that are ran
# inside of the executor thread). If you are in a `def` method (no async) then use `schedule_update_ha_state` since you are inside of the event loop.

from __future__ import annotations

import datetime as dt
Expand Down Expand Up @@ -200,7 +209,7 @@ def wrapper(self: _SpotifyMediaPlayerT, *args: _P.args, **kwargs: _P.kwargs) ->
result = func(self, *args, **kwargs)

# do not update HA state in this handler! doing so causes UI buttons
# pressed to "toggle" between states. the "self.async_write_ha_state()"
# pressed to "toggle" between states. the "self.schedule_update_ha_state(force_refresh=True)"
# call should be done in the individual methods.

# return function result to caller.
Expand Down Expand Up @@ -479,7 +488,7 @@ def media_play(self) -> None:

# update ha state.
self._attr_state = MediaPlayerState.PLAYING
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)

# verify that a spotify connect player device is active.
deviceId:str = self._VerifyDeviceActive()
Expand All @@ -495,7 +504,7 @@ def media_pause(self) -> None:

# update ha state.
self._attr_state = MediaPlayerState.PAUSED
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)

# verify that a spotify connect player device is active.
deviceId:str = self._VerifyDeviceActive()
Expand Down Expand Up @@ -536,7 +545,7 @@ def media_seek(self, position: float) -> None:
# update ha state.
self._attr_media_position = position
self._attr_media_position_updated_at = utcnow()
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)

# verify that a spotify connect player device is active.
deviceId:str = self._VerifyDeviceActive()
Expand All @@ -554,15 +563,15 @@ def mute_volume(self, mute:bool) -> None:

if mute:
self._volume_level_saved = self._attr_volume_level
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)
self.set_volume_level(0.0)
else:
# did we save the volume on a previous mute request? if not, then default volume.
if self._volume_level_saved is None or self._volume_level_saved == 0.0:
_logsi.LogVerbose("Previously saved volume was not set; defaulting to 0.10")
self._volume_level_saved = 0.10
self._attr_volume_level = self._volume_level_saved
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)
self.set_volume_level(self._volume_level_saved)


Expand Down Expand Up @@ -630,14 +639,14 @@ def play_media(self, media_type: MediaType | str, media_id: str, **kwargs: Any)
if media_type in {MediaType.TRACK, MediaType.EPISODE, MediaType.MUSIC}:

self._attr_state = MediaPlayerState.PLAYING
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)
_logsi.LogVerbose("Playing via PlayerMediaPlayTracks: uris='%s', deviceId='%s'" % (media_id, deviceId))
self.data.spotifyClient.PlayerMediaPlayTracks([media_id], deviceId=deviceId)

elif media_type in PLAYABLE_MEDIA_TYPES:

self._attr_state = MediaPlayerState.PLAYING
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)
_logsi.LogVerbose("Playing via PlayerMediaPlayContext: contextUri='%s', deviceId='%s'" % (media_id, deviceId))
self.data.spotifyClient.PlayerMediaPlayContext(media_id, deviceId=deviceId)

Expand Down Expand Up @@ -671,7 +680,7 @@ def set_shuffle(self, shuffle: bool) -> None:

# update ha state.
self._attr_shuffle = shuffle
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)

# verify that a spotify connect player device is active.
deviceId:str = self._VerifyDeviceActive()
Expand All @@ -689,7 +698,7 @@ def set_repeat(self, repeat: RepeatMode) -> None:
if repeat not in REPEAT_MODE_MAPPING_TO_SPOTIFY:
raise ValueError(f"Unsupported repeat mode: {repeat}")
self._attr_repeat = repeat
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)

# verify that a spotify connect player device is active.
deviceId:str = self._VerifyDeviceActive()
Expand All @@ -709,7 +718,7 @@ def set_volume_level(self, volume: float) -> None:

# update ha state.
self._attr_volume_level = volume
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)

# verify that a spotify connect player device is active.
deviceId:str = self._VerifyDeviceActive()
Expand All @@ -731,7 +740,7 @@ def turn_off(self) -> None:
# set media player state and update ha state.
self._attr_state = MediaPlayerState.OFF
_logsi.LogVerbose("'%s': MediaPlayerState set to '%s'" % (self.name, self._attr_state))
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)

# get current player state.
self._playerState = self.data.spotifyClient.GetPlayerPlaybackState(additionalTypes=MediaType.EPISODE.value)
Expand All @@ -758,7 +767,7 @@ def turn_off(self) -> None:
finally:

# update ha state.
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)

# trace.
_logsi.LeaveMethod(SILevel.Debug)
Expand All @@ -781,7 +790,7 @@ def turn_on(self) -> None:
# set media player state and update ha state.
self._attr_state = MediaPlayerState.IDLE
_logsi.LogVerbose("'%s': MediaPlayerState set to '%s'" % (self.name, self._attr_state))
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)

# call script to power on device.
self._CallScriptPower(self.data.OptionScriptTurnOn, "turn_on")
Expand Down Expand Up @@ -810,7 +819,7 @@ def turn_on(self) -> None:
finally:

# update ha state.
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)

# trace.
_logsi.LeaveMethod(SILevel.Debug)
Expand Down Expand Up @@ -954,7 +963,7 @@ def _handle_devices_update(self) -> None:
return

# inform HA of our current state.
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)


def _UpdateHAFromPlayerPlayState(self, playerPlayState:PlayerPlayState) -> None:
Expand Down Expand Up @@ -2769,7 +2778,7 @@ def service_spotify_player_media_play_context(self,
# self.data.spotifyClient.PlayerTransferPlayback(deviceId, True)

# update ha state.
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)

# the following exceptions have already been logged, so we just need to
# pass them back to HA for display in the log (or service UI).
Expand Down Expand Up @@ -2831,7 +2840,7 @@ def service_spotify_player_media_play_track_favorites(self,
# self.data.spotifyClient.PlayerTransferPlayback(deviceId, True)

# update ha state.
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)

# the following exceptions have already been logged, so we just need to
# pass them back to HA for display in the log (or service UI).
Expand Down Expand Up @@ -2898,7 +2907,7 @@ def service_spotify_player_media_play_tracks(self,
# self.data.spotifyClient.PlayerTransferPlayback(deviceId, True)

# update ha state.
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)

# the following exceptions have already been logged, so we just need to
# pass them back to HA for display in the log (or service UI).
Expand Down Expand Up @@ -2957,7 +2966,7 @@ def service_spotify_player_transfer_playback(self,
self.data.spotifyClient.PlayerTransferPlayback(deviceId, play)

# update ha state.
self.async_write_ha_state()
self.schedule_update_ha_state(force_refresh=True)

# the following exceptions have already been logged, so we just need to
# pass them back to HA for display in the log (or service UI).
Expand Down
4 changes: 3 additions & 1 deletion custom_components/spotifyplus/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
},
"system_health": {
"info": {
"api_endpoint_reachable": "Spotify API endpoint reachable"
"integration_version": "Version",
"api_endpoint_reachable": "Spotify API endpoint reachable",
"clients_configured": "Clients Configured"
}
},
"issues": {
Expand Down
77 changes: 68 additions & 9 deletions custom_components/spotifyplus/system_health.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,79 @@
"""Provide info to system health."""
from typing import Any
import json

from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback

from .const import DOMAIN
from .instancedata_spotifyplus import InstanceDataSpotifyPlus

# get smartinspect logger reference; create a new session for this module name.
from smartinspectpython.siauto import SIAuto, SILevel, SISession
import logging
_logsi:SISession = SIAuto.Si.GetSession(__name__)
if (_logsi == None):
_logsi = SIAuto.Si.AddSession(__name__, True)
_logsi.SystemLogger = logging.getLogger(__name__)


@callback
def async_register(
hass: HomeAssistant, register: system_health.SystemHealthRegistration
) -> None:
def async_register(hass: HomeAssistant, register: system_health.SystemHealthRegistration) -> None:
"""Register system health callbacks."""
register.async_register_info(system_health_info)
register.async_register_info(system_health_info, "/config/integrations/integration/%s" % DOMAIN)


async def system_health_info(hass):
"""Get info for the info page."""
return {
"api_endpoint_reachable": system_health.async_check_can_reach_url(
hass, "https://api.spotify.com"
)
}

try:

# trace.
_logsi.EnterMethod(SILevel.Debug)

# create dictionary for health information.
healthInfo: dict[str, Any] = {}

# add manifest file details.
myConfigDir:str = "%s/custom_components/%s" % (hass.config.config_dir, DOMAIN)
myManifestPath:str = "%s/manifest.json" % (myConfigDir)
_logsi.LogTextFile(SILevel.Verbose, "Integration Manifest File (%s)" % myManifestPath, myManifestPath)
with open(myManifestPath) as reader:
data = reader.read()
myManifest:dict = json.loads(data)
healthInfo["integration_version"] = myManifest.get('version','unknown')

# add client configuration data.
clientConfig:str = ""
if len(hass.data[DOMAIN]) > 0:
clientConfig = str("%d: " % len(hass.data[DOMAIN]))
data:InstanceDataSpotifyPlus = None
for data in hass.data[DOMAIN].values():
_logsi.LogDictionary(SILevel.Verbose, "InstanceDataSpotifyPlus data", data, prettyPrint=True)
if data.spotifyClient != None:
if data.spotifyClient.UserProfile != None:
clientConfig = clientConfig + "%s (%s), " % (data.spotifyClient.UserProfile.DisplayName, data.spotifyClient.UserProfile.Product)
clientConfig = clientConfig[:len(clientConfig)-2] # drop ending ", "
else:
clientConfig = "(None Defined)"
healthInfo["clients_configured"] = clientConfig

# check if Spotify Web API endpoint is reachable.
healthInfo["api_endpoint_reachable"] = system_health.async_check_can_reach_url(hass, "https://api.spotify.com")

# trace.
_logsi.LogDictionary(SILevel.Verbose, "System Health results", healthInfo)

# return system health data.
return healthInfo

except Exception as ex:

# trace.
_logsi.LogException("system_health_info exception: %s" % str(ex), ex, logToSystemLogger=False)
raise

finally:

# trace.
_logsi.LeaveMethod(SILevel.Debug)
4 changes: 3 additions & 1 deletion custom_components/spotifyplus/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
},
"system_health": {
"info": {
"api_endpoint_reachable": "Spotify API endpoint reachable"
"integration_version": "Version",
"api_endpoint_reachable": "Spotify API endpoint reachable",
"clients_configured": "Clients Configured"
}
},
"issues": {
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
pip>=21.0,<23.4
colorlog==6.7.0
homeassistant==2023.10.5
homeassistant==2024.5.0
ruff==0.1.3
smartinspectPython>=3.0.33
spotifywebapiPython==1.0.43
4 changes: 2 additions & 2 deletions spotifyplus.pyproj
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@
<ItemGroup>
<Interpreter Include="env\">
<Id>env</Id>
<Version>3.11</Version>
<Description>env (Python 3.11 (64-bit))</Description>
<Version>0.0</Version>
<Description>env (Python 3.12 (64-bit))</Description>
<InterpreterPath>Scripts\python.exe</InterpreterPath>
<WindowsInterpreterPath>Scripts\pythonw.exe</WindowsInterpreterPath>
<PathEnvironmentVariable>PYTHONPATH</PathEnvironmentVariable>
Expand Down

0 comments on commit 8facfcb

Please sign in to comment.