Skip to content

Commit

Permalink
sonos: update to SoCo version 30.2; fix for play_tts audio length;
Browse files Browse the repository at this point in the history
  • Loading branch information
aschwith committed Jan 16, 2024
1 parent ab06c9c commit 584ea6f
Show file tree
Hide file tree
Showing 14 changed files with 262 additions and 63 deletions.
51 changes: 27 additions & 24 deletions sonos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ def renew_error_callback(exception): # events_twisted: failure
# Redundant, as the exception will be logged by the events module
self.logger.error(msg)

# ToDo possible improvement: Do not do periodic renew but do prober disposal on renew failure here instead. sub.renew(requested_timeout=10)
# ToDo possible improvement: Do not do periodic renew but do propper disposal on renew failure here instead. sub.renew(requested_timeout=10)


class SubscriptionHandler(object):
Expand Down Expand Up @@ -266,31 +266,34 @@ def unsubscribe(self):
# try to unsubscribe first
try:
self._event.unsubscribe()
self.logger.info(f"Event {self._endpoint} unsubscribed")
except Exception as e:
self.logger.warning(f"Exception in unsubscribe(): {e}")
self._signal.set()
if self._thread:
self.logger.dbglow("Preparing to terminate thread")
if debug:
self.logger.dbghigh(f"unsubscribe(): Preparing to terminate thread for endpoint {self._endpoint}")
self._thread.join(timeout=4)
if debug:
self.logger.dbghigh(f"unsubscribe(): Thread joined for endpoint {self._endpoint}")
else:
if debug:
self.logger.warning(f"unsubscribe(): Endpoint: {self._endpoint}, Thread: {self._threadName}, self._event not valid")

self._signal.set()
if self._thread:
self.logger.dbglow("Preparing to terminate thread")
if debug:
self.logger.dbghigh(f"unsubscribe(): Preparing to terminate thread for endpoint {self._endpoint}")
self._thread.join(timeout=4)
if debug:
self.logger.dbghigh(f"unsubscribe(): Thread joined for endpoint {self._endpoint}")

if not self._thread.is_alive():
self.logger.dbglow("Thread killed for enpoint {self._endpoint}")
if debug:
self.logger.dbghigh(f"Thread killed for endpoint {self._endpoint}")
if not self._thread.is_alive():
self.logger.dbglow("Thread killed for enpoint {self._endpoint}")
if debug:
self.logger.dbghigh(f"Thread killed for endpoint {self._endpoint}")
else:
self.logger.warning("unsubscibe(): Error, thread is still alive after termination (join timed-out)")
self._thread = None
self.logger.info(f"Event {self._endpoint} thread terminated")

else:
self.logger.warning("unsubscibe(): Error, thread is still alive after termination (join timed-out)")
self._thread = None
self.logger.info(f"Event {self._endpoint} unsubscribed and thread terminated")
if debug:
self.logger.dbghigh(f"unsubscribe(): Event {self._endpoint} unsubscribed and thread terminated")
else:
if debug:
self.logger.warning(f"unsubscribe(): {self._endpoint}: self._event not valid")

if debug:
self.logger.dbghigh(f"unsubscribe(): {self._endpoint}: lock released")

Expand Down Expand Up @@ -704,7 +707,7 @@ def _av_transport_event(self, sub_handler: SubscriptionHandler) -> None:

self.logger.dbghigh(f"_av_transport_event: {self.uid}: av transport event handler active.")
while not sub_handler.signal.wait(1):
self.logger.dbgmed(f"_av_transport_event: {self.uid}: start try")
# self.logger.dbglow(f"_av_transport_event: {self.uid}: start try")

try:
event = sub_handler.event.events.get(timeout=0.5)
Expand Down Expand Up @@ -857,7 +860,7 @@ def _av_transport_event(self, sub_handler: SubscriptionHandler) -> None:

