diff --git a/.gitignore b/.gitignore index dd4f36c..2f81aaf 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ config/* # Local project files to ignore. /custom_components/soundtouchplus/developer_notes.txt /.github/workflows/lint.yml +/New_Release_Instructions.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index d12e8e0..3a65538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,15 @@ ## Change Log -All notable changes to this project are listed here. +All notable changes to this project are listed here. -Change are listed in reverse chronological order (newest to oldest). +Change are listed in reverse chronological order (newest to oldest). +###### [ 1.0.5 ] - 2023/11/01 + + * Updated code to handle device websocket connection errors (e.g. power loss, socket connection errors, etc). This was causing devices to not respond once the websocket connection was re-established. + ###### [ 1.0.4 ] - 2023/10/31 * Updated code to handle devices that do not support websocket notifications. In this case, HA will poll the device every 10 seconds for status updates. diff --git a/custom_components/soundtouchplus/manifest.json b/custom_components/soundtouchplus/manifest.json index e578be1..c30e245 100644 --- a/custom_components/soundtouchplus/manifest.json +++ b/custom_components/soundtouchplus/manifest.json @@ -12,8 +12,8 @@ "websocket-client>=1.6.4", "urllib3", "smartinspectPython>=3.0.27", - "bosesoundtouchapi>=1.0.3" + "bosesoundtouchapi>=1.0.4" ], - "version": "1.0.4", + "version": "1.0.5", "zeroconf": [ "_soundtouch._tcp.local." ] } diff --git a/custom_components/soundtouchplus/media_player.py b/custom_components/soundtouchplus/media_player.py index 659533e..83d8cb3 100644 --- a/custom_components/soundtouchplus/media_player.py +++ b/custom_components/soundtouchplus/media_player.py @@ -78,10 +78,6 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): """ Representation of a Bose SoundTouch device. """ - # we will (by default) set polling to false, as the SoundTouch device should be - # sending us updates as they happen if it supports websockets. if not, then we - # will reset this flag in the __init__ method. - should_poll = False def __init__(self, initParms:EntityInitParms) -> None: """ @@ -109,15 +105,37 @@ def __init__(self, initParms:EntityInitParms) -> None: # entity screens, and used to build the Entity ID that's used in automations etc. self._attr_name = self._client.Device.DeviceName + # we will (by default) set polling to false, as the SoundTouch device should be + # sending us updates as they happen if it supports websocket notificationss. + # if not, then we will reset this flag in the __init__ method. + self._attr_should_poll = False + # if websockets are not supported, then we need to enable device polling. if self._socket is None: - _logsi.LogVerbose("'%s': should_poll is being enabled, as the device does not support websockets" % (self.name)) - self.should_poll = True + _logsi.LogVerbose("'%s': _attr_should_poll is being enabled, as the device does not support websockets" % (self.name)) + self._attr_should_poll = True _logsi.LogObject(SILevel.Verbose, "'%s': initialized" % (self.name), self._client) return + # @property + # def should_poll(self) -> bool: + # """Return True if entity has to be polled for state. + + # False if entity pushes its state to HA. + # """ + # return self._attr_should_poll + + # @should_poll.setter + # def should_poll(self, value:bool): + # """ + # Sets the _attr_should_poll property value. + # """ + # if isinstance(value, bool): + # self._attr_should_poll = value + + async def async_added_to_hass(self) -> None: """ Run when this Entity has been added to HA. @@ -150,7 +168,8 @@ async def async_added_to_hass(self) -> None: self._socket.AddListener(SoundTouchNotifyCategorys.SoundTouchSdkInfo, self._OnSoundTouchInfoEvent) # add our listener that will handle SoundTouch websocket related events. - self._socket.AddListener(SoundTouchNotifyCategorys.WebSocketClose, self._OnSoundTouchWebSocketCloseEvent) + self._socket.AddListener(SoundTouchNotifyCategorys.WebSocketClose, self._OnSoundTouchWebSocketConnectionEvent) + self._socket.AddListener(SoundTouchNotifyCategorys.WebSocketOpen, self._OnSoundTouchWebSocketConnectionEvent) self._socket.AddListener(SoundTouchNotifyCategorys.WebSocketError, self._OnSoundTouchWebSocketErrorEvent) # start receiving device event notifications. @@ -384,15 +403,6 @@ def mute_volume(self, mute:bool) -> None: """ Send mute command. """ self._client.Mute() - # if device notification events thread is stopped, then restart it. - # this can happen if the SoundTouch device loses power and drops the connection. - # we could probably check for this somewhere else to have it restart automatically, - # but let's start with here. - if self._socket is not None: - if self._socket.IsThreadRunForeverActive == False: - self._socket.StopNotification() - self._socket.StartNotification() - def set_repeat(self, repeat:RepeatMode) -> None: """ Set repeat mode. """ @@ -429,11 +439,36 @@ def turn_on(self) -> None: def update(self) -> None: """ Retrieve the latest data. """ - _logsi.LogVerbose("'%s': update method (should_poll=%s)" % (self.name, self.should_poll)) - self._nowPlayingStatus = self._client.GetNowPlayingStatus(self.should_poll) - self._volume = self._client.GetVolume(self.should_poll) - self._zone = self._client.GetZoneStatus(self.should_poll) - + _logsi.LogVerbose("'%s': update method (_attr_should_poll=%s)" % (self.name, self._attr_should_poll)) + + # get updated device status. + _logsi.LogVerbose("'%s': update method - getting nowPlaying status" % (self.name)) + self._nowPlayingStatus = self._client.GetNowPlayingStatus(self._attr_should_poll) + _logsi.LogVerbose("'%s': update method - getting volume status" % (self.name)) + self._volume = self._client.GetVolume(self._attr_should_poll) + _logsi.LogVerbose("'%s': update method - getting zone status" % (self.name)) + self._zone = self._client.GetZoneStatus(self._attr_should_poll) + + # does this device support websocket notifications? + # note - if socket is None, it denotes that websocket notifications are + # NOT supported for the device, and we should not try to restart. + if self._socket is not None: + + # is polling enabled? if so it should NOT be since websockets are supported. + # this denotes that a websocket error previously occured which broke the connection. + # this can happen if the SoundTouch device loses power and drops the connection. + if self._attr_should_poll == True: + + _logsi.LogVerbose("'%s': update method - checking _socket.IsThreadRunForeverActive status" % (self.name)) + + # if device notification events thread is stopped, then restart it if possible. + if self._socket.IsThreadRunForeverActive == False: + _logsi.LogVerbose("'%s': update is re-starting websocket notifications" % (self.name)) + self._socket.StopNotification() + self._socket.StartNotification() + _logsi.LogVerbose("'%s': update is setting _attr_should_poll=False since event notifications are active again" % (self.name)) + self._attr_should_poll = False + def volume_down(self) -> None: """ Volume down media player. """ @@ -606,16 +641,28 @@ async def async_browse_media( # ----------------------------------------------------------------------------------- @callback - def _OnSoundTouchWebSocketCloseEvent(self, client:SoundTouchClient, args) -> None: + def _OnSoundTouchWebSocketConnectionEvent(self, client:SoundTouchClient, args:str) -> None: if (args != None): - _logsi.LogError("SoundTouch device websocket close event: %s" % (str(args)), colorValue=SIColors.LightGreen) + _logsi.LogError("SoundTouch device websocket connection event: %s" % (str(args)), colorValue=SIColors.Coral) @callback def _OnSoundTouchWebSocketErrorEvent(self, client:SoundTouchClient, ex:Exception) -> None: if (ex != None): - _logsi.LogError("SoundTouch device websocket error event: %s" % (str(ex)), colorValue=SIColors.LightGreen) - + _logsi.LogError("SoundTouch device websocket error event: (%s) %s" % (str(type(ex)), str(ex)), colorValue=SIColors.Coral) + _logsi.LogVerbose("'%s': Setting _attr_should_poll=True due to websocket error event" % (client.Device.DeviceName), colorValue=SIColors.Coral) + self._attr_should_poll = True + + # at this point we will assume that the device lost power since it lost the websocket connection. + # reset nowPlayingStatus, which will drive a MediaPlayerState.OFF state. + _logsi.LogVerbose("'%s': Setting _nowPlayingStatus to None to simulate a MediaPlayerState.OFF state" % (client.Device.DeviceName), colorValue=SIColors.Coral) + self._nowPlayingStatus = None + + # inform Home Assistant of the status update. + # this will turn the player off in the Home Assistant UI. + _logsi.LogVerbose("'%s': Calling async_write_ha_state to update player status" % (client.Device.DeviceName), colorValue=SIColors.Coral) + self.async_write_ha_state() + @callback def _OnSoundTouchInfoEvent(self, client:SoundTouchClient, args:Element) -> None: @@ -702,6 +749,7 @@ def _OnSoundTouchUpdateEvent_zoneUpdated(self, client:SoundTouchClient, args:Ele # inform Home Assistant of the status update. self.async_write_ha_state() + # ----------------------------------------------------------------------------------- # Helpfer functions # ----------------------------------------------------------------------------------- diff --git a/requirements.txt b/requirements.txt index 274c910..105ef21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ homeassistant==2023.8.0 pip>=21.0,<23.4 ruff==0.1.3 smartinspectPython>=3.0.27 -# bosesoundtouchapi>=1.0.3 +# bosesoundtouchapi>=1.0.4 diff --git a/soundtouchplus.pyproj b/soundtouchplus.pyproj index b17ee1a..d6c8dde 100644 --- a/soundtouchplus.pyproj +++ b/soundtouchplus.pyproj @@ -68,6 +68,7 @@ +