def _check_property(self):
if not self.is_initialized:
self.logger.warning(f"Speaker '{self.uid}' is not initialized.")
self.logger.warning(f"Checkproperty: Speaker '{self.uid}' is not initialized.")
return False
if not self.coordinator:
self.logger.warning(f"Speaker '{self.uid}': coordinator is empty")
Expand Down Expand Up @@ -2674,7 +2677,7 @@ def _play_snippet(self, file_path: str, webservice_url: str, volume: int = -1, d
if not tag.duration:
self.logger.error("TinyTag duration is none.")
else:
duration = round(tag.duration) + duration_offset
duration = tag.duration + duration_offset
self.logger.debug(f"TTS track duration: {duration}s, TTS track duration offset: {duration_offset}s")
file_name = quote(os.path.split(file_path)[1])
snippet_url = f"{webservice_url}/{file_name}"
Expand Down Expand Up @@ -2994,7 +2997,7 @@ class Sonos(SmartPlugin):
"""
Main class of the Plugin. Does all plugin specific stuff
"""
PLUGIN_VERSION = "1.8.4"
PLUGIN_VERSION = "1.8.5"

def __init__(self, sh):
"""Initializes the plugin."""
Expand Down
2 changes: 1 addition & 1 deletion sonos/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ plugin:
documentation: https://github.com/smarthomeNG/plugins/blob/master/sonos/README.md
support: https://knx-user-forum.de/forum/supportforen/smarthome-py/25151-sonos-anbindung

version: 1.8.4 # Plugin version
version: 1.8.5 # Plugin version
sh_minversion: 1.5.1 # minimum shNG version to use this plugin
py_minversion: 3.8 # minimum Python version to use for this plugin
multi_instance: False # plugin supports multi instance
Expand Down
2 changes: 1 addition & 1 deletion sonos/soco/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
__author__ = "The SoCo-Team <[email protected]>"
# Please increment the version number and add the suffix "-dev" after
# a release, to make it possible to identify in-development code
__version__ = "0.29.1"
__version__ = "0.30.2"
__website__ = "https://github.com/SoCo/SoCo"
__license__ = "MIT License"

Expand Down
106 changes: 105 additions & 1 deletion sonos/soco/alarms.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""This module contains classes relating to Sonos Alarms."""
import logging
import re
from datetime import datetime
from datetime import datetime, timedelta

from . import discovery
from .core import _SocoSingletonBase, PLAY_MODES
Expand All @@ -10,6 +10,12 @@

log = logging.getLogger(__name__)
TIME_FORMAT = "%H:%M:%S"
RECURRENCE_KEYWORD_EQUIVALENT = {
"DAILY": "ON_0123456",
"ONCE": "ON_", # Never reoccurs
"WEEKDAYS": "ON_12345",
"WEEKENDS": "ON_06",
}


def is_valid_recurrence(text):
Expand Down Expand Up @@ -179,6 +185,44 @@ def update(self, zone=None):
if not new_alarms.get(alarm_id):
self.alarms.pop(alarm_id)

def get_next_alarm_datetime(
self, from_datetime=None, include_disabled=False, zone_uid=None
):
"""Get the next alarm trigger datetime.
Args:
from_datetime (datetime, optional): a datetime to reference next
alarms from. This argument filters by alarms on or after this
exact time. Since alarms do not store timezone information,
the output timezone will match this input argument. Defaults
to `datetime.now()`.
include_disabled (bool, optional): If `True` then disabled alarms
will be included in searching for the next alarm. Defaults to
`False`.
zone_uid (str, optional): If set the alarms will be filtered by
zone with this UID. Defaults to `None`.
Returns:
datetime: The next alarm trigger datetime or None if disabled
"""
if from_datetime is None:
from_datetime = datetime.now()

next_alarm_datetime = None
for alarm_id in self.alarms:
this_alarm = self.alarms.get(alarm_id)
if zone_uid is not None and this_alarm.zone.uid != zone_uid:
continue
this_next_datetime = this_alarm.get_next_alarm_datetime(
from_datetime, include_disabled
)
if (next_alarm_datetime is None) or (
this_next_datetime is not None
and this_next_datetime < next_alarm_datetime
):
next_alarm_datetime = this_next_datetime
return next_alarm_datetime


class Alarm:

Expand Down Expand Up @@ -372,6 +416,66 @@ def alarm_id(self):
"""`str`: The ID of the alarm, or `None`."""
return self._alarm_id

def get_next_alarm_datetime(self, from_datetime=None, include_disabled=False):
"""Get the next alarm trigger datetime.
Args:
from_datetime (datetime, optional): a datetime to reference next
alarms from. This argument filters by alarms on or after this
exact time. Since alarms do not store timezone information,
the output timezone will match this input argument. Defaults
to `datetime.now()`.
include_disabled (bool, optional): If `True` then the next datetime
will be computed even if the alarm is disabled. Defaults to
`False`.
Returns:
datetime: The next alarm trigger datetime or None if disabled
"""
if not self.enabled and not include_disabled:
return None

if from_datetime is None:
from_datetime = datetime.now()

# Convert helper words to number recurrences
recurrence_on_str = RECURRENCE_KEYWORD_EQUIVALENT.get(
self.recurrence, self.recurrence
)

# For the purpose of finding the next alarm a "once" trigger that has
# yet to trigger is everyday (the next possible day)
if recurrence_on_str == RECURRENCE_KEYWORD_EQUIVALENT["ONCE"]:
recurrence_on_str = RECURRENCE_KEYWORD_EQUIVALENT["DAILY"]

# Trim the 'ON_' prefix, convert to int, remove duplicates
recurrence_set = set(map(int, recurrence_on_str[3:]))

# Convert Sonos weekdays to Python weekdays
# Sonos starts on Sunday, Python starts on Monday
if 0 in recurrence_set:
recurrence_set.remove(0)
recurrence_set.add(7)
recurrence_set = {x - 1 for x in recurrence_set}

# Begin search from next day if it would have already triggered today
offset = 0
if self.start_time <= from_datetime.time():
offset += 1

# Find first day
from_datetime_day = from_datetime.weekday()
offset_weekday = (from_datetime_day + offset) % 7
while offset_weekday not in recurrence_set:
offset += 1
offset_weekday = (from_datetime_day + offset) % 7

return datetime.combine(
from_datetime.date() + timedelta(days=offset),
self.start_time,
tzinfo=from_datetime.tzinfo,
)


def get_alarms(zone=None):
"""Get a set of all alarms known to the Sonos system.
Expand Down
48 changes: 48 additions & 0 deletions sonos/soco/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ class SoCo(_SocoSingletonBase):
is_soundbar
is_satellite
has_satellites
sub_crossover
sub_enabled
sub_gain
is_subwoofer
Expand Down Expand Up @@ -510,7 +511,14 @@ def has_subwoofer(self):
Only provides reliable results when called on the soundbar
or subwoofer devices if configured in a home theater setup.
Sonos Amp devices support a directly-connected 3rd party subwoofer
connected over RCA. This property is always enabled for those devices.
"""
model_name = self.speaker_info["model_name"].lower()
if model_name.endswith("sonos amp"):
return True

self.zone_group_state.poll(self)
channel_map = self._channel_map or self._ht_sat_chan_map
if not channel_map:
Expand Down Expand Up @@ -1074,6 +1082,46 @@ def surround_enabled(self, enable):
]
)

@property
def sub_crossover(self):
"""int: Reports the current subwoofer crossover frequency in Hz.
Only supported on Amp devices.
"""
model_name = self.speaker_info["model_name"].lower()
if not model_name.endswith("sonos amp"):
return None

response = self.renderingControl.GetEQ(
[("InstanceID", 0), ("EQType", "SubCrossover")]
)
return int(response["CurrentValue"])

@sub_crossover.setter
def sub_crossover(self, frequency):
"""Set the subwoofer crossover frequency. Only supported on Amp devices.
:param frequency: Desired subwoofer crossover frequency in Hz
:type frequency: int
"""
model_name = self.speaker_info["model_name"].lower()
if not model_name.endswith("sonos amp"):
message = "Subwoofer crossover not supported on this device."
raise NotSupportedException(message)

if not 50 <= frequency <= 110:
raise ValueError(
"Invalid value, must be integer between 50 and 110 inclusive"
)

self.renderingControl.SetEQ(
[
("InstanceID", 0),
("EQType", "SubCrossover"),
("DesiredValue", int(frequency)),
]
)

@property
def sub_enabled(self):
"""bool: Reports if the subwoofer is enabled.
Expand Down
4 changes: 2 additions & 2 deletions sonos/soco/data_structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ def __init__(
restricted=True,
resources=None,
desc="RINCON_AssociatedZPUDN",
**kwargs
**kwargs,
):
"""
Args:
Expand Down Expand Up @@ -629,7 +629,7 @@ def from_element(cls, element): # pylint: disable=R0914
restricted=restricted,
resources=resources,
desc=desc,
**content
**content,
)

@classmethod
Expand Down
2 changes: 1 addition & 1 deletion sonos/soco/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def discover(
interface_addr=None,
household_id="Sonos",
allow_network_scan=False,
**network_scan_kwargs
**network_scan_kwargs,
):
"""Discover Sonos zones on the local network.
Expand Down
2 changes: 2 additions & 0 deletions sonos/soco/events_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ def success(headers):
self.timeout = int(timeout.lstrip("Second-"))
self._timestamp = time.time()
self.is_subscribed = True
service.soco.zone_group_state.add_subscription(self)
log.debug(
"Subscribed to %s, sid: %s",
service.base_url + service.event_subscription_url,
Expand Down Expand Up @@ -653,6 +654,7 @@ def _cancel_subscription(self, msg=None):
# an attempt to unsubscribe fails
self._has_been_unsubscribed = True
self._timestamp = None
self.service.soco.zone_group_state.remove_subscription(self)
# Cancel any auto renew
self._auto_renew_cancel()
if msg:
Expand Down
2 changes: 1 addition & 1 deletion sonos/soco/events_twisted.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ def listen(self, ip_address):
port_number, factory, interface=ip_address
)
break
# pylint: disable=invalid-name
# pylint: disable=invalid-name,used-before-assignment
except twisted.internet.error.CannotListenError as e:
log.warning(e)
continue
Expand Down
2 changes: 1 addition & 1 deletion sonos/soco/plugins/plex.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ def add_to_queue(self, plex_media, position=0, as_next=False, **kwargs):
("DesiredFirstTrackNumberEnqueued", position),
("EnqueueAsNext", int(as_next)),
],
**kwargs
**kwargs,
)
qnumber = response["FirstTrackNumberEnqueued"]
return int(qnumber)
2 changes: 1 addition & 1 deletion sonos/soco/plugins/sharelink.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ def add_share_link_to_queue(self, uri, position=0, as_next=False, **kwargs):
("DesiredFirstTrackNumberEnqueued", position),
("EnqueueAsNext", int(as_next)),
],
**kwargs
**kwargs,
)

qnumber = response["FirstTrackNumberEnqueued"]
Expand Down
Loading

0 comments on commit 584ea6f

Please sign in to comment.