From 73bb9df85faed1d38f4dfa5a3e0004a46437a912 Mon Sep 17 00:00:00 2001 From: Reinerlein <> Date: Sun, 14 May 2017 11:18:12 +0000 Subject: [PATCH] Sonos: New Features and bugfixes. See Changelog. git-svn-id: https://svn.fhem.de/fhem/trunk@14279 2b470e98-0d58-463d-a4d8-8e2adae1ed80 --- fhem/FHEM/00_SONOS.pm | 969 ++++++++++++++---- fhem/FHEM/21_SONOSPLAYER.pm | 169 ++- fhem/FHEM/lib/UPnP/ControlPoint.pm | 24 +- .../lib/UPnP/sonos_bibliothek_quadratic.jpg | Bin 0 -> 2808 bytes fhem/FHEM/lib/UPnP/sonos_bibliothek_round.png | Bin 0 -> 2817 bytes fhem/FHEM/lib/UPnP/sonos_dock_quadratic.jpg | Bin 0 -> 5416 bytes fhem/FHEM/lib/UPnP/sonos_dock_round.png | Bin 0 -> 5738 bytes fhem/FHEM/lib/UPnP/sonos_leer.gif | Bin 0 -> 814 bytes fhem/FHEM/lib/UPnP/sonos_linein_quadratic.jpg | Bin 0 -> 2948 bytes fhem/FHEM/lib/UPnP/sonos_linein_round.png | Bin 0 -> 1988 bytes .../FHEM/lib/UPnP/sonos_playbar_quadratic.jpg | Bin 0 -> 5306 bytes fhem/FHEM/lib/UPnP/sonos_playbar_round.png | Bin 0 -> 9633 bytes fhem/FHEM/lib/UPnP/sonos_tunein_quadratic.jpg | Bin 0 -> 2779 bytes fhem/FHEM/lib/UPnP/sonos_tunein_round.png | Bin 0 -> 1979 bytes 14 files changed, 956 insertions(+), 206 deletions(-) create mode 100644 fhem/FHEM/lib/UPnP/sonos_bibliothek_quadratic.jpg create mode 100644 fhem/FHEM/lib/UPnP/sonos_bibliothek_round.png create mode 100644 fhem/FHEM/lib/UPnP/sonos_dock_quadratic.jpg create mode 100644 fhem/FHEM/lib/UPnP/sonos_dock_round.png create mode 100644 fhem/FHEM/lib/UPnP/sonos_leer.gif create mode 100644 fhem/FHEM/lib/UPnP/sonos_linein_quadratic.jpg create mode 100644 fhem/FHEM/lib/UPnP/sonos_linein_round.png create mode 100644 fhem/FHEM/lib/UPnP/sonos_playbar_quadratic.jpg create mode 100644 fhem/FHEM/lib/UPnP/sonos_playbar_round.png create mode 100644 fhem/FHEM/lib/UPnP/sonos_tunein_quadratic.jpg create mode 100644 fhem/FHEM/lib/UPnP/sonos_tunein_round.png diff --git a/fhem/FHEM/00_SONOS.pm b/fhem/FHEM/00_SONOS.pm index fecec9bf5..bcfaaf2ca 100755 --- a/fhem/FHEM/00_SONOS.pm +++ b/fhem/FHEM/00_SONOS.pm @@ -1,6 +1,6 @@ ######################################################################################## # -# SONOS.pm (c) by Reiner Leins, April 2017 +# SONOS.pm (c) by Reiner Leins, Mai 2017 # rleins at lmsoft dot de # # $Id$ @@ -51,6 +51,29 @@ # Changelog (last 4 entries only, see Wiki for complete changelog) # # SVN-History: +# 13.05.2017 +# FHEMWEB-Anzeige der Player auf die neu möglichen, nicht quadratischen, Radiocover angepasst. +# In der Datei ControlPoint.pm wurde beim Öffnen des SSDP-Ports das Attribut ReusePort hinzugefügt (sofern das Betriebssystem das unterstützt). +# Es gibt ein neues Reading "currentEnqueuedTransportHandle", welches zur Weitergabe der aktuellen Quelle des aktuellen Titels verwendet werden kann. +# Es gibt zwei neue Readings "currentTrackHandle" und "nextTrackHandle", welche zur Weitergabe der aktuellen bzw. nächsten Wiedergabe geeignet sind. +# Es gibt ein neues Reading "currentSource", welches (wenn beliefert) den Namen der Quelle des Titels angibt. Bei Spotify z.B. die gewählte Playliste oder das Album, oder die gewählte Sonos-Playliste. +# Die Trackprovider werden jetzt über die verfügbaren, von Sonos bereitgestellten, MusicServices ermittelt. +# (Fehlende) Radiocover werden nun über den offiziellen Webservice von Sonos ermittelt, und werden wieder geladen. +# Etwaige, immer noch, fehlende Radiocover werden als Fallback wie bisher geladen. Das sollte aber nicht mehr vorkommen. +# Spotify-Playlisten-Cover werden wieder geladen. +# Teilweise wurden Bibliothekscover nicht geladen, das sollte wieder gehen. +# Die Verbindungsprüfung zwischen Fhem-Modul und SubProzess erfolgt nur noch, wenn nicht sowieso schon Daten übertragen werden, und sollte dementsprechend nicht mehr dazwischenfunken. +# Die Verbindungsprüfung und der grundsätzliche Verbindungsaufbau zum SubProzess wurden umgestellt. +# Fehlermeldungen bei Fhem-Player-Such-Prozeduren verbessert. +# Es gibt zwei (bzw. vier) neue Readings "TrackProviderIconRoundURL" und "TrackProviderIconQuadraticURL" (jeweils für "current" und für "next"). Mit diesen lassen sich die entsprechenden Provider-Icons anzeigen. +# Die Titelanzeige in FhemWeb enthält nun auch die neuen Provider-Icons. +# Detaildarstellung der SonosPlayer-Devices enthält jetzt auch die Coverdarstellung. +# Bug in "SONOS_GetTimeFromString" behoben. +# Es gibt zwei neue Attribute "simulateCurrentTrackPosition" und "simulateCurrentTrackPositionPercentFormat". Mit diesen kann man eine Simulation des Fortschritts von currentTrackPosition im gewählten Intervall aktivieren. Dabei werden die Readings "currentTrackPositionSimulated", "currentTrackPositionSimulatedSec" und "currentTrackPositionSimulatedPercent" (nur bei gelieferter "currentTrackDuration") aktualisiert. +# Es gibt zwei neue Readings "currentTrackDurationSec" und "nextTrackDurationSec", welche die jeweilige Tracklänge in Sekunden angeben. +# Durch ein mittlerweile verändertes Notify-Event-Handling in Fhem wurden die Bookmarks nicht immer zusammen mit dem globalen Save-Befehl gespeichert. +# Die Cover-/Titelanzeige wird nun intern vom Modul durchgeführt. Deshalb ist keine zusätzliche ReadingsGroup für die Anzeige und Aktualisierung mehr notwendig. In der Raumansicht kann man das Verhalten für alle Sonosplayer einheitlich mit dem Attribut "deviceRoomView" beeinflussen. Es kann die Zustände "Both" und "DeviceLineOnly" annehmen. +# Die Steuermöglichkeiten werden nun intern vom Modul dargestellt. Dazu muss die Cover-/Titelanzeige aktiviert sein. Will man die Steuerung ausblenden, kann man das Attribut "suppressControlButtons" für Sonosplayer einzeln setzen setzen. # 09.04.2017 # Beim Maskieren der Anzeigelisten für Playlisten, Radios oder Favoriten werden Klammern und andere Sonderzeichen für reguläre Ausdrücke nun auch in Punkte umgewandelt, da diese sonst den regulären Such-Ausdruck stören. # Beim Starten/Laden von Playlisten, Radios oder Favoriten werden, vor der eigentlichen Suche im Sonossystem, einfache Anführungszeichen (') in die HTML-Schreibweise (') übersetzt, da diese so auch von Sonos verwendet werden. @@ -75,8 +98,6 @@ # Es gibt ein neues Attribut "getListsDirectlyToReadings", mit welchem die UserReadings bzgl. Favourites, Playlists und Radios (sowie currentTrackPosition) obsolet werden, da sie dann direkt in die passenden Readings geschrieben werden. Wenn man selber beeinflussen möchte, auf welche Weise und mit welchem Namen diese Readings gefüllt werden, dann darf dieses Attribut nicht gesetzt werden (es bleibt dann das bisherige Verhalten) # Es gibt zwei neue Getter "Queue" und "QueueWithCovers", welche die aktuelle Abspielliste liefern. Diese können wieder mit UserReadings oder dem neuen Attribut "getListsDirectlyToReadings" in entsprechende Readings übertragen werden. Hierbei werden auch zwei neue Readings "QueueDuration" und "QueueDurationSec" gefüllt, die die gesamte Abspieldauer der Abspielliste enthalten. # Die Prozedur "SONOS_getGroupsRG()" wurde umgebaut, da FW_makeImage von FhemWeb scheinbar die Variable "$_" verändert. -# 13.03.2017 -# Saubere Fehlerbehandlung bei der Verarbeitung von currentFavouriteName, currentPlaylistName und currentRadioName. # ######################################################################################## # @@ -112,6 +133,7 @@ use Net::Ping; use Socket; use IO::Select; use IO::Socket::INET; +use HTTP::Request::Common; use File::Path; use File::stat; use Time::HiRes qw(usleep gettimeofday); @@ -122,6 +144,7 @@ use feature 'unicode_strings'; use Digest::MD5 qw(md5_hex); use File::Temp; use File::Copy; +# use Encode::Guess; use Data::Dumper; $Data::Dumper::Terse = 1; @@ -147,7 +170,7 @@ my %usedonlyIPs = (); ######################################################## # Standards aus FHEM einbinden ######################################################## -use vars qw{%attr %defs %intAt %data}; +use vars qw{%attr %modules %defs %intAt %data}; ######################################################## @@ -218,23 +241,9 @@ my %sets = ( 'EnableBookmark' => 'groupname' ); -my %SONOS_ProviderList = ('^http:(\/\/.*)' => 'Radio', - '^aac:(\/\/.*)' => 'Radio', - '^\/\/' => 'Bibliothek', - '^x-sonos-spotify:' => 'Spotify', - '^x-sonos-http:amz' => 'Amazon Music', - '^npsdy:' => 'Napster', - '^x-sonos-http:track%3a' => 'SoundCloud', - '^x-sonosapi-hls-static:' => 'Amazon Music'); - my @SONOS_PossibleDefinitions = qw(NAME INTERVAL); my @SONOS_PossibleAttributes = qw(targetSpeakFileHashCache targetSpeakFileTimestamp targetSpeakDir targetSpeakURL targetSpeakMP3FileDir targetSpeakMP3FileConverter SpeakGoogleURL Speak0 Speak1 Speak2 Speak3 Speak4 SpeakCover Speak1Cover Speak2Cover Speak3Cover Speak4Cover minVolume maxVolume minVolumeHeadphone maxVolumeHeadphone getAlarms disable generateVolumeEvent buttonEvents generateProxyAlbumArtURLs proxyCacheTime bookmarkSaveDir bookmarkTitleDefinition bookmarkPlaylistDefinition coverLoadTimeout getListsDirectlyToReadings getTitleInfoFromMaster stopSleeptimerInAction saveSleeptimerInAction); -my @SONOS_PossibleReadings = qw(AlarmList AlarmListIDs UserID_Spotify UserID_Napster location SleepTimerVersion Mute OutputFixed HeadphoneConnected Balance Volume Loudness Bass Treble TruePlay SurroundEnable SurroundLevel SubEnable SubGain SubPolarity AudioDelay AudioDelayLeftRear AudioDelayRightRear NightMode DialogLevel AlarmListVersion ZonePlayerUUIDsInGroup ZoneGroupState ZoneGroupID fieldType IsBonded ZoneGroupName roomName roomNameAlias roomIcon currentTransportState transportState TransportState LineInConnected presence currentAlbum currentArtist currentTitle GroupVolume GroupMute FavouritesVersion RadiosVersion PlaylistsVersion QueueVersion QueueHash GroupMasterPlayer ShareIndexInProgress DirectControlClientID DirectControlIsSuspended DirectControlAccountID IsMaster MasterPlayer SlavePlayer ButtonState ButtonLockState AllPlayer LineInName LineInIcon); - -# Obsolete Einstellungen... -my $SONOS_UseTelnetForQuestions = 1; -my $SONOS_UseTelnetForQuestions_Host = 'localhost'; # Wird automatisch durch den anfragenden Host ersetzt -my $SONOS_UseTelnetForQuestions_Port = 7072; +my @SONOS_PossibleReadings = qw(AlarmList AlarmListIDs UserID_Spotify UserID_Napster location SleepTimerVersion Mute OutputFixed HeadphoneConnected Balance Volume Loudness Bass Treble TruePlay SurroundEnable SurroundLevel SubEnable SubGain SubPolarity AudioDelay AudioDelayLeftRear AudioDelayRightRear NightMode DialogLevel AlarmListVersion ZonePlayerUUIDsInGroup ZoneGroupState ZoneGroupID fieldType IsBonded ZoneGroupName roomName roomNameAlias roomIcon currentTransportState transportState TransportState LineInConnected presence currentAlbum currentArtist currentTitle currentStreamAudio GroupVolume GroupMute FavouritesVersion RadiosVersion PlaylistsVersion QueueVersion QueueHash GroupMasterPlayer ShareIndexInProgress DirectControlClientID DirectControlIsSuspended DirectControlAccountID IsMaster MasterPlayer SlavePlayer ButtonState ButtonLockState AllPlayer LineInName LineInIcon MusicServicesListVersion MusicServicesList); # Communication between the two "levels" of threads my $SONOS_ComObjectTransportQueue = Thread::Queue->new(); @@ -247,6 +256,7 @@ my $SONOS_Thread :shared = -1; my $SONOS_Thread_IsAlive :shared = -1; my $SONOS_Thread_PlayerRestore :shared = -1; +my $SONOS_Thread_IsAlive_CheckerActive = 0; my %SONOS_Thread_IsAlive_Counter; my $SONOS_Thread_IsAlive_Counter_MaxMerci = 2; @@ -254,6 +264,8 @@ my $SONOS_Thread_IsAlive_Counter_MaxMerci = 2; my @SONOS_PINGTYPELIST = qw(none tcp udp icmp syn); my $SONOS_DEFAULTPINGTYPE = 'syn'; my $SONOS_SUBSCRIPTIONSRENEWAL = 1800; +my $SONOS_USERAGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, likeGecko) Chrome/23.0.1271.64 Safari/537.11'; +#my $SONOS_USERAGENT = 'Linux UPnP/1.0 Sonos/35.3-39010 (WDCR:Microsoft Windows NT 6.1.7601 Service Pack 1)'; my $SONOS_DIDLHeader = ''; my $SONOS_DIDLFooter = ''; my $SONOS_GOOGLETRANSLATOR_URL = 'http://translate.google.com/translate_tts?tl=%1$s&client=tw-ob&q=%2$s'; # 1->Sprache, 2->Text @@ -290,6 +302,7 @@ my %SONOS_AlarmSubscriptions; my %SONOS_ZoneGroupTopologySubscriptions; my %SONOS_DevicePropertiesSubscriptions; my %SONOS_AudioInSubscriptions; +my %SONOS_MusicServicesSubscriptions; # Bookmark-Daten my %SONOS_BookmarkQueueHash; @@ -351,6 +364,7 @@ sub SONOS_Initialize ($) { $hash->{GetFn} = 'SONOS_Get'; $hash->{SetFn} = 'SONOS_Set'; $hash->{AttrFn} = 'SONOS_Attribute'; + $hash->{NotifyFn} = 'SONOS_Notify'; # CGI my $name = "sonos"; @@ -362,7 +376,7 @@ sub SONOS_Initialize ($) { eval { no strict; no warnings; - $hash->{AttrList}= 'disable:1,0 pingType:'.join(',', @SONOS_PINGTYPELIST).' usedonlyIPs ignoredIPs targetSpeakDir targetSpeakURL targetSpeakFileTimestamp:1,0 targetSpeakFileHashCache:1,0 targetSpeakMP3FileDir targetSpeakMP3FileConverter SpeakGoogleURL Speak1 Speak2 Speak3 Speak4 SpeakCover Speak1Cover Speak2Cover Speak3Cover Speak4Cover generateProxyAlbumArtURLs:1,0 proxyCacheTime proxyCacheDir bookmarkSaveDir bookmarkTitleDefinition bookmarkPlaylistDefinition coverLoadTimeout:1,2,3,4,5,6,7,8,9,10,15,20,25,30 getListsDirectlyToReadings:1,0 '.$readingFnAttributes; + $hash->{AttrList}= 'disable:1,0 pingType:'.join(',', @SONOS_PINGTYPELIST).' usedonlyIPs ignoredIPs targetSpeakDir targetSpeakURL targetSpeakFileTimestamp:1,0 targetSpeakFileHashCache:1,0 targetSpeakMP3FileDir targetSpeakMP3FileConverter SpeakGoogleURL Speak1 Speak2 Speak3 Speak4 SpeakCover Speak1Cover Speak2Cover Speak3Cover Speak4Cover generateProxyAlbumArtURLs:1,0 proxyCacheTime proxyCacheDir bookmarkSaveDir bookmarkTitleDefinition bookmarkPlaylistDefinition coverLoadTimeout:1,2,3,4,5,6,7,8,9,10,15,20,25,30 getListsDirectlyToReadings:1,0 deviceRoomView:Both,DeviceLineOnly '.$readingFnAttributes; use strict; use warnings; }; @@ -473,7 +487,7 @@ sub SONOS_getCoverTitleRG($;$$) { $transportState = FW_makeImage('audio_pause', 'Paused', 'SONOS_Transportstate') if ($transportState eq 'PAUSED_PLAYBACK'); $transportState = FW_makeImage('audio_stop', 'Stopped', 'SONOS_Transportstate') if ($transportState eq 'STOPPED'); - my $fullscreenDiv = '
'.ReadingsVal($device, 'roomName', $device).$transportState.'
'.((lc($presence) eq 'disappeared') ? 'Player disappeared' : ReadingsVal($device, 'infoSummarize1', '')).'
'.(($normalAudio) ? '
' : '').'
'; + my $fullscreenDiv = '
'.ReadingsVal($device, 'roomName', $device).$transportState.'
'.((lc($presence) eq 'disappeared') ? 'Player disappeared' : ReadingsVal($device, 'infoSummarize1', '')).'
'.(($normalAudio) ? '
' : '').'
'; my $javascriptTimer = 'function refreshTime'.$device.'() { var playing = document.getElementById("prog_playing_'.$device.'"); @@ -531,7 +545,7 @@ sub SONOS_getCoverTitleRG($;$$) { '; $javascriptText =~ s/\n/ /g; - return $javascriptText.'
'.SONOS_getCoverRG($device).'
'.SONOS_getTitleRG($device, $space).'
'; + return $javascriptText.'
'.SONOS_getCoverRG($device).'
'.SONOS_getTitleRG($device, $space).'
'; } ######################################################################################## @@ -546,7 +560,7 @@ sub SONOS_getCoverRG($;$) { my $presence = ReadingsVal($device, 'presence', 'disappeared'); $presence = 'disappeared' if ($presence =~ m/~~NotLoadedMarker~~/i); - return ''; + return '
'; } ######################################################################################## @@ -556,14 +570,14 @@ sub SONOS_getCoverRG($;$) { ######################################################################################## sub SONOS_getTitleRG($;$) { my ($device, $space) = @_; - $space = '1em' if (!defined($space)); + $space = '2.35em' if (!defined($space)); $space .= 'px' if (looks_like_number($space)); # Wenn der Player weg ist, nur eine Kurzinfo dazu anzeigen my $presence = ReadingsVal($device, 'presence', 'disappeared'); $presence = 'disappeared' if ($presence =~ m/~~NotLoadedMarker~~/i); if (lc($presence) eq 'disappeared') { - return '
Player disappeared
'; + return '
Player disappeared
'; } my $infoString = ''; @@ -572,29 +586,29 @@ sub SONOS_getTitleRG($;$) { $transportState = 'Spiele' if ($transportState eq 'PLAYING'); $transportState = 'Pausiere' if ($transportState eq 'PAUSED_PLAYBACK'); $transportState = 'Stop bei' if ($transportState eq 'STOPPED'); - # 55 - + + my $source = ReadingsVal($device, 'currentSource', ''); + # Läuft Radio oder ein "normaler" Titel my $currentNormalAudio = ReadingsVal($device, 'currentNormalAudio', 1); - $currentNormalAudio = 1 if (SONOS_Trim($currentNormalAudio) eq ''); + $currentNormalAudio = 0 if (SONOS_Trim($currentNormalAudio) eq ''); if ($currentNormalAudio == 1) { my $showNext = ReadingsVal($device, 'nextTitle', '') || ReadingsVal($device, 'nextArtist', '') || ReadingsVal($device, 'nextAlbum', ''); - $infoString = sprintf('
%s Titel %s von %s (%s)
Titel: %s
Interpret: %s
Album: %s'.($showNext ? '
Nächste Wiedergabe (%s):
Titel: %s
Interpret: %s
Album: %s
' : ''), + $infoString = sprintf('
%s Titel %s von %s'.(($source) ? ' ~ '.$source.'' : '').'
Titel: %s
Interpret: %s
Album: %s'.($showNext ? '
Nächste Wiedergabe:
Titel: %s
Interpret: %s
Album: %s
' : '').'
', $transportState, ReadingsVal($device, 'currentTrack', ''), ReadingsVal($device, 'numberOfTracks', ''), - ReadingsVal($device, 'currentTrackProvider', ''), ReadingsVal($device, 'currentTitle', ''), ReadingsVal($device, 'currentArtist', ''), ReadingsVal($device, 'currentAlbum', ''), $space, - ReadingsVal($device, 'nextTrackProvider', ''), ReadingsVal($device, 'nextAlbumArtURL', ''), + ReadingsVal($device, 'nextTrackProviderIconRoundURL', ''), ReadingsVal($device, 'nextTitle', ''), ReadingsVal($device, 'nextArtist', ''), ReadingsVal($device, 'nextAlbum', '')); } else { - $infoString = sprintf('
%s Radiostream
Sender: %s
Info: %s
Läuft: %s
', + $infoString = sprintf('
%s Radiostream
Sender: %s
Info: %s
Läuft: %s
', $transportState, ReadingsVal($device, 'currentSender', ''), ReadingsVal($device, 'currentSenderInfo', ''), @@ -634,9 +648,9 @@ sub SONOS_getListRG($$;$) { $command = "FW_cmd('/fhem?XHR=1&$command')"; if ($ul) { - $resultString .= '
  • '; + $resultString .= '
  • '; } else { - $resultString .= ''.(($reading eq 'Queue') ? $elems{$key}->{ShowTitle} : $elems{$key}->{Title})."\n"; + $resultString .= '
    '.(($reading eq 'Queue') ? $elems{$key}->{ShowTitle} : $elems{$key}->{Title})."\n"; } } @@ -702,7 +716,13 @@ sub SONOS_FhemWebCallback($) { last; } } - return ("text/html; charset=UTF8", 'Call for Non-Sonos-Player: '.$URL) if (defined($ip) && $albumurl !~ /\.cloudfront.net\//i && $albumurl !~ /\.scdn.co\/image\//i && $albumurl !~ /cdn-radiotime-logos\.tunein\.com/i && $albumurl !~ /\/music\/image\?/i); + return ("text/html; charset=UTF8", 'Call for Non-Sonos-Player: '.$URL) + if (defined($ip) + && $albumurl !~ /\.cloudfront.net\//i + && $albumurl !~ /\.scdn.co\//i + && $albumurl !~ /sonos-logo\.ws\.sonos\.com\//i + && $albumurl !~ /\.tunein\.com/i + && $albumurl !~ /\/music\/image\?/i); # Generierter Dateiname für die Cache-Funktionalitaet my $albumHash; @@ -738,7 +758,7 @@ sub SONOS_FhemWebCallback($) { } # Bild vom Player holen... - my $ua = LWP::UserAgent->new(agent => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, likeGecko) Chrome/23.0.1271.64 Safari/537.11'); + my $ua = LWP::UserAgent->new(agent => $SONOS_USERAGENT); my $response = $ua->get($albumurl); if ($response->is_success) { SONOS_Log undef, 5, 'Cover wurde neu geladen: '.$albumurl; @@ -763,7 +783,7 @@ sub SONOS_FhemWebCallback($) { return (undef, undef); } else { - SONOS_Log undef, 1, 'Cover couldn\'t be loaded: '.$albumurl; + SONOS_Log undef, 1, 'Cover couldn\'t be loaded "'.$albumurl.'": '.$response->status_line,; FW_serveSpecial('sonos_empty', 'jpg', $attr{global}{modpath}.'/FHEM/lib/UPnP', 1); return (undef, undef); @@ -776,6 +796,57 @@ sub SONOS_FhemWebCallback($) { SONOS_Log undef, 5, 'Cover: '.$URL; + if ($URL =~ m/^\/leer.gif/i) { + FW_serveSpecial('sonos_leer', 'gif', $attr{global}{modpath}.'/FHEM/lib/UPnP', 1); + return (undef, undef); + } + + if ($URL =~ m/^\/tunein_quadratic.jpg/i) { + FW_serveSpecial('sonos_tunein_quadratic', 'jpg', $attr{global}{modpath}.'/FHEM/lib/UPnP', 1); + return (undef, undef); + } + if ($URL =~ m/^\/tunein_round.png/i) { + FW_serveSpecial('sonos_tunein_round', 'png', $attr{global}{modpath}.'/FHEM/lib/UPnP', 1); + return (undef, undef); + } + + if ($URL =~ m/^\/bibliothek_quadratic.jpg/i) { + FW_serveSpecial('sonos_bibliothek_quadratic', 'jpg', $attr{global}{modpath}.'/FHEM/lib/UPnP', 1); + return (undef, undef); + } + if ($URL =~ m/^\/bibliothek_round.png/i) { + FW_serveSpecial('sonos_bibliothek_round', 'png', $attr{global}{modpath}.'/FHEM/lib/UPnP', 1); + return (undef, undef); + } + + if ($URL =~ m/^\/linein_quadratic.jpg/i) { + FW_serveSpecial('sonos_linein_quadratic', 'jpg', $attr{global}{modpath}.'/FHEM/lib/UPnP', 1); + return (undef, undef); + } + if ($URL =~ m/^\/linein_round.png/i) { + FW_serveSpecial('sonos_linein_round', 'png', $attr{global}{modpath}.'/FHEM/lib/UPnP', 1); + return (undef, undef); + } + + if ($URL =~ m/^\/dock_quadratic.jpg/i) { + FW_serveSpecial('sonos_dock_quadratic', 'jpg', $attr{global}{modpath}.'/FHEM/lib/UPnP', 1); + return (undef, undef); + } + if ($URL =~ m/^\/dock_round.png/i) { + FW_serveSpecial('sonos_dock_round', 'png', $attr{global}{modpath}.'/FHEM/lib/UPnP', 1); + return (undef, undef); + } + + if ($URL =~ m/^\/playbar_quadratic.jpg/i) { + FW_serveSpecial('sonos_playbar_quadratic', 'jpg', $attr{global}{modpath}.'/FHEM/lib/UPnP', 1); + return (undef, undef); + } + if ($URL =~ m/^\/playbar_round.png/i) { + FW_serveSpecial('sonos_playbar_round', 'png', $attr{global}{modpath}.'/FHEM/lib/UPnP', 1); + return (undef, undef); + } + + if ($URL =~ m/^\/empty.jpg/i) { FW_serveSpecial('sonos_empty', 'jpg', $attr{global}{modpath}.'/FHEM/lib/UPnP', 1); return (undef, undef); @@ -802,21 +873,6 @@ sub SONOS_FhemWebCallback($) { } } - ## FolderCover-Features... - #if ($URL =~ m/^\/foldercover\//i) { - # $URL =~ s/^\/foldercover//i; - # - # SONOS_Log undef, 0, 'FolderCover: '.$URL; - # - # # Da wir die Standard-Prozedur 'FW_serveSpecial' aus 'FHEMWEB' verwenden moechten, brauchen wir eine lokale Datei - # my $tempFile = File::Temp->new(SUFFIX => '.image'); - # my $filename = $tempFile->filename; - # $filename =~ s/\\/\//g; - # SONOS_Log undef, 5, 'TempFilename: '.$filename; - # - # - #} - # Wenn wir hier ankommen, dann konnte nichts verarbeitet werden... return ("text/html; charset=UTF8", 'Call failure: '.$URL); } @@ -869,13 +925,12 @@ sub SONOS_Define($$) { $delaytime = 0; } + # Wir brauchen momentan nur die Notifies für global und jeden Sonosplayer... + $hash->{NOTIFYDEV} = 'global,TYPE=SONOSPLAYER'; + $hash->{NAME} = $name; $hash->{DeviceName} = $upnplistener; - # Wir brauchen momentan nur die Notifies für global, da wir auf "save" reagieren wollen... - $hash->{NOTIFYDEV} = 'global'; - $hash->{NotifyFn} = 'SONOS_Notify'; - $hash->{INTERVAL} = $interval; $hash->{WAITTIME} = $waittime; $hash->{DELAYTIME} = $delaytime; @@ -945,6 +1000,8 @@ sub SONOS_Attribute($$$@) { SONOS_Log(undef, 5, 'Neu-Enabled'); $disableChange = 1; } + } elsif ($attrName eq 'deviceRoomView') { + $modules{SONOSPLAYER}->{FW_addDetailToSummary} = ($attrValue =~ m/(Both)/i) if (defined($modules{SONOSPLAYER})); } } elsif ($mode eq 'del') { if ($attrName eq 'disable') { @@ -953,6 +1010,8 @@ sub SONOS_Attribute($$$@) { $disableChange = 1; $attrValue = 0; } + } elsif ($attrName eq 'deviceRoomView') { + $modules{SONOSPLAYER}->{FW_addDetailToSummary} = 1 if (defined($modules{SONOSPLAYER})); } } @@ -1015,11 +1074,47 @@ sub SONOS_StopSubProcess($) { sub SONOS_Notify() { my ($hash, $notifyhash) = @_; - # Bei einem globalen save auch die bookmarks sichern... - if (($notifyhash->{NAME} eq 'global') && ($notifyhash->{CHANGED}[0] eq 'SAVE')) { - SONOS_DoWork('SONOS', 'SaveBookmarks', ''); + return if (AttrVal($hash->{NAME}, 'disable', 0)); + + my $events = deviceEvents($notifyhash, 1); + return if(!$events); + + my $triggerCoverTitle = 0; + + foreach my $event (@{$events}) { + next if(!defined($event)); + + #SONOS_Log $hash->{UDN}, 0, 'Event: '.$notifyhash->{NAME}.'~'.$event; + + # Wenn ein CoverTitle-Trigger gesendet werden muss... + if ($event =~ m/^(currentAlbumArtURL|currentTrackProviderIconRoundURL|currentTrackDuration|currentTrack|numberOfTracks|currentTitle|currentArtist|currentAlbum|nextAlbumArtURL|nextTrackProviderIconRoundURL|nextTitle|nextArtist|nextAlbum|currentSender|currentSenderInfo|currentSenderCurrent|transportState):/is) { + $triggerCoverTitle = 1; + } + + # Wenn der Benutzer das Kommando 'Save' ausgeführt hat, dann auch die Bookmarks sichern... + if (($notifyhash->{NAME} eq 'global') && ($event eq 'SAVE')) { + SONOS_DoWork('SONOS', 'SaveBookmarks', ''); + } } + if ($triggerCoverTitle) { + InternalTimer(gettimeofday(), 'SONOS_TriggerCoverTitelLater', $notifyhash, 0); + } + + return undef; +} + +######################################################################################## +# +# SONOS_TriggerCoverTitelLater - Refreshs the CoverTitle-Element later via DoTrigger +# +######################################################################################## +sub SONOS_TriggerCoverTitelLater($) { + my ($hash) = @_; + + my $html = SONOSPLAYER_Detail('', $hash->{NAME}, '', 0); + DoTrigger($hash->{NAME}, 'display_covertitle: '.$html, 1); + return undef; } @@ -1046,6 +1141,8 @@ sub SONOS_Ready($) { sub SONOS_Read($) { my ($hash) = @_; + RemoveInternalTimer($hash, 'SONOS_IsSubprocessAliveChecker'); + # Bis zum letzten (damit der Puffer leer ist) Zeilenumbruch einlesen, da SimpleRead immer nur kleine Päckchen einliest. my $buf = DevIo_SimpleReadWithTimeout($hash, AttrVal($hash->{NAME}, 'coverLoadTimeout', $SONOS_DEFAULTCOVERLOADTIMEOUT)); @@ -1213,12 +1310,16 @@ sub SONOS_Read($) { # Updating... SONOS_readingsBulkUpdateIfChanged($hash, "currentTrack", ''); SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackURI", ''); + SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackHandle", ''); SONOS_readingsBulkUpdateIfChanged($hash, "currentEnqueuedTransportURI", ''); + SONOS_readingsBulkUpdateIfChanged($hash, "currentEnqueuedTransportHandle", ''); SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackDuration", ''); - SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackPosition", ''); - SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackPositionSec", 0); + SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackDurationSec", ''); + readingsBulkUpdate($hash, "currentTrackPosition", ''); + readingsBulkUpdate($hash, "currentTrackPositionSec", 0); SONOS_readingsBulkUpdateIfChanged($hash, "currentTitle", 'Disappeared'); SONOS_readingsBulkUpdateIfChanged($hash, "currentArtist", ''); + SONOS_readingsBulkUpdateIfChanged($hash, "currentSource", ''); SONOS_readingsBulkUpdateIfChanged($hash, "currentAlbum", ''); SONOS_readingsBulkUpdateIfChanged($hash, "currentOriginalTrackNumber", ''); SONOS_readingsBulkUpdateIfChanged($hash, "currentAlbumArtist", ''); @@ -1229,7 +1330,9 @@ sub SONOS_Read($) { SONOS_readingsBulkUpdateIfChanged($hash, "currentStreamAudio", 0); SONOS_readingsBulkUpdateIfChanged($hash, "currentNormalAudio", 1); SONOS_readingsBulkUpdateIfChanged($hash, "nextTrackDuration", ''); + SONOS_readingsBulkUpdateIfChanged($hash, "nextTrackDurationSec", ''); SONOS_readingsBulkUpdateIfChanged($hash, "nextTrackURI", ''); + SONOS_readingsBulkUpdateIfChanged($hash, "nextTrackHandle", ''); SONOS_readingsBulkUpdateIfChanged($hash, "nextTitle", ''); SONOS_readingsBulkUpdateIfChanged($hash, "nextArtist", ''); SONOS_readingsBulkUpdateIfChanged($hash, "nextAlbum", ''); @@ -1254,6 +1357,10 @@ sub SONOS_Read($) { if ($hash) { readingsBeginUpdate($hash); + my $oldTransportState = ReadingsVal($hash->{NAME}, 'transportState', 0); + my $oldTrackHandle = ReadingsVal($hash->{NAME}, 'currentTrackHandle', ''); + my $oldTrack = ReadingsVal($hash->{NAME}, 'currentTrack', ''); + my $oldTrackPosition = ReadingsVal($hash->{NAME}, 'currentTrackPosition', ''); # Wurden für das Device bereits Favoriten geladen? Dann raussuchen, ob gerade ein solcher abgespielt wird... $current{FavouriteName} = ''; @@ -1332,7 +1439,9 @@ sub SONOS_Read($) { SONOS_readingsBulkUpdateIfChanged($hash, "numberOfTracks", $current{NumberOfTracks}); SONOS_readingsBulkUpdateIfChanged($hash, "currentTrack", $current{Track}); SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackURI", $current{TrackURI}); + SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackHandle", $current{TrackHandle}); SONOS_readingsBulkUpdateIfChanged($hash, "currentEnqueuedTransportURI", $current{EnqueuedTransportURI}); + SONOS_readingsBulkUpdateIfChanged($hash, "currentEnqueuedTransportHandle", $current{EnqueuedTransportHandle}); SONOS_readingsBulkUpdateIfChanged($hash, "currentFavouriteName", $current{FavouriteName}); SONOS_readingsBulkUpdateIfChanged($hash, "currentPlaylistName", $current{PlaylistName}); @@ -1353,11 +1462,22 @@ sub SONOS_Read($) { } SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackDuration", $current{TrackDuration}); - SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackPosition", $current{TrackPosition}); - SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackPositionSec", $current{TrackPositionSec}); + SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackDurationSec", $current{TrackDurationSec}); + + if ($current{StreamAudio} && ($oldTransportState eq $current{TransportState})) { + SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackPosition", '0:00:00'); + SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackPositionSec", '0:00:00'); + } else { + readingsBulkUpdate($hash, "currentTrackPosition", $current{TrackPosition}); + readingsBulkUpdate($hash, "currentTrackPositionSec", $current{TrackPositionSec}); + } + SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackProvider", $current{TrackProvider}); + SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackProviderIconQuadraticURL", $current{TrackProviderIconQuadraticURL}); + SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackProviderIconRoundURL", $current{TrackProviderIconRoundURL}); SONOS_readingsBulkUpdateIfChanged($hash, "currentTitle", $current{Title}); SONOS_readingsBulkUpdateIfChanged($hash, "currentArtist", $current{Artist}); + SONOS_readingsBulkUpdateIfChanged($hash, "currentSource", $current{Source}); SONOS_readingsBulkUpdateIfChanged($hash, "currentAlbum", $current{Album}); SONOS_readingsBulkUpdateIfChanged($hash, "currentOriginalTrackNumber", $current{OriginalTrackNumber}); SONOS_readingsBulkUpdateIfChanged($hash, "currentAlbumArtist", $current{AlbumArtist}); @@ -1368,8 +1488,12 @@ sub SONOS_Read($) { SONOS_readingsBulkUpdateIfChanged($hash, "currentStreamAudio", $current{StreamAudio}); SONOS_readingsBulkUpdateIfChanged($hash, "currentNormalAudio", $current{NormalAudio}); SONOS_readingsBulkUpdateIfChanged($hash, "nextTrackDuration", $current{nextTrackDuration}); + SONOS_readingsBulkUpdateIfChanged($hash, "nextTrackDurationSec", $current{nextTrackDurationSec}); SONOS_readingsBulkUpdateIfChanged($hash, "nextTrackURI", $current{nextTrackURI}); + SONOS_readingsBulkUpdateIfChanged($hash, "nextTrackHandle", $current{nextTrackHandle}); SONOS_readingsBulkUpdateIfChanged($hash, "nextTrackProvider", $current{nextTrackProvider}); + SONOS_readingsBulkUpdateIfChanged($hash, "nextTrackProviderIconQuadraticURL", $current{nextTrackProviderIconQuadraticURL}); + SONOS_readingsBulkUpdateIfChanged($hash, "nextTrackProviderIconRoundURL", $current{nextTrackProviderIconRoundURL}); SONOS_readingsBulkUpdateIfChanged($hash, "nextTitle", $current{nextTitle}); SONOS_readingsBulkUpdateIfChanged($hash, "nextArtist", $current{nextArtist}); SONOS_readingsBulkUpdateIfChanged($hash, "nextAlbum", $current{nextAlbum}); @@ -1415,8 +1539,11 @@ sub SONOS_Read($) { $currentElem{Track} = $current{Track}; $currentElem{NumberOfTracks} = $current{NumberOfTracks}; $currentElem{TrackDuration} = $current{TrackDuration}; + $currentElem{TrackDurationSec} = $current{TrackDurationSec}; $currentElem{TrackPosition} = $current{TrackPosition}; $currentElem{TrackProvider} = $current{TrackProvider}; + $currentElem{TrackProviderIconQuadraticURL} = $current{TrackProviderIconQuadraticURL}; + $currentElem{TrackProviderIconRoundURL} = $current{TrackProviderIconRoundURL}; # Loslegen readingsBeginUpdate($elem); @@ -1429,8 +1556,11 @@ sub SONOS_Read($) { SONOS_readingsBulkUpdateIfChanged($elem, "currentTrack", $currentElem{Track}); SONOS_readingsBulkUpdateIfChanged($elem, "numberOfTracks", $currentElem{NumberOfTracks}); SONOS_readingsBulkUpdateIfChanged($elem, "currentTrackDuration", $currentElem{TrackDuration}); - SONOS_readingsBulkUpdateIfChanged($elem, "currentTrackPosition", $currentElem{TrackPosition}); - SONOS_readingsBulkUpdateIfChanged($hash, "currentTrackProvider", $currentElem{TrackProvider}); + SONOS_readingsBulkUpdateIfChanged($elem, "currentTrackDurationSec", $currentElem{TrackDurationSec}); + readingsBulkUpdate($elem, "currentTrackPosition", $currentElem{TrackPosition}); + SONOS_readingsBulkUpdateIfChanged($elem, "currentTrackProvider", $currentElem{TrackProvider}); + SONOS_readingsBulkUpdateIfChanged($elem, "currentTrackProviderIconQuadraticURL", $currentElem{TrackProviderIconQuadraticURL}); + SONOS_readingsBulkUpdateIfChanged($elem, "currentTrackProviderIconRoundURL", $currentElem{TrackProviderIconRoundURL}); if (AttrVal($elem->{NAME}, 'generateSomethingChangedEvent', 0) == 1) { readingsBulkUpdate($elem, "somethingChanged", 1); @@ -1450,6 +1580,14 @@ sub SONOS_Read($) { } } } + + # SimulatedValues aktualisieren, wenn ein Wechsel des Titels stattgefunden hat, und gerade keine Wiedergabe erfolgt... + if (($current{TransportState} ne 'PLAYING') + && (($oldTrackHandle ne $current{TrackHandle}) + || ($oldTrack != $current{Track}) + || ($oldTrackPosition ne $current{TrackPosition}))) { + SONOSPLAYER_SimulateCurrentTrackPosition($hash); + } } else { SONOS_Log undef, 0, "Fehlerhafter Aufruf von CurrentBulkUpdate: $1"; } @@ -1489,9 +1627,15 @@ sub SONOS_Read($) { $srcURI = $groundURL.$tempURI; $currentValue = $attr{global}{modpath}.'/www/images/default/SONOSPLAYER/'.$name.'_'.$nextName.'AlbumArt.'.SONOS_ImageDownloadTypeExtension($groundURL.$tempURI); SONOS_Log undef, 4, "Transport-Event: Spotify-Bilder-Download failed. Use normal thumbnail: SONOS_DownloadReplaceIfChanged('$srcURI', '".$currentValue."');"; - } + }#http://192.168.0.47:1400/getaa?s=1&u=x-sonosapi-stream%3as84483%3fsid%3d254%26flags%3d32 } elsif ($tempURI =~ m/getaa.*?x-sonosapi-stream%3a(.+?)%3f/i) { - $srcURI = 'http://cdn-radiotime-logos.tunein.com/'.$1.'g.png';; + $srcURI = SONOS_GetRadioMediaMetadata($hash->{UDN}, $1); + eval { + my $result = SONOS_ReadURL($srcURI); + if (!defined($result) || ($result =~ m/.*<\/Error>/i)) { + $srcURI = $groundURL.$tempURI; + } + }; $currentValue = $attr{global}{modpath}.'/www/images/default/SONOSPLAYER/'.$name.'_'.$nextName.'AlbumArt.png'; SONOS_Log undef, 4, "Transport-Event: Radiocover-Download: SONOS_DownloadReplaceIfChanged('$srcURI', '".$currentValue."');"; } elsif ($tempURI =~ m/^\/fhem\/sonos\/cover\/(.*)/i) { @@ -1567,6 +1711,8 @@ sub SONOS_Read($) { SONOS_DoTriggerInternal('Main', $line); } } + + InternalTimer(gettimeofday() + $hash->{INTERVAL}, 'SONOS_IsSubprocessAliveChecker', $hash, 0); } ######################################################################################## @@ -1657,11 +1803,11 @@ sub SONOS_StartClientProcessIfNeccessary($) { # Antwort vom Client weglesen... my $answer; - $socket->recv($answer, 50); + $socket->recv($answer, 5000); + $socket->send("Test\r\n", 0); # Hiermit wird eine etwaig bestehende Thread-Struktur beendet und diese Verbindung selbst geschlossen... eval{ - $socket->send("disconnect\n", 0); $socket->shutdown(2); $socket->close(); }; @@ -1682,6 +1828,7 @@ sub SONOS_InitClientProcessLater($) { # Begrüßung weglesen... my $answer = DevIo_SimpleRead($hash); + DevIo_SimpleWrite($hash, "Establish connection\r\n", 0); # Verbindung aufbauen... InternalTimer(gettimeofday() + 1, 'SONOS_InitClientProcess', $hash, 0); @@ -1782,27 +1929,26 @@ sub SONOS_IsSubprocessAliveChecker() { return undef if (AttrVal($hash->{NAME}, 'disable', 0)); my $answer; + # Neue Verbindung parallel zur bestehenden Kommunikationsleitung. + # Nur zum Prüfen, ob der SubProzess noch lebt und antworten kann. my $socket = new IO::Socket::INET(PeerAddr => $hash->{DeviceName}, Proto => 'tcp'); if ($socket) { $socket->sockopt(SO_LINGER, pack("ii", 1, 0)); $socket->recv($answer, 500); - - $socket->send("hello\n", 0); - $socket->recv($answer, 500); - - $socket->send("goaway\n", 0); + $socket->send("Test\r\n", 0); $socket->shutdown(2); $socket->close(); } + # Ab hier keine Parallelverbindung mehr offen... if (defined($answer)) { $answer =~ s/[\r\n]//g; } - if (!defined($answer) || ($answer ne 'OK')) { - SONOS_Log undef, 0, 'No Answer from Subprocess. Restart Sonos-Subprocess...'; + if (!defined($answer) || ($answer !~ m/^This is UPnP-Server listening for commands/)) { + SONOS_Log undef, 0, 'No (or incorrect) answer from Subprocess. Restart Sonos-Subprocess...'; # Verbindung beenden, damit der SubProzess die Chance hat neu initialisiert zu werden... RemoveInternalTimer($hash); @@ -1812,9 +1958,9 @@ sub SONOS_IsSubprocessAliveChecker() { # Neu anstarten... SONOS_StartClientProcessIfNeccessary($hash->{DeviceName}) if ($SONOS_StartedOwnUPnPServer); InternalTimer(gettimeofday() + $hash->{WAITTIME}, 'SONOS_DelayOpenDev', $hash, 0); - } elsif (defined($answer) && ($answer eq 'OK')) { - SONOS_Log undef, 4, 'Got correct Answer from Subprocess...'; - + } elsif (defined($answer) && ($answer =~ m/^This is UPnP-Server listening for commands/)) { + SONOS_Log undef, 4, 'Got correct answer from Subprocess...'; + RemoveInternalTimer($hash, 'SONOS_IsSubprocessAliveChecker'); InternalTimer(gettimeofday() + $hash->{INTERVAL}, 'SONOS_IsSubprocessAliveChecker', $hash, 0); } } @@ -2205,6 +2351,9 @@ sub SONOS_DoWork($$;@) { my $hash = SONOS_getSonosPlayerByName(); + while ($SONOS_Thread_IsAlive_CheckerActive) { + select(undef, undef, undef, 0.1); + } DevIo_SimpleWrite($hash, 'DoWork:'.$udn.':'.$method.':'.encode_utf8(join('--#--', @params))."\r\n", 0); return undef; @@ -2317,8 +2466,9 @@ sub SONOS_Discover() { my $position = $SONOS_AVTransportControlProxy{$udn}->GetPositionInfo(0)->getValue('RelTime'); SONOS_Client_Notifier('ReadingsBeginUpdate:'.$udn); - SONOS_Client_Data_Refresh('ReadingsBulkUpdateIfChanged', $udn, 'currentTrackPosition', $position); - SONOS_Client_Data_Refresh('ReadingsBulkUpdateIfChanged', $udn, 'currentTrackPositionSec', SONOS_GetTimeSeconds($position)); + my $modus = 'ReadingsBulkUpdate'.((SONOS_Client_Data_Retreive($udn, 'reading', 'currentStreamAudio', 0)) ? 'IfChanged' : ''); + SONOS_Client_Data_Refresh($modus, $udn, 'currentTrackPosition', $position); + SONOS_Client_Data_Refresh($modus, $udn, 'currentTrackPositionSec', SONOS_GetTimeSeconds($position)); SONOS_Client_Notifier('ReadingsEndUpdate:'.$udn); SONOS_MakeSigHandlerReturnValue($udn, 'LastActionResult', ucfirst($workType).': DirectlySet'); @@ -3250,18 +3400,24 @@ sub SONOS_Discover() { $resultHash{DurationSec} = 0; $resultHash{Duration} = '0:00:00'; my $position = 0; - while ($tmp =~ m/.*?(.*?)<\/res>.*?(.*?)<\/dc:title>.*?(.*?)<\/dc:creator>.*?(.*?)<\/upnp:album>.*?<\/item>/ig) { + while ($tmp =~ m/.*?(.*?)<\/res>.*?(.*?)<\/dc:title>.*?(.*?)<\/dc:creator>.*?(.*?)<\/upnp:album>.*?<\/item>/ig) { my $key = $1.sprintf("%04d", $2); $resultHash{$key}->{Position} = ++$position; - $resultHash{$key}->{ShowTitle} = $position.'. ('.$6.') '.$5.' ['.$3.']'; - $resultHash{$key}->{Title} = $5; - $resultHash{$key}->{Artist} = $6; - $resultHash{$key}->{Album} = $7; - $resultHash{$key}->{Duration} = $3; - $resultHash{$key}->{DurationSec} = SONOS_GetTimeSeconds($3); - $resultHash{DurationSec} += SONOS_GetTimeSeconds($3); - $resultHash{$key}->{Cover} = SONOS_MakeCoverURL($udn, $4); - $resultHash{$key}->{Ressource} = decode_entities($4); + $resultHash{$key}->{Title} = $6; + $resultHash{$key}->{Artist} = $7; + $resultHash{$key}->{Album} = $8; + if (defined($4)) { + $resultHash{$key}->{ShowTitle} = $position.'. ('.$7.') '.$6.' ['.$4.']'; + $resultHash{$key}->{Duration} = $4; + $resultHash{$key}->{DurationSec} = SONOS_GetTimeSeconds($4); + $resultHash{DurationSec} += SONOS_GetTimeSeconds($4); + } else { + $resultHash{$key}->{ShowTitle} = $position.'. ('.$7.') '.$6.' [k.A.]'; + $resultHash{$key}->{Duration} = '0:00:00'; + $resultHash{$key}->{DurationSec} = 0; + } + $resultHash{$key}->{Cover} = SONOS_MakeCoverURL($udn, $5); + $resultHash{$key}->{Ressource} = decode_entities($5); } $resultHash{Duration} = SONOS_ConvertSecondsToTime($resultHash{DurationSec}); @@ -3377,29 +3533,31 @@ sub SONOS_Discover() { return; } - if (SONOS_CheckProxyObject($udn, $SONOS_AVTransportControlProxy{$udn})) { - # Entscheiden, ob eine Abspielliste geladen und gestartet werden soll, oder etwas direkt abgespielt werden kann - if ($resultHash{$favouriteName}{METADATA} =~ m/object\.container.*?<\/upnp:class>/i) { - SONOS_Log $udn, 5, 'StartFavourite AddToQueue-Res: "'.$resultHash{$favouriteName}{RES}.'", -Meta: "'.$resultHash{$favouriteName}{METADATA}.'"'; - - # Queue leeren - $SONOS_AVTransportControlProxy{$udn}->RemoveAllTracksFromQueue(0); - - # Queue wieder füllen - SONOS_MakeSigHandlerReturnValue($udn, 'LastActionResult', ucfirst($workType).': '.SONOS_UPnPAnswerMessage($SONOS_AVTransportControlProxy{$udn}->AddURIToQueue(0, $resultHash{$favouriteName}{RES}, $resultHash{$favouriteName}{METADATA}, 0, 1))); - - # Queue aktivieren - $SONOS_AVTransportControlProxy{$udn}->SetAVTransportURI(0, SONOS_GetTagData('res', $SONOS_ContentDirectoryControlProxy{$udn}->Browse('Q:0', 'BrowseMetadata', '', 0, 0, '')->getValue('Result')), ''); - } else { - SONOS_Log $udn, 5, 'StartFavourite SetAVTransport-Res: "'.$resultHash{$favouriteName}{RES}.'", -Meta: "'.$resultHash{$favouriteName}{METADATA}.'"'; - - # Stück aktivieren - SONOS_MakeSigHandlerReturnValue($udn, 'LastActionResult', ucfirst($workType).': '.SONOS_UPnPAnswerMessage($SONOS_AVTransportControlProxy{$udn}->SetAVTransportURI(0, $resultHash{$favouriteName}{RES}, $resultHash{$favouriteName}{METADATA}))); - } - - # Abspielen starten, wenn nicht absichtlich verhindert - $SONOS_AVTransportControlProxy{$udn}->Play(0, 1) if (!$nostart); - } + SONOS_StartMetadata($workType, $udn, $resultHash{$favouriteName}{RES}, $resultHash{$favouriteName}{METADATA}, $nostart); + +# if (SONOS_CheckProxyObject($udn, $SONOS_AVTransportControlProxy{$udn})) { +# # Entscheiden, ob eine Abspielliste geladen und gestartet werden soll, oder etwas direkt abgespielt werden kann +# if ($resultHash{$favouriteName}{METADATA} =~ m/object\.container.*?<\/upnp:class>/i) { +# SONOS_Log $udn, 5, 'StartFavourite AddToQueue-Res: "'.$resultHash{$favouriteName}{RES}.'", -Meta: "'.$resultHash{$favouriteName}{METADATA}.'"'; +# +# # Queue leeren +# $SONOS_AVTransportControlProxy{$udn}->RemoveAllTracksFromQueue(0); +# +# # Queue wieder füllen +# SONOS_MakeSigHandlerReturnValue($udn, 'LastActionResult', ucfirst($workType).': '.SONOS_UPnPAnswerMessage($SONOS_AVTransportControlProxy{$udn}->AddURIToQueue(0, $resultHash{$favouriteName}{RES}, $resultHash{$favouriteName}{METADATA}, 0, 1))); +# +# # Queue aktivieren +# $SONOS_AVTransportControlProxy{$udn}->SetAVTransportURI(0, SONOS_GetTagData('res', $SONOS_ContentDirectoryControlProxy{$udn}->Browse('Q:0', 'BrowseMetadata', '', 0, 0, '')->getValue('Result')), ''); +# } else { +# SONOS_Log $udn, 5, 'StartFavourite SetAVTransport-Res: "'.$resultHash{$favouriteName}{RES}.'", -Meta: "'.$resultHash{$favouriteName}{METADATA}.'"'; +# +# # Stück aktivieren +# SONOS_MakeSigHandlerReturnValue($udn, 'LastActionResult', ucfirst($workType).': '.SONOS_UPnPAnswerMessage($SONOS_AVTransportControlProxy{$udn}->SetAVTransportURI(0, $resultHash{$favouriteName}{RES}, $resultHash{$favouriteName}{METADATA}))); +# } +# +# # Abspielen starten, wenn nicht absichtlich verhindert +# $SONOS_AVTransportControlProxy{$udn}->Play(0, 1) if (!$nostart); +# } } } elsif ($workType eq 'loadPlaylist') { my $answer = ''; @@ -3969,6 +4127,42 @@ sub SONOS_Discover() { } } } + + if (defined($SONOS_MusicServicesSubscriptions{$udn}) && (Time::HiRes::time() - $SONOS_MusicServicesSubscriptions{$udn}->{_startTime} > $SONOS_SUBSCRIPTIONSRENEWAL)) { + eval { + $SONOS_MusicServicesSubscriptions{$udn}->renew(); + SONOS_Log $udn, 3, 'MusicServices-Subscription for ZonePlayer "'.$udn.'" has expired and is now renewed.'; + }; + if ($@) { + SONOS_Log $udn, 3, 'Error! MusicServices-Subscription for ZonePlayer "'.$udn.'" has expired and could not be renewed: '.$@; + + # Wenn der Player nicht erreichbar war, dann entsprechend entfernen... + # Hier aber nur eine kleine Lösung, da es nur ein Notbehelf sein soll... + if ($@ =~ m/Can.t connect to/) { + SONOS_DeleteProxyObjects($udn); + + # Player-Informationen aktualisieren... + SONOS_Client_Data_Refresh('ReadingsSingleUpdateIfChanged', $udn, 'presence', 'disappeared'); + SONOS_Client_Data_Refresh('ReadingsSingleUpdateIfChanged', $udn, 'state', 'disappeared'); + + # Discovery neu anstarten, falls der Player irgendwie doch noch erreichbar sein sollte... + $SONOS_Search = $SONOS_Controlpoint->searchByType('urn:schemas-upnp-org:device:ZonePlayer:1', \&SONOS_Discover_Callback); + } + } + } + + } elsif ($workType eq 'startHandle') { + if ($params[0] =~ m/^(.+)\|(.+)$/) { + my $songURI = $1; + my $songMeta = $2; + SONOS_Log undef, 4, 'songURI: '.$songURI; + SONOS_Log undef, 4, 'songMeta: '.$songMeta; + SONOS_Log undef, 4, 'nostart: '.$params[1]; + + SONOS_StartMetadata($workType, $udn, $songURI, $songMeta, $params[1]); + } else { + SONOS_MakeSigHandlerReturnValue($udn, 'LastActionResult', ucfirst($workType).': Wrong Handle: '.$params[0]); + } } elsif ($workType eq 'playURI') { my $songURI = SONOS_ExpandURIForQueueing($params[0]); SONOS_Log undef, 4, 'songURI: '.$songURI; @@ -4138,6 +4332,39 @@ sub SONOS_Discover() { return 1; } +######################################################################################## +# +# SONOS_StartMetadata - Starts any kind of Metadata +# +######################################################################################## +sub SONOS_StartMetadata($$$$) { + my ($workType, $udn, $res, $meta, $nostart) = @_; + + if (SONOS_CheckProxyObject($udn, $SONOS_AVTransportControlProxy{$udn})) { + # Entscheiden, ob eine Abspielliste geladen und gestartet werden soll, oder etwas direkt abgespielt werden kann + if ($meta =~ m/object\.container.*?<\/upnp:class>/i) { + SONOS_Log $udn, 5, 'StartFavourite AddToQueue-Res: "'.$res.'", -Meta: "'.$meta.'"'; + + # Queue leeren + $SONOS_AVTransportControlProxy{$udn}->RemoveAllTracksFromQueue(0); + + # Queue wieder füllen + SONOS_MakeSigHandlerReturnValue($udn, 'LastActionResult', ucfirst($workType).': '.SONOS_UPnPAnswerMessage($SONOS_AVTransportControlProxy{$udn}->AddURIToQueue(0, $res, $meta, 0, 1))); + + # Queue aktivieren + $SONOS_AVTransportControlProxy{$udn}->SetAVTransportURI(0, SONOS_GetTagData('res', $SONOS_ContentDirectoryControlProxy{$udn}->Browse('Q:0', 'BrowseMetadata', '', 0, 0, '')->getValue('Result')), ''); + } else { + SONOS_Log $udn, 5, 'StartFavourite SetAVTransport-Res: "'.$res.'", -Meta: "'.$meta.'"'; + + # Stück aktivieren + SONOS_MakeSigHandlerReturnValue($udn, 'LastActionResult', ucfirst($workType).': '.SONOS_UPnPAnswerMessage($SONOS_AVTransportControlProxy{$udn}->SetAVTransportURI(0, $res, $meta))); + } + + # Abspielen starten, wenn nicht absichtlich verhindert + $SONOS_AVTransportControlProxy{$udn}->Play(0, 1) if (!$nostart); + } +} + ######################################################################################## # # SONOS_RecursiveStructure - Retrieves the structure of the Sonos-Bibliothek @@ -4365,20 +4592,27 @@ sub SONOS_CountInString($$) { ######################################################################################## # -# SONOS_MakeCoverURL - Generates the approbriate cover-url incl. the use of an Fhem-Proxy +# SONOS_MakeCoverURL - Generates the approbriate cover-url incl. the use of a Fhem-Proxy # ######################################################################################## sub SONOS_MakeCoverURL($$) { my ($udn, $resURL) = @_; - SONOS_Log $udn, 5, 'MakeCoverURL-Before: '.$resURL; + SONOS_Log $udn, 0, 'MakeCoverURL-Before: '.$resURL; if ($resURL =~ m/^x-rincon-cpcontainer.*?(spotify.*?)(\?|$)/i) { $resURL = SONOS_getSpotifyCoverURL($1, 1); } elsif ($resURL =~ m/^x-sonos-spotify:spotify%3atrack%3a(.*?)(\?|$)/i) { $resURL = SONOS_getSpotifyCoverURL($1); } elsif ($resURL =~ m/^x-sonosapi-stream:(.+?)\?/i) { - $resURL = 'http://cdn-radiotime-logos.tunein.com/'.$1.'g.png'; + my $resURLtemp = SONOS_GetRadioMediaMetadata($udn, $1); + eval { + my $result = SONOS_ReadURL($resURLtemp); + if (!defined($result) || ($result =~ m/.*<\/Error>/i)) { + $resURLtemp = $1.'/getaa?s=1&u='.SONOS_URI_Escape($resURL) if (SONOS_Client_Data_Retreive($udn, 'reading', 'location', '') =~ m/^(http:\/\/.*?:.*?)\//i); + } + }; + $resURL = $resURLtemp; } elsif (($resURL =~ m/x-rincon-playlist:.*?#(.*)/i) || ($resURL =~ m/savedqueues.rsq(#\d+)/i)) { my $search = $1; $search = 'SQ:'.$1 if ($search =~ m/#(\d+)/i); @@ -4387,12 +4621,12 @@ sub SONOS_MakeCoverURL($$) { $resURL = '/fhem/sonos/cover/playlist.jpg'; if (SONOS_CheckProxyObject($udn, $SONOS_ContentDirectoryControlProxy{$udn})) { - my $result = $SONOS_ContentDirectoryControlProxy{$udn}->Browse($search, 'BrowseDirectChildren', '', 0, 5, ''); + my $result = $SONOS_ContentDirectoryControlProxy{$udn}->Browse($search, 'BrowseDirectChildren', '', 0, 15, ''); if ($result) { my $tmp = $result->getValue('Result'); while (defined($tmp) && $tmp =~ m/.*?<\/container>/i) { $search = $1; - $result = $SONOS_ContentDirectoryControlProxy{$udn}->Browse($search, 'BrowseDirectChildren', '', 0, 5, ''); + $result = $SONOS_ContentDirectoryControlProxy{$udn}->Browse($search, 'BrowseDirectChildren', '', 0, 15, ''); if ($result) { $tmp = $result->getValue('Result'); @@ -4404,8 +4638,9 @@ sub SONOS_MakeCoverURL($$) { if ($result) { my $tmp = $result->getValue('Result'); + my $coverOK = 0; - if (defined($tmp) && $tmp =~ m/.*?(.*?)<\/upnp:albumArtURI>.*?<\/item>/i) { + while (!$coverOK && defined($tmp) && $tmp =~ m/.*?(.*?)<\/upnp:albumArtURI>.*?<\/item>/ig) { $resURL = $1; $resURL =~ s/%25/%/ig; @@ -4415,19 +4650,30 @@ sub SONOS_MakeCoverURL($$) { } else { $resURL = $1.$resURL if (SONOS_Client_Data_Retreive($udn, 'reading', 'location', '') =~ m/^(http:\/\/.*?:.*?)\//i); } + + my $loadedCover = SONOS_ReadURL($resURL); + $coverOK = (defined($loadedCover) && ($loadedCover !~ m/.*<\/Error>/i)) } } } } else { my $stream = 0; $stream = 1 if (($resURL =~ /x-sonosapi-stream/) && ($resURL !~ /x-sonos-http%3aamz/)); - $resURL = $1.'/getaa?'.($stream ? 's=1&' : '').'u='.SONOS_URI_Escape($resURL) if (SONOS_Client_Data_Retreive($udn, 'reading', 'location', '') =~ m/^(http:\/\/.*?:.*?)\//i); + $stream = 1 if (!$stream && (($resURL =~ /x-sonosapi-hls-static/) || ($resURL =~ /x-sonos-http:track%3a/))); + + $resURL = SONOS_URI_Escape($resURL); + SONOS_Log undef, 0, 'resURL-1: '.$resURL; + $resURL =~ s/%26apos%3B/'/ig; + $resURL =~ s/%26amp%3B/%26/ig; + SONOS_Log undef, 0, 'resURL-2: '.$resURL; + $resURL = $1.'/getaa?'.($stream ? 's=1&' : '').'u='.$resURL if (SONOS_Client_Data_Retreive($udn, 'reading', 'location', '') =~ m/^(http:\/\/.*?:.*?)\//i); + SONOS_Log undef, 0, 'resURL-3: '.$resURL; } # Alles über Fhem als Proxy laufen lassen? $resURL = '/fhem/sonos/proxy/aa?url='.SONOS_URI_Escape($resURL) if (($resURL !~ m/^\//) && SONOS_Client_Data_Retreive('undef', 'attr', 'generateProxyAlbumArtURLs', 0)); - SONOS_Log $udn, 5, 'MakeCoverURL-After: '.$resURL; + SONOS_Log $udn, 0, 'MakeCoverURL-After: '.$resURL; return $resURL; } @@ -4449,7 +4695,7 @@ sub SONOS_getSpotifyCoverURL($;$) { } $infos =~ s/\\//g; - $infos = $1.'original'.$3 if ($infos =~ m/(.*?\/)(cover|default)(\/.*)/i); + #$infos = $1.'original'.$3 if ($infos =~ m/(.*?\/)(cover|default)(\/.*)/i); # Falls es ein Standardcover von Spotify geben soll, lieber das Thumbnail von Sonos verwenden... return '' if ($infos =~ m/\/static\/img\/defaultCoverL.png/i); @@ -4555,7 +4801,7 @@ sub SONOS_GetSpeakFile($$$$$) { SONOS_Log $udn, 3, 'Load Google generated MP3 ('.$counter.'. Element) from "'.$url.'" to "'.$destFileName.$counter.'"'; - my $ua = LWP::UserAgent->new(agent => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.64 Safari/537.11'); + my $ua = LWP::UserAgent->new(agent => $SONOS_USERAGENT); my $response = $ua->get($url, ':content_file' => $destFileName.$counter); if (!$response->is_success) { SONOS_Log $udn, 1, 'MP3 Download-Error: '.$response->status_line; @@ -4983,31 +5229,49 @@ sub SONOS_PlayURITemp($$$) { ######################################################################################## sub SONOS_GetTrackProvider($;$) { my ($songURI, $songTitle) = @_; + return ('', '', '') if (!defined($songURI) || ($songURI eq '')); # Backslashe umwandeln $songURI =~ s/\\/\//g; # Gruppen- und LineIn-Wiedergaben bereits hier erkennen if ($songURI =~ m/x-rincon:(RINCON_[\dA-Z]+)/) { - return 'Gruppenwiedergabe: '.SONOS_Client_Data_Retreive($1.'_MR', 'reading', 'roomName', $1); + return ('Gruppenwiedergabe: '.SONOS_Client_Data_Retreive($1.'_MR', 'reading', 'roomName', $1), '', ''); } elsif ($songURI =~ m/x-rincon-stream:(RINCON_[\dA-Z]+)/) { my $elem = 'LineIn'; $elem = $songTitle if (defined($songTitle) && $songTitle); - return $elem.'-Wiedergabe: '.SONOS_Client_Data_Retreive($1.'_MR', 'reading', 'roomName', $1); + return ($elem.'-Wiedergabe: '.SONOS_Client_Data_Retreive($1.'_MR', 'reading', 'roomName', $1), '/fhem/sonos/cover/linein_round.png', '/fhem/sonos/cover/linein_quadratic.jpg'); } elsif ($songURI =~ m/x-sonos-dock:(RINCON_[\dA-Z]+)/) { - return 'Dock-Wiedergabe: '.SONOS_Client_Data_Retreive($1.'_MR', 'reading', 'roomName', $1); + return ('Dock-Wiedergabe: '.SONOS_Client_Data_Retreive($1.'_MR', 'reading', 'roomName', $1), '/fhem/sonos/cover/dock_round.png', '/fhem/sonos/cover/dock_quadratic.jpg'); } elsif ($songURI =~ m/x-sonos-htastream:(RINCON_[\dA-Z]+):spdif/) { - return 'SPDIF-Wiedergabe: '.SONOS_Client_Data_Retreive($1.'_MR', 'reading', 'roomName', $1); + return ('SPDIF-Wiedergabe: '.SONOS_Client_Data_Retreive($1.'_MR', 'reading', 'roomName', $1), '/fhem/sonos/cover/playbar_round.png', '/fhem/sonos/cover/playbar_quadratic.jpg'); + } elsif (($songURI =~ m/^http:(\/\/.*)/) || ($songURI =~ m/^aac:(\/\/.*)/) || ($songURI =~ m/x-rincon-mp3radio:\/\//)) { + return ('Radio', '/fhem/sonos/cover/tunein_round.png', '/fhem/sonos/cover/tunein_quadratic.jpg'); + } elsif ($songURI =~ m/^\/\//) { + return ('Bibliothek', '/fhem/sonos/cover/bibliothek_round.png', '/fhem/sonos/cover/bibliothek_quadratic.jpg'); } - # Hier die restlichen Erkennungen durchführen... - for my $elem (keys %SONOS_ProviderList) { - if ($songURI =~ /$elem/) { - return $SONOS_ProviderList{$elem}; + my $result = ''; + my $roundIcon = ''; + my $quadraticIcon = ''; + eval { + my %musicServices = %{eval(SONOS_Client_Data_Retreive('undef', 'reading', 'MusicServicesList', '()'))}; + if ($songURI =~ m/sid=(\d+)/i) { + my $sid = $1; + $result = $musicServices{$sid}{Name}; + $result = '' if (!defined($result)); + + $roundIcon = $musicServices{$sid}{IconRoundURL}; + $quadraticIcon = $musicServices{$sid}{IconQuadraticURL}; + + SONOS_Log undef, 4, 'TrackProvider for "'.$songURI.'" ~ SID='.$sid.' ~ Name: '.$result; } + }; + if ($@) { + SONOS_Log undef, 2, 'Unable to identify TrackProvider for "'.$songURI.'". Revert to empty default!'; } - return ''; + return ($result, $roundIcon, $quadraticIcon); } ######################################################################################## @@ -5567,8 +5831,11 @@ sub SONOS_Discover_Callback($$$) { my $devicePropertiesService = $device->getService('urn:schemas-upnp-org:service:DeviceProperties:1'); $SONOS_DevicePropertiesProxy{$udn} = $devicePropertiesService->controlProxy if ($devicePropertiesService); + #$SONOS_GroupManagementProxy{$udn} = $device->getService('urn:schemas-upnp-org:service:GroupManagement:1')->controlProxy if ($device->getService('urn:schemas-upnp-org:service:GroupManagement:1')); - #$SONOS_MusicServicesProxy{$udn} = $device->getService('urn:schemas-upnp-org:service:MusicServices:1')->controlProxy if ($device->getService('urn:schemas-upnp-org:service:MusicServices:1')); + + my $musicServicesService = $device->getService('urn:schemas-upnp-org:service:MusicServices:1'); + $SONOS_MusicServicesProxy{$udn} = $musicServicesService->controlProxy if ($musicServicesService); my $zoneGroupTopologyService = $device->getService('urn:schemas-upnp-org:service:ZoneGroupTopology:1'); $SONOS_ZoneGroupTopologyProxy{$udn} = $zoneGroupTopologyService->controlProxy if ($zoneGroupTopologyService); @@ -5754,10 +6021,17 @@ sub SONOS_Discover_Callback($$$) { my $tmp = $result->getValue('TrackURI'); $tmp =~ s/'/'/gi; SONOS_Client_Data_Refresh('ReadingsBulkUpdateIfChanged', $udn, 'currentTrackURI', $tmp); - SONOS_Client_Data_Refresh('ReadingsBulkUpdateIfChanged', $udn, 'currentTrackProvider', SONOS_GetTrackProvider($result->getValue('TrackURI'))); + my ($trackProvider, $trackProviderRoundURL, $trackProviderQuadraticURL) = SONOS_GetTrackProvider($result->getValue('TrackURI')); + SONOS_Client_Data_Refresh('ReadingsBulkUpdateIfChanged', $udn, 'currentTrackProvider', $trackProvider); + SONOS_Client_Data_Refresh('ReadingsBulkUpdateIfChanged', $udn, 'currentTrackProviderIconRoundURL', $trackProviderRoundURL); + SONOS_Client_Data_Refresh('ReadingsBulkUpdateIfChanged', $udn, 'currentTrackProviderIconQuadraticURL', $trackProviderQuadraticURL); SONOS_Client_Data_Refresh('ReadingsBulkUpdateIfChanged', $udn, 'currentTrackDuration', $result->getValue('TrackDuration')); - SONOS_Client_Data_Refresh('ReadingsBulkUpdateIfChanged', $udn, 'currentTrackPosition', $result->getValue('RelTime')); - SONOS_Client_Data_Refresh('ReadingsBulkUpdateIfChanged', $udn, 'currentTrackPositionSec', SONOS_GetTimeSeconds($result->getValue('RelTime'))); + SONOS_Client_Data_Refresh('ReadingsBulkUpdateIfChanged', $udn, 'currentTrackDurationSec', SONOS_GetTimeSeconds($result->getValue('TrackDuration'))); + + my $modus = 'ReadingsBulkUpdate'.((SONOS_Client_Data_Retreive($udn, 'reading', 'currentStreamAudio', 0)) ? 'IfChanged' : ''); + SONOS_Client_Data_Refresh($modus, $udn, 'currentTrackPosition', $result->getValue('RelTime')); + SONOS_Client_Data_Refresh($modus, $udn, 'currentTrackPositionSec', SONOS_GetTimeSeconds($result->getValue('RelTime'))); + SONOS_Client_Data_Refresh('ReadingsBulkUpdateIfChanged', $udn, 'currentTrack', $result->getValue('Track')); $result = $SONOS_AVTransportControlProxy{$udn}->GetMediaInfo(0); @@ -5875,11 +6149,26 @@ sub SONOS_Discover_Callback($$$) { SONOS_Log undef, 2, 'AudioIn-Service-subscribing successful with SID='.$SONOS_AudioInSubscriptions{$udn}->SID; } else { SONOS_Log undef, 1, 'AudioIn-Service-subscribing NOT successful'; + delete($SONOS_AudioInSubscriptions{$udn}); } } else { undef($SONOS_AudioInSubscriptions{$udn}); } + # MusicServices-Subscription + if ($musicServicesService) { + $SONOS_MusicServicesSubscriptions{$udn} = $musicServicesService->subscribe(\&SONOS_MusicServicesCallback); + if (defined($SONOS_MusicServicesSubscriptions{$udn})) { + SONOS_Log undef, 2, 'MusicServices-Service-subscribing successful with SID='.$SONOS_MusicServicesSubscriptions{$udn}->SID; + } else { + SONOS_Log undef, 1, 'MusicServices-Service-subscribing NOT successful'; + delete($SONOS_MusicServicesSubscriptions{$udn}); + } + } else { + undef($SONOS_MusicServicesSubscriptions{$udn}); + } + + SONOS_Log undef, 3, 'Discover: End of discover-event for "'.$roomName.'".'; } elsif ($action eq 'deviceRemoved') { my $udn = $device->UDN; @@ -5923,9 +6212,12 @@ sub SONOS_GetDefineStringlist($$$$$$$$$$) { push(@defs, 'CommandAttr:'.$name.' generateVolumeSlider 1'); push(@defs, 'CommandAttr:'.$name.' getAlarms 1'); push(@defs, 'CommandAttr:'.$name.' minVolume 0'); - push(@defs, 'CommandAttr:'.$name.' stateVariable Presence'); + #push(@defs, 'CommandAttr:'.$name.' stateVariable Presence'); + push(@defs, 'CommandAttr:'.$name.' stateFormat presence ~ currentTrackPositionSimulated / currentTrackDuration'); push(@defs, 'CommandAttr:'.$name.' getTitleInfoFromMaster 1'); + push(@defs, 'CommandAttr:'.$name.' simulateCurrentTrackPosition 1'); + push(@defs, 'CommandAttr:'.$name.' webCmd Volume'); #push(@defs, 'CommandAttr:'.$name.' webCmd Play:Pause:Previous:Next:VolumeD:VolumeU:MuteT'); } else { push(@defs, 'CommandAttr:'.$name.' stateFormat presence'); @@ -5933,12 +6225,12 @@ sub SONOS_GetDefineStringlist($$$$$$$$$$) { } elsif (lc($devicetype) eq 'sonosplayer_readingsgroup') { if (!$isZoneBridge) { if ($master) { - push(@defs, 'CommandDefine:'.$name.'RG readingsGroup '.$name.':<{SONOS_getCoverTitleRG($DEVICE)}@infoSummarize2>'); - push(@defs, 'CommandAttr:'.$name.'RG room '.$sonosDeviceName); - push(@defs, 'CommandAttr:'.$name.'RG group '.$groupName); - push(@defs, 'CommandAttr:'.$name.'RG sortby 2'); - push(@defs, 'CommandAttr:'.$name.'RG noheading 1'); - push(@defs, 'CommandAttr:'.$name.'RG nonames 1'); +# push(@defs, 'CommandDefine:'.$name.'RG readingsGroup '.$name.':<{SONOS_getCoverTitleRG($DEVICE)}@infoSummarize2>'); +# push(@defs, 'CommandAttr:'.$name.'RG room '.$sonosDeviceName); +# push(@defs, 'CommandAttr:'.$name.'RG group '.$groupName); +# push(@defs, 'CommandAttr:'.$name.'RG sortby 2'); +# push(@defs, 'CommandAttr:'.$name.'RG noheading 1'); +# push(@defs, 'CommandAttr:'.$name.'RG nonames 1'); #push(@defs, 'CommandDefine:'.$name.'RG2 readingsGroup '.$name.':infoSummarize2@{SONOSPLAYER_GetMasterPlayerName($DEVICE)}'); #push(@defs, 'CommandAttr:'.$name.'RG2 valueFormat {" "}'); @@ -5963,19 +6255,19 @@ sub SONOS_GetDefineStringlist($$$$$$$$$$) { } elsif (lc($devicetype) eq 'sonosplayer_remotecontrol') { if (!$isZoneBridge) { if ($master) { - push(@defs, 'CommandDefine:'.$name.'RC remotecontrol'); - push(@defs, 'CommandAttr:'.$name.'RC room hidden'); - push(@defs, 'CommandAttr:'.$name.'RC group '.$sonosDeviceName); - push(@defs, 'CommandAttr:'.$name.'RC rc_iconpath icons/remotecontrol'); - push(@defs, 'CommandAttr:'.$name.'RC rc_iconprefix black_btn_'); - push(@defs, 'CommandAttr:'.$name.'RC row00 Play:rc_PLAY.svg,Pause:rc_PAUSE.svg,Previous:rc_PREVIOUS.svg,Next:rc_NEXT.svg,:blank,VolumeD:rc_VOLDOWN.svg,VolumeU:rc_VOLUP.svg,:blank,MuteT:rc_MUTE.svg,ShuffleT:rc_SHUFFLE.svg,RepeatT:rc_REPEAT.svg'); + #push(@defs, 'CommandDefine:'.$name.'RC remotecontrol'); + #push(@defs, 'CommandAttr:'.$name.'RC room hidden'); + #push(@defs, 'CommandAttr:'.$name.'RC group '.$sonosDeviceName); + #push(@defs, 'CommandAttr:'.$name.'RC rc_iconpath icons/remotecontrol'); + #push(@defs, 'CommandAttr:'.$name.'RC rc_iconprefix black_btn_'); + #push(@defs, 'CommandAttr:'.$name.'RC row00 Play:rc_PLAY.svg,Pause:rc_PAUSE.svg,Previous:rc_PREVIOUS.svg,Next:rc_NEXT.svg,:blank,VolumeD:rc_VOLDOWN.svg,VolumeU:rc_VOLUP.svg,:blank,MuteT:rc_MUTE.svg,ShuffleT:rc_SHUFFLE.svg,RepeatT:rc_REPEAT.svg'); - push(@defs, 'CommandDefine:'.$name.'RC_Notify notify '.$name.'RC set '.$name.' $EVENT'); + #push(@defs, 'CommandDefine:'.$name.'RC_Notify notify '.$name.'RC set '.$name.' $EVENT'); - push(@defs, 'CommandDefine:'.$name.'RC_Weblink weblink htmlCode {fhem("get '.$name.'RC htmlcode", 1)}'); - push(@defs, 'CommandAttr:'.$name.'RC_Weblink room '.$sonosDeviceName); - push(@defs, 'CommandAttr:'.$name.'RC_Weblink group '.$groupName); - push(@defs, 'CommandAttr:'.$name.'RC_Weblink sortby 3'); + #push(@defs, 'CommandDefine:'.$name.'RC_Weblink weblink htmlCode {fhem("get '.$name.'RC htmlcode", 1)}'); + #push(@defs, 'CommandAttr:'.$name.'RC_Weblink room '.$sonosDeviceName); + #push(@defs, 'CommandAttr:'.$name.'RC_Weblink group '.$groupName); + #push(@defs, 'CommandAttr:'.$name.'RC_Weblink sortby 3'); } } } @@ -6154,6 +6446,7 @@ sub SONOS_DeleteProxyObjects($) { delete $SONOS_ZoneGroupTopologySubscriptions{$udn}; delete $SONOS_DevicePropertiesSubscriptions{$udn}; delete $SONOS_AudioInSubscriptions{$udn}; + delete $SONOS_MusicServicesSubscriptions{$udn}; # Am Ende noch das Device entfernen... delete $SONOS_UPnPDevice{$udn}; @@ -6183,22 +6476,29 @@ sub SONOS_GetReadingsToCurrentHash($$) { $current{NumberOfTracks} = ''; $current{Track} = ''; $current{TrackURI} = ''; + $current{TrackHandle} = ''; $current{TrackDuration} = ''; + $current{TrackDurationSec} = ''; $current{TrackPosition} = ''; $current{TrackProvider} = ''; + $current{TrackProviderIconQuadraticURL} = ''; + $current{TrackProviderIconRoundURL} = ''; $current{TrackMetaData} = ''; $current{AlbumArtURI} = ''; $current{AlbumArtURL} = ''; $current{Title} = ''; $current{Artist} = ''; $current{Album} = ''; + $current{Source} = ''; $current{OriginalTrackNumber} = ''; $current{AlbumArtist} = ''; $current{Sender} = ''; $current{SenderCurrent} = ''; $current{SenderInfo} = ''; $current{nextTrackDuration} = ''; + $current{nextTrackDurationSec} = ''; $current{nextTrackURI} = ''; + $current{nextTrackHandle} = ''; $current{nextAlbumArtURI} = ''; $current{nextAlbumArtURL} = ''; $current{nextTitle} = ''; @@ -6222,25 +6522,36 @@ sub SONOS_GetReadingsToCurrentHash($$) { $current{NumberOfTracks} = ReadingsVal($name, 'numberOfTracks', ''); $current{Track} = ReadingsVal($name, 'currentTrack', ''); $current{TrackURI} = ReadingsVal($name, 'currentTrackURI', ''); - $current{TrackURI} = ReadingsVal($name, 'currentEnqueuedTransportURI', ''); + $current{TrackHandle} = ReadingsVal($name, 'currentTrackHandle', ''); + $current{EnqueuedTransportURI} = ReadingsVal($name, 'currentEnqueuedTransportURI', ''); + $current{EnqueuedTransportHandle} = ReadingsVal($name, 'currentEnqueuedTransportHandle', ''); + $current{TrackDuration} = ReadingsVal($name, 'currentTrackDuration', ''); + $current{TrackDurationSec} = ReadingsVal($name, 'currentTrackDurationSec', ''); $current{TrackPosition} = ReadingsVal($name, 'currentTrackPosition', ''); $current{TrackPosition} = ReadingsVal($name, 'currentTrackPositionSec', ''); $current{TrackProvider} = ReadingsVal($name, 'currentTrackProvider', ''); + $current{TrackProviderIconQuadraticURL} = ReadingsVal($name, 'currentTrackProviderIconQuadraticURL', ''); + $current{TrackProviderIconRoundURL} = ReadingsVal($name, 'currentTrackProviderIconRoundURL', ''); #$current{TrackMetaData} = ''; $current{AlbumArtURI} = ReadingsVal($name, 'currentAlbumArtURI', ''); $current{AlbumArtURL} = ReadingsVal($name, 'currentAlbumArtURL', ''); $current{Title} = ReadingsVal($name, 'currentTitle', ''); $current{Artist} = ReadingsVal($name, 'currentArtist', ''); $current{Album} = ReadingsVal($name, 'currentAlbum', ''); + $current{Source} = ReadingsVal($name, 'currentSource', ''); $current{OriginalTrackNumber} = ReadingsVal($name, 'currentOriginalTrackNumber', ''); $current{AlbumArtist} = ReadingsVal($name, 'currentAlbumArtist', ''); $current{Sender} = ReadingsVal($name, 'currentSender', ''); $current{SenderCurrent} = ReadingsVal($name, 'currentSenderCurrent', ''); $current{SenderInfo} = ReadingsVal($name, 'currentSenderInfo', ''); $current{nextTrackDuration} = ReadingsVal($name, 'nextTrackDuration', ''); + $current{nextTrackDurationSec} = ReadingsVal($name, 'nextTrackDurationSec', ''); $current{nextTrackURI} = ReadingsVal($name, 'nextTrackURI', ''); + $current{nextTrackHandle} = ReadingsVal($name, 'nextTrackHandle', ''); $current{nextTrackProvider} = ReadingsVal($name, 'nextTrackProvider', ''); + $current{nextTrackProviderIconQuadraticURL} = ReadingsVal($name, 'nextTrackProviderIconQuadraticURL', ''); + $current{nextTrackProviderIconRoundURL} = ReadingsVal($name, 'nextTrackProviderIconRoundURL', ''); $current{nextAlbumArtURI} = ReadingsVal($name, 'nextAlbumArtURI', ''); $current{nextAlbumArtURL} = ReadingsVal($name, 'nextAlbumArtURL', ''); $current{nextTitle} = ReadingsVal($name, 'nextTitle', ''); @@ -6470,15 +6781,15 @@ sub SONOS_TransportCallback($$) { # Für die Bookmarkverwaltung ablegen $SONOS_BookmarkSpeicher{OldTrackURIs}{$udn} = $currentTrackURI; + my $enqueuedTransportMetaData = decode_entities($1) if ($properties{LastChangeDecoded} =~ m//i); + # Wenn es ein Spotify-Track ist, dann den Benutzernamen sichern, damit man diesen beim nächsten Export zur Verfügung hat if ($currentTrackURI =~ m/^x-sonos-spotify:/i) { - my $enqueuedTransportMetaData = decode_entities($1) if ($properties{LastChangeDecoded} =~ m//i); SONOS_Client_Notifier('ReadingsSingleUpdateIfChangedNoTrigger:undef:UserID_Spotify:'.SONOS_URI_Escape($1)) if ($enqueuedTransportMetaData =~ m/(SA_.*?)<\/desc>/i); } # Wenn es ein Napster/Rhapsody-Track ist, dann den Benutzernamen sichern, damit man diesen beim nächsten Export zur Verfügung hat if ($currentTrackURI =~ m/^npsdy:/i) { - my $enqueuedTransportMetaData = decode_entities($1) if ($properties{LastChangeDecoded} =~ m//i); SONOS_Client_Notifier('ReadingsSingleUpdateIfChangedNoTrigger:undef:UserID_Napster:'.SONOS_URI_Escape($1)) if ($enqueuedTransportMetaData =~ m/(SA_.*?)<\/desc>/i); } @@ -6486,20 +6797,39 @@ sub SONOS_TransportCallback($$) { my $enqueuedTransportURI = decode_entities($1) if ($properties{LastChangeDecoded} =~ m//i); $enqueuedTransportURI = "" if (!defined($enqueuedTransportURI)); SONOS_Client_Notifier('SetCurrent:EnqueuedTransportURI:'.decode_entities($enqueuedTransportURI)); + SONOS_Client_Notifier('SetCurrent:EnqueuedTransportHandle:'.decode_entities($enqueuedTransportURI).'|'.$enqueuedTransportMetaData); + if ($enqueuedTransportMetaData =~ m/(.*?)<\/dc:title>/) { + SONOS_Log $udn, 5, 'UTF8-Decode-Title1: '.$1; + my $text = $1; + eval { + $text = Encode::decode('UTF-8', $text, Encode::FB_CROAK); + }; + eval { + SONOS_Log $udn, 5, 'UTF8-Decode-Title2: '.$text; + $text = Encode::decode('UTF-8', $text, Encode::FB_CROAK); + }; + if ($@) { + SONOS_Log $udn, 5, 'UTF8-Decode: '.$@; + } + SONOS_Log $udn, 5, 'UTF8-Decode-Title3: '.$text; + SONOS_Client_Notifier('SetCurrent:Source:'.Encode::encode('UTF-8', $text)); + } # Current Trackdauer ermitteln if ($properties{LastChangeDecoded} =~ m//i) { SONOS_Client_Notifier('SetCurrent:TrackDuration:'.decode_entities($1)); + SONOS_Client_Notifier('SetCurrent:TrackDurationSec:'.SONOS_GetTimeSeconds(decode_entities($1))); $SONOS_BookmarkSpeicher{OldTrackDurations}{$udn} = SONOS_GetTimeSeconds(decode_entities($1)); } # Current Track Metadaten ermitteln my $currentTrackMetaData = decode_entities($1) if ($properties{LastChangeDecoded} =~ m//is); SONOS_Log $udn, 4, 'Transport-Event: CurrentTrackMetaData: '.$currentTrackMetaData; + SONOS_Client_Notifier('SetCurrent:TrackHandle:'.$currentTrackURI.'|'.$currentTrackMetaData); # Cover herunterladen (Infos dazu in den Track Metadaten) my $tempURIground = decode_entities($currentTrackMetaData); - $tempURIground =~ s/%25/%/ig; + #$tempURIground =~ s/%25/%/ig; my $tempURI = ''; $tempURI = ($1) if ($tempURIground =~ m/(.*?)<\/upnp:albumArtURI>/i); @@ -6507,9 +6837,6 @@ sub SONOS_TransportCallback($$) { if ($tempURI =~ m/^(http:\/\/.*?\/)(.*)/) { $groundURL = $1; $tempURI = $2; - } elsif ($tempURI =~ m/^x-sonosapi-stream:(.+?)\?/i) { - $groundURL = 'http://cdn-radiotime-logos.tunein.com/'; - $tempURI = $1.'g.png'; } SONOS_Client_Notifier('ProcessCover:'.$udn.':0:'.$tempURI.':'.$groundURL); @@ -6523,7 +6850,11 @@ sub SONOS_TransportCallback($$) { # Sender ermitteln (per SOAP-Request an den SonosPlayer) if ($service->controlProxy()->GetMediaInfo(0)->getValue('CurrentURIMetaData') =~ m/(.*?)<\/dc:title>/i) { SONOS_Client_Notifier('SetCurrent:Sender:'.$1); - SONOS_Client_Notifier('SetCurrent:TrackProvider:'.SONOS_GetTrackProvider($currentTrackURI, $1)); + + my ($trackProvider, $trackProviderRoundURL, $trackProviderQuadraticURL) = SONOS_GetTrackProvider($currentTrackURI, $1); + SONOS_Client_Notifier('SetCurrent:TrackProvider:'.$trackProvider); + SONOS_Client_Notifier('SetCurrent:TrackProviderIconRoundURL:'.$trackProviderRoundURL); + SONOS_Client_Notifier('SetCurrent:TrackProviderIconQuadraticURL:'.$trackProviderQuadraticURL); $SONOS_BookmarkSpeicher{OldTitles}{$udn} = $1; } @@ -6614,7 +6945,11 @@ sub SONOS_TransportCallback($$) { $SONOS_BookmarkSpeicher{OldTitles}{$udn} = '('.$currentArtist.') '.$currentTitle; } - SONOS_Client_Notifier('SetCurrent:TrackProvider:'.SONOS_GetTrackProvider($currentTrackURI, $currentTitle)); + my ($trackProvider, $trackProviderRoundURL, $trackProviderQuadraticURL) = SONOS_GetTrackProvider($currentTrackURI, $currentTitle); + SONOS_Client_Notifier('SetCurrent:TrackProvider:'.$trackProvider); + SONOS_Client_Notifier('SetCurrent:TrackProviderIconRoundURL:'.$trackProviderRoundURL); + SONOS_Client_Notifier('SetCurrent:TrackProviderIconQuadraticURL:'.$trackProviderQuadraticURL); + $SONOS_BookmarkSpeicher{OldTitles}{$udn} = $1; # Original Tracknumber ermitteln SONOS_Client_Notifier('SetCurrent:OriginalTrackNumber:'.decode_entities($1)) if ($currentTrackMetaData =~ m/(.*?)<\/upnp:originalTrackNumber>/i); @@ -6630,16 +6965,23 @@ sub SONOS_TransportCallback($$) { SONOS_Log $udn, 4, 'Transport-Event: NextTrackMetaData: '.$nextTrackMetaData; SONOS_Client_Notifier('SetCurrent:nextTrackDuration:'.decode_entities($1)) if ($nextTrackMetaData =~ m//i); + SONOS_Client_Notifier('SetCurrent:nextTrackDurationSec:'.SONOS_GetTimeSeconds(decode_entities($1))) if ($nextTrackMetaData =~ m//i); if ($properties{LastChangeDecoded} =~ m//i) { my $tmp = SONOS_GetURIFromQueueValue($1); $tmp =~ s/'/'/gi; SONOS_Client_Notifier('SetCurrent:nextTrackURI:'.$tmp); - SONOS_Client_Notifier('SetCurrent:nextTrackProvider:'.SONOS_GetTrackProvider($tmp)); + SONOS_Client_Notifier('SetCurrent:nextTrackHandle:'.$tmp.'|'.$nextTrackMetaData); + + my ($trackProvider, $trackProviderRoundURL, $trackProviderQuadraticURL) = SONOS_GetTrackProvider($tmp); + SONOS_Client_Notifier('SetCurrent:nextTrackProvider:'.$trackProvider); + SONOS_Client_Notifier('SetCurrent:nextTrackProviderIconRoundURL:'.$trackProviderRoundURL); + SONOS_Client_Notifier('SetCurrent:nextTrackProviderIconQuadraticURL:'.$trackProviderQuadraticURL); + } $tempURIground = decode_entities($nextTrackMetaData); - $tempURIground =~ s/%25/%/ig; + #$tempURIground =~ s/%25/%/ig; $tempURI = ''; $tempURI = ($1) if ($tempURIground =~ m/(.*?)<\/upnp:albumArtURI>/i); @@ -8307,6 +8649,157 @@ sub SONOS_AudioInCallback($$) { return 0; } +######################################################################################## +# +# SONOS_MusicServicesCallback - MusicServices-Callback, +# +# Parameter $service = Service-Representing Object +# $properties = Properties, that have been changed in this event +# +######################################################################################## +sub SONOS_MusicServicesCallback($$) { + my ($service, %properties) = @_; + + my $udn = $SONOS_Locations{$service->base}; + my $udnShort = $1 if ($udn =~ m/(.*?)_MR/i); + + if (!$udn) { + SONOS_Log undef, 1, 'MusicServices-Event receive error: SonosPlayer not found; Searching for \''.$service->base.'\'!'; + return; + } + + my $name = SONOS_Client_Data_Retreive($udn, 'def', 'NAME', $udn); + + # If the Device is disabled, return here... + if (SONOS_Client_Data_Retreive($udn, 'attr', 'disable', 0) == 1) { + SONOS_Log $udn, 3, "MusicServices-Event: device '$name' disabled. No Events/Data will be processed!"; + return; + } + + SONOS_Log $udn, 3, 'Event: Received MusicServices-Event for Zone "'.$name.'".'; + $SONOS_Client_SendQueue_Suspend = 1; + + # Check if the correct ServiceType + if ($service->serviceType() ne 'urn:schemas-upnp-org:service:MusicServices:1') { + SONOS_Log $udn, 1, 'MusicServices-Event receive error: Wrong Servicetype, was \''.$service->serviceType().'\'!'; + return; + } + + SONOS_Log $udn, 4, "MusicServices-Event: All correct with this service-call till now. UDN='uuid:".$udn."'"; + + SONOS_Client_Notifier('ReadingsBeginUpdate:undef'); + + # ServiceListVersion wurde angepasst? + my $serviceListVersion = SONOS_Client_Data_Retreive('undef', 'reading', 'MusicServicesListVersion', ''); + if (defined($properties{ServiceListVersion}) && $properties{ServiceListVersion} ne '') { + if ($serviceListVersion ne $properties{ServiceListVersion}) { + SONOS_Client_Data_Refresh('ReadingsBulkUpdateIfChanged', 'undef', 'MusicServicesListVersion', $properties{ServiceListVersion}); + + # Call MusicServiceProxy... + my $response = $SONOS_MusicServicesProxy{$udn}->ListAvailableServices(); + + # ServiceTypes + my @serviceTypes = split(',', $response->getValue('AvailableServiceTypeList')); + my $servicepos = 0; + + my %musicServices = (); + my $result = $response->getValue('AvailableServiceDescriptorList'); + SONOS_Log undef, 5, 'MusicService-Call: '.$result; + while ($result =~ m/.*?.*?.*?<\/Service>/sgi) { + my $id = $1; + my $name = $2; + my $smapi = $3; + my $capabilities = $4; + my $stringsURL = $5; + + my $presentationMap = $6; + + my $promoString = ''; + if (defined($stringsURL) && ($stringsURL ne '')) { + my $strings = encode('UTF-8', get($stringsURL)); + if (defined($strings) && ($strings ne '')) { + $promoString = $1 if ($strings =~ m/.*?(.*?)<\/string>.*?<\/stringtable>/si); + $promoString = $1 if (($promoString eq '') && ($strings =~ m/.*?(.*?)<\/string>.*?<\/stringtable>/si)); + } + } + + my $presentationMapData = encode('UTF-8', get($presentationMap)); + if (defined($presentationMapData)) { + SONOS_Log undef, 5, 'PresentationMap('.$id.' ~ '.$name.'): '.$presentationMapData; + } else { + SONOS_Log undef, 5, 'PresentationMap('.$id.' ~ '.$name.'): undef'; + } + + my ($resolution, $resolutionSubst) = SONOS_ExtractMaxResolution($presentationMapData, 'ArtWorkSizeMap'); + if (!defined($resolution)) { + ($resolution, $resolutionSubst) = SONOS_ExtractMaxResolution($presentationMapData, 'BrowseIconSizeMap'); + } + + #SONOS_GetMediaMetadata($udn, $id, + + $musicServices{$id}{Name} = $name; + $musicServices{$id}{ServiceType} = $serviceTypes[$servicepos++]; + + $musicServices{$id}{IconQuadraticURL} = 'http://sonos-logo.ws.sonos.com/'.$musicServices{$id}{ServiceType}.'/'.$musicServices{$id}{ServiceType}.'-400x400.png'; + $musicServices{$id}{IconQuadraticURL} = '/fhem/sonos/proxy/aa?url='.SONOS_URI_Escape($musicServices{$id}{IconQuadraticURL}) if (SONOS_Client_Data_Retreive('undef', 'attr', 'generateProxyAlbumArtURLs', 0)); + + $musicServices{$id}{IconRoundURL} = 'http://sonos-logo.ws.sonos.com/'.$musicServices{$id}{ServiceType}.'/'.$musicServices{$id}{ServiceType}.'-72x72.png'; + $musicServices{$id}{IconRoundURL} = '/fhem/sonos/proxy/aa?url='.SONOS_URI_Escape($musicServices{$id}{IconRoundURL}) if (SONOS_Client_Data_Retreive('undef', 'attr', 'generateProxyAlbumArtURLs', 0)); + + $musicServices{$id}{SMAPI} = $smapi; + $musicServices{$id}{Resolution} = $resolution; + $musicServices{$id}{ResolutionSubstitution} = $resolutionSubst; + $musicServices{$id}{Capabilities} = $capabilities; + + $promoString =~ s/[\r\n]/ /g; + $musicServices{$id}{PromoText} = $promoString; + } + + SONOS_Log undef, 5, 'MusicService-List: '.SONOS_Dumper(\%musicServices); + SONOS_Client_Data_Refresh('ReadingsBulkUpdateIfChanged', 'undef', 'MusicServicesList', SONOS_Dumper(\%musicServices)); + } + } + + SONOS_Client_Notifier('ReadingsEndUpdate:undef'); + + $SONOS_Client_SendQueue_Suspend = 0; + SONOS_Log $udn, 3, 'Event: End of MusicServices-Event for Zone "'.$name.'".'; + + # Prüfen, ob der Player auf 'disappeared' steht, und in diesem Fall den DiscoverProcess neu anstarten... + if (SONOS_Client_Data_Retreive($udn, 'reading', 'presence', 'disappeared') eq 'disappeared') { + SONOS_Log $udn, 1, "MusicServices-Event: device '$name' is marked as disappeared. Restarting discovery-process!"; + + SONOS_RestartControlPoint(); + } + + return 0; +} + +######################################################################################## +# +# SONOS_ExtractMaxResolution - Extracts the available Coversizes +# +######################################################################################## +sub SONOS_ExtractMaxResolution($$) { + my ($map, $area) = @_; + return (undef, undef) if (!defined($map)); + + + my $artworkSizeMap = $1 if ($map =~ m/.*?.*?(.*?)<\/imageSizeMap>.*?<\/Match>.*?<\/PresentationMap>/is); + return (undef, undef) if (!defined($artworkSizeMap)); + + my @resolutions = (); + while ($artworkSizeMap =~ m/.*?/gis) { + push(@resolutions, $1); + } + @resolutions = sort {$b <=> $a} @resolutions; + my $resolution = $resolutions[0]; + + my $resolutionSubst = $1 if ($artworkSizeMap =~ m/.*?/gis); + + return ($resolution, $resolutionSubst); +} + ######################################################################################## # # SONOS_replaceSpecialStringCharacters - Replaces invalid Characters in Strings (like ") for FHEM-internal @@ -8621,6 +9114,121 @@ sub SONOS_DownloadReplaceIfChanged($$) { } } +######################################################################################## +# +# SONOS_GetRadioMediaMetadata - Read the Radio-Metadata from the Sonos-Webservice +# +######################################################################################## +sub SONOS_GetRadioMediaMetadata($$) { + my ($udn, $id) = @_; + my $udnKey = "$1-$2-$3-$4-$5-$6:D" if ($udn =~ m/RINCON_([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})01400(_MR|)/); + + return '' if (!defined($udnKey)); + + my $ua = LWP::UserAgent->new(agent => $SONOS_USERAGENT); + my $response = $ua->request(POST 'http://legato.radiotime.com/Radio.asmx', 'content-type' => 'text/xml; charset="utf-8"', + Content => " + + + + $udnKey + Sonos + + + + + $id + + + "); + SONOS_Log $udn, 5, 'Radioservice-Metadata: '.$response->content; + + my $title = $1 if ($response->content =~ m/(.*?)<\/title>/i); + my $genreId = $1 if ($response->content =~ m/<genreId>(.*?)<\/genreId>/i); + my $genre = $1 if ($response->content =~ m/<genre>(.*?)<\/genre>/i); + my $bitrate = $1 if ($response->content =~ m/<bitrate>(.*?)<\/bitrate>/i); + my $logo = $1 if ($response->content =~ m/<logo>(.*?)<\/logo>/i); $logo =~ s/(.*?)q(\..*)/$1g$2/; + my $subtitle = $1 if ($response->content =~ m/<subtitle>(.*?)<\/subtitle>/i); + + return $logo; +} + +######################################################################################## +# +# SONOS_GetMediaMetadata - Read the Media-Metadata from the Sonos-Webservice +# +######################################################################################## +sub SONOS_GetMediaMetadata($$$) { + my ($udn, $sid, $id) = @_; + my $udnKey = "$1-$2-$3-$4-$5-$6:D" if ($udn =~ m/RINCON_([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})01400(_MR|)/); + + # Shorthand to special handling for TuneIn-Artwork + return SONOS_GetRadioMediaMetadata($udn, $id) if ($sid == 254); + + # Normal Artwork... + $udn =~ s/RINCON_(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)01400(_MR|)/$1-$2-$3-$4-$5-$6:D/; + + my $musicServicesList = SONOS_Client_Data_Retreive($udn, 'reading', 'MusicServicesList', '()'); + return '' if (!$musicServicesList); + my %musicService = %{eval($musicServicesList)->{$sid}}; + + my $url = $musicService{SMAPI}; + if ($url) { + my $ua = LWP::UserAgent->new(agent => $SONOS_USERAGENT); + my $response = $ua->request(POST $url, 'content-type' => 'text/xml; charset="utf-8"', + Content => "<?xml version=\"1.0\" encoding=\"utf-8\"?> + <s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"> + <s:Header> + <credentials xmlns=\"http://www.sonos.com/Services/1.1\"> + <deviceId>$udnKey</deviceId> + <deviceProvider>Sonos</deviceProvider> + </credentials> + </s:Header> + <s:Body> + <getMediaMetadata xmlns=\"http://www.sonos.com/Services/1.1\"> + <id>$id</id> + </getMediaMetadata> + </s:Body> + </s:Envelope>"); + SONOS_Log $udn, 0, 'MediaMetadata: '.$response->content; + + my $title = $1 if ($response->content =~ m/<title>(.*?)<\/title>/i); + my $genreId = $1 if ($response->content =~ m/<genreId>(.*?)<\/genreId>/i); + my $genre = $1 if ($response->content =~ m/<genre>(.*?)<\/genre>/i); + my $bitrate = $1 if ($response->content =~ m/<bitrate>(.*?)<\/bitrate>/i); + + my $logo = $1 if ($response->content =~ m/<logo>(.*?)<\/logo>/i); + if ($musicService{ResolutionSubstitution}) { + $logo =~ s//$musicService{ResolutionSubstitution}/; + } + + my $subtitle = $1 if ($response->content =~ m/<subtitle>(.*?)<\/subtitle>/i); + + return $logo; + } else { + return ''; + } +} + +######################################################################################## +# +# SONOS_ReadURL - Read the content of the given URL +# +# Parameter $url = The url, that has to be read +# +######################################################################################## +sub SONOS_ReadURL($) { + my ($url) = @_; + + my $ua = LWP::UserAgent->new(agent => $SONOS_USERAGENT); + my $response = $ua->get($url); + if ($response->is_success) { + return $response->content; + } + + return undef; +} + ######################################################################################## # # SONOS_ReadFile - Read the content of the given filename @@ -8752,7 +9360,7 @@ sub SONOS_getSonosPlayerByName(;$) { } } - SONOS_Log undef, 0, "The Method 'SONOS_getSonosPlayerByName' cannot find the FHEM-Device according to '$devicename'. This should not happen!"; + SONOS_Log undef, 0, "The Method 'SONOS_getSonosPlayerByName' cannot find the FHEM-Device according to '".(defined($devicename) ? $devicename : 'undef')."'. This should not happen!"; return undef; } @@ -8773,7 +9381,7 @@ sub SONOS_getSonosPlayerByUDN(;$) { return SONOS_getSonosPlayerByName(); } - SONOS_Log undef, 0, "The Method 'SONOS_getSonosPlayerByUDN' cannot find the FHEM-Device according to '$udn'. This should not happen!"; + SONOS_Log $udn, 0, "The Method 'SONOS_getSonosPlayerByUDN' cannot find the FHEM-Device according to '".(defined($udn) ? $udn : 'undef')."'. This should not happen!"; return undef; } @@ -8790,7 +9398,7 @@ sub SONOS_getSonosPlayerByRoomName($) { return $main::defs{$fhem_dev} if($main::defs{$fhem_dev}{TYPE} eq 'SONOSPLAYER' && $main::defs{$fhem_dev}{READINGS}{roomName}{VAL} eq $roomName); } - SONOS_Log undef, 0, "The Method 'SONOS_getSonosPlayerByRoomName' cannot find the FHEM-Device according to '$roomName'. This should not happen!"; + SONOS_Log undef, 0, "The Method 'SONOS_getSonosPlayerByRoomName' cannot find the FHEM-Device according to '".(defined($roomName) ? $roomName : 'undef')."'. This should not happen!"; return undef; } @@ -8975,7 +9583,7 @@ sub SONOS_GetTimeFromString($) { eval { use Time::Local; if($timeStr =~ m/^(\d{4})-(\d{2})-(\d{2})( |_)([0-2]\d):([0-5]\d):([0-5]\d)$/) { - return timelocal($6, $5, $4, $3, $2 - 1, $1 - 1900); + return timelocal($7, $6, $5, $3, $2 - 1, $1 - 1900); } } } @@ -9100,9 +9708,6 @@ if (defined($SONOS_ListenPort)) { while ($runEndlessLoop) { # NormalQueueWorking wird für die Dauer einer Direkt-Wert-Anfrage deaktiviert, damit hier nicht blockiert und/oder zuviel weggelesen wird. if ($SONOS_Client_NormalQueueWorking) { - # Falls wir hier auf eine Antwort reagieren würden, die gar nicht hierfür bestimmt ist, dann übergehen... - next if (!$SONOS_Client_NormalQueueWorking); - # Nachschauen, ob Subscriptions erneuert werden müssen if (time() - $lastRenewSubscriptionCheckTime > 1800) { $lastRenewSubscriptionCheckTime = time (); @@ -9153,13 +9758,15 @@ if (defined($SONOS_ListenPort)) { SONOS_Log undef, 3, "Connection accepted from $name:$port"; - # Von dort kommt die Anfrage, dort finde ich den Telnet-Port von Fhem :-) - $SONOS_UseTelnetForQuestions_Host = $name; - # Send Welcome-Message - send($client, "'This is UPnP-Server calling'\r\n", 0); + send($client, "This is UPnP-Server listening for commands\r\n", 0); - $SONOS_Client_Selector->add($client); + # Antwort lesen, und nur wenn es eine dauerhaft gedachte Verbindung ist, dann auch merken... + my $answer = ''; + recv($client, $answer, 500, 0); + if ($answer eq "Establish connection\r\n") { + $SONOS_Client_Selector->add($client); + } } else { # Existing client calling if (!$so->opened()) { $SONOS_Client_Selector->remove($so); @@ -9180,7 +9787,7 @@ if (defined($SONOS_ListenPort)) { } } else { # Wenn die Verarbeitung gerade unterbrochen sein soll, dann hier etwas warten, um keine 100% CPU-Last zu erzeugen - select(undef, undef, undef, 0.5); + select(undef, undef, undef, 0.2); } } @@ -9734,6 +10341,8 @@ The order in the sublists are important, because the first entry defines the so- <li><b>Common</b><ul> <li><a name="SONOS_attribut_coverLoadTimeout"><b><code>coverLoadTimeout <value></code></b> </a><br />One of (0..10,15,20,25,30). Defines the timeout for waiting of the Sonosplayer for Cover-Downloads. Defaults to 5.</li> +<li><a name="SONOS_attribut_deviceRoomView"><b><code>deviceRoomView <Both|DeviceLineOnly></code></b> +</a><br /> Defines the style of the Device in the room overview. <code>Both</code> means "normal" Deviceline incl. Cover-/Titleview and maybe the control area, <code>DeviceLineOnly</code> means only the "normal" Deviceline-view.</li> <li><a name="SONOS_attribut_disable"><b><code>disable <value></code></b> </a><br />One of (0,1). With this value you can disable the whole module. Works immediatly. If set to 1 the subprocess will be terminated and no message will be transmitted. If set to 0 the subprocess is again started.<br />It is useful when you install new Sonos-Components and don't want any disgusting devices during the Sonos setup.</li> <li><a name="SONOS_attribut_getListsDirectlyToReadings"><b><code>getListsDirectlyToReadings <value></code></b> @@ -9914,6 +10523,8 @@ Dabei ist die Reihenfolge innerhalb der Unterlisten wichtig, da der erste Eintra <li><b>Grundsätzliches</b><ul> <li><a name="SONOS_attribut_coverLoadTimeout"><b><code>coverLoadTimeout <value></code></b> </a><br />Eines von (0..10,15,20,25,30). Definiert den Timeout der für die Abfrage des Covers beim Sonosplayer verwendet wird. Wenn nicht angegeben, dann wird 5 verwendet.</li> +<li><a name="SONOS_attribut_deviceRoomView"><b><code>deviceRoomView <Both|DeviceLineOnly></code></b> +</a><br /> Gibt an, was in der Raumansicht zum Sonosplayer-Device angezeigt werden soll. <code>Both</code> bedeutet "normale" Devicezeile zzgl. Cover-/Titelanzeige und u.U. Steuerbereich, <code>DeviceLineOnly</code> bedeutet nur die Anzeige der "normalen" Devicezeile.</li> <li><a name="SONOS_attribut_disable"><b><code>disable <value></code></b> </a><br />Eines von (0,1). Hiermit kann das Modul abgeschaltet werden. Wirkt sofort. Bei 1 wird der SubProzess beendet, und somit keine weitere Verarbeitung durchgeführt. Bei 0 wird der Prozess wieder gestartet.<br />Damit kann das Modul temporär abgeschaltet werden, um bei der Neueinrichtung von Sonos-Komponenten keine halben Zustände mitzubekommen.</li> <li><a name="SONOS_attribut_getListsDirectlyToReadings"><b><code>getListsDirectlyToReadings <value></code></b> diff --git a/fhem/FHEM/21_SONOSPLAYER.pm b/fhem/FHEM/21_SONOSPLAYER.pm index 7e128029a..2cfe3438d 100755 --- a/fhem/FHEM/21_SONOSPLAYER.pm +++ b/fhem/FHEM/21_SONOSPLAYER.pm @@ -1,6 +1,6 @@ ######################################################################################## # -# SONOSPLAYER.pm (c) by Reiner Leins, April 2017 +# SONOSPLAYER.pm (c) by Reiner Leins, Mai 2017 # rleins at lmsoft dot de # # $Id$ @@ -58,6 +58,13 @@ sub Log($$); sub Log3($$$); sub SONOSPLAYER_Log($$$); + +######################################################## +# Standards aus FHEM einbinden +######################################################## +use vars qw{%modules %defs}; + + ######################################################################################## # Variable Definitions ######################################################################################## @@ -99,6 +106,8 @@ my %sets = ( 'StartRadio' => 'radioname', 'PlayURI' => 'songURI [Volume]', 'PlayURITemp' => 'songURI [Volume]', + 'LoadHandle' => 'Handle', + 'StartHandle' => 'Handle', 'AddURIToQueue' => 'songURI', 'Speak' => 'volume(0..100) language text', 'OutputFixed' => 'state', @@ -178,8 +187,13 @@ sub SONOSPLAYER_Initialize ($) { $hash->{SetFn} = "SONOSPLAYER_Set"; $hash->{StateFn} = "SONOSPLAYER_State"; $hash->{AttrFn} = 'SONOSPLAYER_Attribute'; + $hash->{NotifyFn} = 'SONOSPLAYER_Notify'; - $hash->{AttrList} = "disable:1,0 generateVolumeSlider:1,0 generateVolumeEvent:1,0 generateSomethingChangedEvent:1,0 generateInfoSummarize1 generateInfoSummarize2 generateInfoSummarize3 generateInfoSummarize4 stateVariable:TransportState,NumberOfTracks,Track,TrackURI,TrackDuration,TrackProvider,Title,Artist,Album,OriginalTrackNumber,AlbumArtist,Sender,SenderCurrent,SenderInfo,StreamAudio,NormalAudio,AlbumArtURI,nextTrackDuration,nextTrackProvider,nextTrackURI,nextAlbumArtURI,nextTitle,nextArtist,nextAlbum,nextAlbumArtist,nextOriginalTrackNumber,Volume,Mute,OutputFixed,Shuffle,Repeat,CrossfadeMode,Balance,HeadphoneConnected,SleepTimer,Presence,RoomName,SaveRoomName,PlayerType,Location,SoftwareRevision,SerialNum,InfoSummarize1,InfoSummarize2,InfoSummarize3,InfoSummarize4 model minVolume maxVolume minVolumeHeadphone maxVolumeHeadphone VolumeStep getAlarms:1,0 buttonEvents getTitleInfoFromMaster:1,0 stopSleeptimerInAction:1,0 saveSleeptimerInAction:1,0 ".$readingFnAttributes; + $hash->{FW_detailFn} = 'SONOSPLAYER_Detail'; + $hash->{FW_deviceOverview} = 1; + #$hash->{FW_addDetailToSummary} = 1; + + $hash->{AttrList} = "disable:1,0 generateVolumeSlider:1,0 generateVolumeEvent:1,0 generateSomethingChangedEvent:1,0 generateInfoSummarize1 generateInfoSummarize2 generateInfoSummarize3 generateInfoSummarize4 stateVariable:TransportState,NumberOfTracks,Track,TrackURI,TrackDuration,TrackProvider,Title,Artist,Album,OriginalTrackNumber,AlbumArtist,Sender,SenderCurrent,SenderInfo,StreamAudio,NormalAudio,AlbumArtURI,nextTrackDuration,nextTrackProvider,nextTrackURI,nextAlbumArtURI,nextTitle,nextArtist,nextAlbum,nextAlbumArtist,nextOriginalTrackNumber,Volume,Mute,OutputFixed,Shuffle,Repeat,CrossfadeMode,Balance,HeadphoneConnected,SleepTimer,Presence,RoomName,SaveRoomName,PlayerType,Location,SoftwareRevision,SerialNum,InfoSummarize1,InfoSummarize2,InfoSummarize3,InfoSummarize4 model minVolume maxVolume minVolumeHeadphone maxVolumeHeadphone VolumeStep getAlarms:1,0 buttonEvents getTitleInfoFromMaster:1,0 stopSleeptimerInAction:1,0 saveSleeptimerInAction:1,0 simulateCurrentTrackPosition:0,1,2,3,4,5,6,7,8,9,10,15,20,25,30,45,60 simulateCurrentTrackPositionPercentFormat suppressControlButtons:1,0 ".$readingFnAttributes; return undef; } @@ -193,8 +207,6 @@ sub SONOSPLAYER_Initialize ($) { ######################################################################################## sub SONOSPLAYER_Define ($$) { my ($hash, $def) = @_; - - # $hash->{NotifyFn} = 'SONOSPLAYER_Notify'; # define <name> SONOSPLAYER <udn> # e.g.: define Sonos_Wohnzimmer SONOSPLAYER RINCON_000EFEFEFEF401400 @@ -209,15 +221,66 @@ sub SONOSPLAYER_Define ($$) { # check syntax return "SONOSPLAYER: Wrong syntax, must be define <name> SONOSPLAYER <udn>" if(int(@a) < 3); + $hash->{NOTIFYDEV} = $name; + $hash->{helper}->{simulateCurrentTrackPosition} = 0; + readingsSingleUpdate($hash, "state", 'init', 1); readingsSingleUpdate($hash, "presence", 'disappeared', 0); # Grund-Initialisierung, falls der Player sich nicht zurückmelden sollte... + # RoomDarstellung für alle Player festlegen + $modules{$hash->{TYPE}}->{FW_addDetailToSummary} = (AttrVal(SONOS_getSonosPlayerByName()->{NAME}, 'deviceRoomView', 'Both') =~ m/(Both)/i); + $hash->{UDN} = $udn; readingsSingleUpdate($hash, "state", 'initialized', 1); return undef; } +######################################################################################## +# +# SONOSPLAYER_Detail - Returns the Detailview +# +######################################################################################## +sub SONOSPLAYER_Detail($$$;$) { + my ($FW_wname, $d, $room, $withRC) = @_; + $withRC = 1 if (!defined($withRC)); + + my $hash = $defs{$d}; + + return '' if (!ReadingsVal($d, 'IsMaster', 0) || (ReadingsVal($d, 'playerType', '') eq 'ZB100')); + + # Open incl. Inform-Div + my $html .= '<html><div informId="'.$d.'-display_covertitle">'; + + # Cover-/TitleView + $html .= '<div style="border: 1px solid gray; border-radius: 10px; padding: 5px;">'; + $html .= SONOS_getCoverTitleRG($d); + $html .= '</div>'; + + # Close Inform-Div + $html .= '</div>'; + + # Control-Buttons + if (!AttrVal($d, 'suppressControlButtons', 0) && ($withRC)) { + $html.= '<div class="rc_body" style="border: 1px solid gray; border-radius: 10px; padding: 5px;">'; + $html .= '<table style="text-align: center;"><tr>'; + $html .= '<td><a onclick="FW_cmd(\'?XHR=1&cmd.dummy=set '.$d.' Previous\')">'.FW_makeImage('rc_PREVIOUS.svg', 'Previous', 'rc-button').'</a></td> + <td><a style="padding-left: 10px;" onclick="FW_cmd(\'?XHR=1&cmd.dummy=set '.$d.' Play\')">'.FW_makeImage('rc_PLAY.svg', 'Play', 'rc-button').'</a></td> + <td><a onclick="FW_cmd(\'?XHR=1&cmd.dummy=set '.$d.' Pause\')">'.FW_makeImage('rc_PAUSE.svg', 'Pause', 'rc-button').'</a></td> + <td><a style="padding-left: 10px;" onclick="FW_cmd(\'?XHR=1&cmd.dummy=set '.$d.' Next\')">'.FW_makeImage('rc_NEXT.svg', 'Next', 'rc-button').'</a></td> + <td><a style="padding-left: 20px;" onclick="FW_cmd(\'?XHR=1&cmd.dummy=set '.$d.' VolumeD\')">'.FW_makeImage('rc_VOLDOWN.svg', 'VolDown', 'rc-button').'</a></td> + <td><a onclick="FW_cmd(\'?XHR=1&cmd.dummy=set '.$d.' MuteT\')">'.FW_makeImage('rc_MUTE.svg', 'Mute', 'rc-button').'</a></td> + <td><a onclick="FW_cmd(\'?XHR=1&cmd.dummy=set '.$d.' VolumeU\')">'.FW_makeImage('rc_VOLUP.svg', 'VolUp', 'rc-button').'</a></td>'; + $html .= '</tr></table>'; + $html .= '</div>'; + } + + # Close + $html .= '</html>'; + + return $html; +} + ######################################################################################## # # SONOSPLAYER_Attribute - Implements AttrFn function @@ -262,7 +325,6 @@ sub SONOSPLAYER_State($$$$) { my ($hash, $time, $name, $value) = @_; # Die folgenden Readings müssen immer neu initialisiert verwendet werden, und dürfen nicht aus dem Statefile verwendet werden - #return 'Reading '.$hash->{NAME}."->$name must not be used out of statefile. This is not an error! This happens due to restrictions of Fhem." if ($name eq 'presence') || ($name eq 'LastActionResult') || ($name eq 'AlarmList') || ($name eq 'AlarmListIDs') || ($name eq 'AlarmListVersion'); if (($name eq 'presence') || ($name eq 'LastActionResult') || ($name eq 'AlarmList') || ($name eq 'AlarmListIDs') || ($name eq 'AlarmListVersion')) { SONOSPLAYER_Log undef, 4, 'StateFn-Call. Ignore the following Reading: '.$hash->{NAME}.'->'.$name.'('.(defined($value) ? $value : '').')'; @@ -280,24 +342,62 @@ sub SONOSPLAYER_State($$$$) { # SONOSPLAYER_Notify - Implements NotifyFn function # ######################################################################################## -sub SONOSPLAYER_Notify() { +sub SONOSPLAYER_Notify($$) { my ($hash, $notifyhash) = @_; - return undef; + my $events = deviceEvents($notifyhash, 1); + return if(!$events); - # Das folgende habe ich erstmal wieder entfernt, da man ja öfter im laufenden Betrieb die Einstellungen speichert, und den Sonos-Komponenten dann immer wichtige Informationen für den Betrieb fehlen (nicht jedes Save wird vor dem Neustart von Fhem ausgeführt) - #if (($notifyhash->{NAME} eq 'global') && (($notifyhash->{CHANGED}[0] eq 'SAVE') || ($notifyhash->{CHANGED}[0] eq 'SHUTDOWN'))) { - # SONOSPLAYER_Log undef, 3, $hash->{NAME}.' has detected a global:'.$notifyhash->{CHANGED}[0].'-Event. Clear out some readings before...'; - # - # # Einige Readings niemals speichern - # delete($defs{$hash->{NAME}}{READINGS}{presence}); - # delete($defs{$hash->{NAME}}{READINGS}{LastActionResult}); - # delete($defs{$hash->{NAME}}{READINGS}{AlarmList}); - # delete($defs{$hash->{NAME}}{READINGS}{AlarmListIDs}); - # delete($defs{$hash->{NAME}}{READINGS}{AlarmListVersion}); - #} - # - #return undef; + foreach my $event (@{$events}) { + next if(!defined($event)); + + if ($event =~ m/transportState: (.+)/i) { + SONOSPLAYER_Log $hash->{NAME}, 5, 'Notify: '.$event; + if ($1 eq 'PLAYING') { + $hash->{helper}->{simulateCurrentTrackPosition} = AttrVal($hash->{NAME}, 'simulateCurrentTrackPosition', 0); + + # Wiederholungskette für die Aktualisierung sofort anstarten... + InternalTimer(gettimeofday(), 'SONOSPLAYER_SimulateCurrentTrackPosition', $hash, 0); + } else { + $hash->{helper}->{simulateCurrentTrackPosition} = 0; + + # Einmal noch etwas später aktualisieren... + InternalTimer(gettimeofday() + 0.1, 'SONOSPLAYER_SimulateCurrentTrackPosition', $hash, 0); + } + } + } + + return undef; +} + +######################################################################################## +# +# SONOSPLAYER_SimulateCurrentTrackPosition - Implements the Simulation for the currentTrackPosition +# +######################################################################################## +sub SONOSPLAYER_SimulateCurrentTrackPosition() { + my ($hash) = @_; + + return undef if (AttrVal($hash->{NAME}, 'disable', 0)); + + readingsBeginUpdate($hash); + + my $trackDurationSec = SONOS_GetTimeSeconds(ReadingsVal($hash->{NAME}, 'currentTrackDuration', 0)) - 1; + my $trackPositionSec = time - SONOS_GetTimeFromString(ReadingsTimestamp($hash->{NAME}, 'currentTrackPositionSec', 0)) + ReadingsVal($hash->{NAME}, 'currentTrackPositionSec', 0); + readingsBulkUpdate($hash, 'currentTrackPositionSimulated', SONOS_ConvertSecondsToTime($trackPositionSec)); + readingsBulkUpdate($hash, 'currentTrackPositionSimulatedSec', $trackPositionSec); + + if ($trackDurationSec) { + readingsBulkUpdateIfChanged($hash, 'currentTrackPositionSimulatedPercent', sprintf(AttrVal($hash->{NAME}, 'simulateCurrentTrackPositionPercentFormat', '%.1f'), 100 * $trackPositionSec / $trackDurationSec)); + } else { + readingsBulkUpdateIfChanged($hash, 'currentTrackPositionSimulatedPercent', sprintf(AttrVal($hash->{NAME}, 'simulateCurrentTrackPositionPercentFormat', '%.1f'), 0.0)); + } + + readingsEndUpdate($hash, 1); + + if ($hash->{helper}->{simulateCurrentTrackPosition}) { + InternalTimer(gettimeofday() + $hash->{helper}->{simulateCurrentTrackPosition}, 'SONOSPLAYER_SimulateCurrentTrackPosition', $hash, 0); + } } ######################################################################################## @@ -786,7 +886,19 @@ sub SONOSPLAYER_Set($@) { $hash = SONOSPLAYER_GetRealTargetPlayerHash($hash); $udn = $hash->{UDN}; - SONOS_DoWork($udn, 'playURITemp', $value, $value2); + SONOS_DoWork($udn, 'playURITemp', $value, $value2); + } elsif ($key =~ m/(start|load)handle/i) { + $hash = SONOSPLAYER_GetRealTargetPlayerHash($hash); + $udn = $hash->{UDN}; + + # Hier die komplette restliche Zeile in den Text-Parameter packen, da damit auch Leerzeichen möglich sind + my $text = ''; + for(my $i = 2; $i < @a; $i++) { + $text .= ' ' if ($i > 2); + $text .= $a[$i]; + } + + SONOS_DoWork($udn, 'startHandle', $text, (lc($key) eq 'loadhandle')); } elsif (lc($key) eq 'adduritoqueue') { $hash = SONOSPLAYER_GetRealTargetPlayerHash($hash); $udn = $hash->{UDN}; @@ -1453,6 +1565,8 @@ sub SONOSPLAYER_Log($$$) { </a><br /> One of (0,1). Enables a slider for volumecontrol in detail view.</li> <li><a name="SONOSPLAYER_attribut_getAlarms"><b><code>getAlarms <int></code></b> </a><br /> One of (0..1). Initializes a callback-method for Alarms. This included the information of the DailyIndexRefreshTime.</li> +<li><a name="SONOSPLAYER_attribut_suppressControlButtons"><b><code>suppressControlButtons <int></code></b> +</a><br /> One of (0,1). Enables the control-section shown under the Cover-/Titleview.</li> <li><a name="SONOSPLAYER_attribut_volumeStep"><b><code>volumeStep <int></code></b> </a><br /> One of (0..100). Defines the stepwidth for subsequent calls of <code>VolumeU</code> and <code>VolumeD</code>.</li> </ul></li> @@ -1467,6 +1581,10 @@ sub SONOSPLAYER_Log($$$) { </a><br /> Generates the reading 'InfoSummarize4' with the given format. More Information on this in the examples-section.</li> <li><a name="SONOSPLAYER_attribut_getTitleInfoFromMaster"><b><code>getTitleInfoFromMaster <int></code></b> </a><br /> One of (0, 1). Gets the current Playing-Informations from the Masterplayer (if one is present).</li> +<li><a name="SONOSPLAYER_attribut_simulateCurrentTrackPosition"><b><code>simulateCurrentTrackPosition <int></code></b> +</a><br /> One of (0,1,2,3,4,5,6,7,8,9,10,15,20,25,30,45,60). Starts an internal Timer which refreshs the current trackposition into the Readings <code>currentTrackPositionSimulated</code> and <code>currentTrackPositionSimulatedSec</code>. At the same time the Reading <code>currentTrackPositionSimulatedPercent</code> (between 0.0 and 100.0) will also be refreshed.</li> +<li><a name="SONOSPLAYER_attribut_simulateCurrentTrackPositionPercentFormat"><b><code>simulateCurrentTrackPositionPercentFormat <Format></code></b> +</a><br /> Defines the format of the percentformat in the Reading <code>currentTrackPositionSimulatedPercent</code>.</li> <li><a name="SONOSPLAYER_attribut_stateVariable"><b><code>stateVariable <string></code></b> </a><br /> One of (TransportState,NumberOfTracks,Track,TrackURI,TrackDuration,Title,Artist,Album,OriginalTrackNumber,AlbumArtist,<br />Sender,SenderCurrent,SenderInfo,StreamAudio,NormalAudio,AlbumArtURI,nextTrackDuration,nextTrackURI,nextAlbumArtURI,<br />nextTitle,nextArtist,nextAlbum,nextAlbumArtist,nextOriginalTrackNumber,Volume,Mute,Shuffle,Repeat,RepeatOne,CrossfadeMode,Balance,<br />HeadphoneConnected,SleepTimer,Presence,RoomName,SaveRoomName,PlayerType,Location,SoftwareRevision,SerialNum,InfoSummarize1,<br />InfoSummarize2,InfoSummarize3,InfoSummarize4). Defines, which variable has to be copied to the content of the state-variable.</li> </ul></li> @@ -1828,6 +1946,9 @@ Here an event is defined, where in time of 2 seconds the Mute-Button has to be p </a><br /> One of (0,1). Aktiviert einen Slider für die Lautstärkekontrolle in der Detailansicht.</li> <li><a name="SONOSPLAYER_attribut_getAlarms"><b><code>getAlarms <int></code></b> </a><br /> One of (0..1). Richtet eine Callback-Methode für Alarme ein. Damit wird auch die DailyIndexRefreshTime automatisch aktualisiert.</li> +<li><a name="SONOSPLAYER_attribut_suppressControlButtons"><b><code>suppressControlButtons <int></code></b> +</a><br /> One of (0,1). Gibt an, ob die Steuerbuttons unter der Cover-/Titelanzeige angezeigt werden sollen (=1) oder nicht (=0).</li> +</ul></li> <li><a name="SONOSPLAYER_attribut_volumeStep"><b><code>volumeStep <int></code></b> </a><br /> One of (0..100). Definiert die Schrittweite für die Aufrufe von <code>VolumeU</code> und <code>VolumeD</code>.</li> </ul></li> @@ -1841,7 +1962,11 @@ Here an event is defined, where in time of 2 seconds the Mute-Button has to be p <li><a name="SONOSPLAYER_attribut_generateInfoSummarize4"><b><code>generateInfoSummarize4 <string></code></b> </a><br /> Erzeugt das Reading 'InfoSummarize4' mit dem angegebenen Format. Mehr Informationen dazu im Bereich Beispiele.</li> <li><a name="SONOSPLAYER_attribut_getTitleInfoFromMaster"><b><code>getTitleInfoFromMaster <int></code></b> -</a><br /> Eins aus (0, 1). Bringt das Device dazu, seine aktuellen Abspielinformationen vom aktuellen Gruppenmaster zu holen, wenn es einen solchen gibt.</li> +</a><br /> Eins aus (0,1,2,3,4,5,6,7,8,9,10,15,20,25,30,45,60). Bringt das Device dazu, seine aktuellen Abspielinformationen vom aktuellen Gruppenmaster zu holen, wenn es einen solchen gibt.</li> +<li><a name="SONOSPLAYER_attribut_simulateCurrentTrackPosition"><b><code>simulateCurrentTrackPosition <int></code></b> +</a><br /> Eins aus (0, 1). Bringt das Device dazu, seine aktuelle Abspielposition simuliert weiterlaufen zu lassen. Dazu werden die Readings <code>currentTrackPositionSimulated</code> und <code>currentTrackPositionSimulatedSec</code> gesetzt. Gleichzeitig wird auch das Reading <code>currentTrackPositionSimulatedPercent</code> (zwischen 0.0 und 100.0) gesetzt.</li> +<li><a name="SONOSPLAYER_attribut_simulateCurrentTrackPositionPercentFormat"><b><code>simulateCurrentTrackPositionPercentFormat <Format></code></b> +</a><br /> Definiert das Format für die sprintf-Prozentausgabe im Reading <code>currentTrackPositionSimulatedPercent</code>.</li> <li><a name="SONOSPLAYER_attribut_stateVariable"><b><code>stateVariable <string></code></b> </a><br /> One of (TransportState,NumberOfTracks,Track,TrackURI,TrackDuration,Title,Artist,Album,OriginalTrackNumber,AlbumArtist,<br />Sender,SenderCurrent,SenderInfo,StreamAudio,NormalAudio,AlbumArtURI,nextTrackDuration,nextTrackURI,nextAlbumArtURI,<br />nextTitle,nextArtist,nextAlbum,nextAlbumArtist,nextOriginalTrackNumber,Volume,Mute,Shuffle,Repeat,RepeatOne,CrossfadeMode,Balance,<br />HeadphoneConnected,SleepTimer,Presence,RoomName,SaveRoomName,PlayerType,Location,SoftwareRevision,SerialNum,InfoSummarize1,I<br />nfoSummarize2,InfoSummarize3,InfoSummarize4). Gibt an, welche Variable in das Reading <code>state</code> kopiert werden soll.</li> </ul></li> diff --git a/fhem/FHEM/lib/UPnP/ControlPoint.pm b/fhem/FHEM/lib/UPnP/ControlPoint.pm index f2e35b2c7..a43c768e7 100644 --- a/fhem/FHEM/lib/UPnP/ControlPoint.pm +++ b/fhem/FHEM/lib/UPnP/ControlPoint.pm @@ -78,11 +78,25 @@ sub new { $self->{_subscriptionPort} = $self->{_subscriptionSocket}->sockport();; # Create the socket on which we'll listen for SSDP Notifications. - $self->{_ssdpMulticastSocket} = IO::Socket::INET->new( - Proto => 'udp', - Reuse => 1, - LocalPort => SSDP_PORT) || - croak("Error creating SSDP multicast listen socket: $!\n"); + # First try with ReusePort... + eval { + $self->{_ssdpMulticastSocket} = IO::Socket::INET->new( + Proto => 'udp', + Reuse => 1, + ReusePort => 1, + LocalPort => SSDP_PORT) || + carp("Error creating SSDP multicast listen socket: $!\n"); + }; + if ($@ =~ /Your vendor has not defined Socket macro SO_REUSEPORT/i) { + $self->{_ssdpMulticastSocket} = IO::Socket::INET->new( + Proto => 'udp', + Reuse => 1, + LocalPort => SSDP_PORT) || + carp("Error creating SSDP multicast listen socket: $!\n"); + } else { + # Weiterwerfen... + carp($@); + } my $ip_mreq = inet_aton(SSDP_IP) . INADDR_ANY; setsockopt($self->{_ssdpMulticastSocket}, IP_LEVEL, diff --git a/fhem/FHEM/lib/UPnP/sonos_bibliothek_quadratic.jpg b/fhem/FHEM/lib/UPnP/sonos_bibliothek_quadratic.jpg new file mode 100644 index 0000000000000000000000000000000000000000..becde3a65b6a012ed3004579784a7219e08ea917 GIT binary patch literal 2808 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 U0k0c4FOtUl;&s8Fgn&l=1zC(TMF0Q* literal 0 HcmV?d00001 diff --git a/fhem/FHEM/lib/UPnP/sonos_bibliothek_round.png b/fhem/FHEM/lib/UPnP/sonos_bibliothek_round.png new file mode 100644 index 0000000000000000000000000000000000000000..358ffe85a42a337e58c1152f84c95ea51f44fa94 GIT binary patch literal 2817 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 T^~g~@00000NkvXXu0mjf)4NaS literal 0 HcmV?d00001 diff --git a/fhem/FHEM/lib/UPnP/sonos_dock_quadratic.jpg b/fhem/FHEM/lib/UPnP/sonos_dock_quadratic.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f0eff0d91ac275088bdbdc3b0a6d7fbfbc2c13f9 GIT binary patch literal 5416 zcmds(c{J4T+sE%QqoI`SWSK%KTM1bjM#)y$35g2XBQcD~NJ;h(NwN!NXJ{EAdu1I< zcE%RM3}P_LGku?Pe$PM8AJ2Jy|NZXId4E3l`P}z)UH9vAopXII#!to+IDA>xKo>wD z5O9lm0md|N)xo$q17K(fqyYeI00ub%SeY{jQ<@Orf6a9vCjs=I_bkjs1;D%mE~bo_ z`a5nh_1ET~r^#(MFF$!B9gCy>it@_x%FIZkK>Kgpe{1}8@^3l#Ys_c>Tx_5nv_m1{ zfQ1VJ<$^Gp0fKpY*8ld+g!pGbSfDW0eQ-AR{RfyEaEAd41PWz=L0MU0Fy`)u%yj_c zV&y)jaA6;h>20{U5AWGW3E6BC7b_b1%z8<ZiVpX~*!S}b2nroJenRTxDQP9;b1JH8 z>e`odboKNNE}L6eUbC{cx$fw6$Jxcz&E3}z?H_;%eDL^5_|u4Ikx_|BFOpMUroKwc z$<51uTToc^uCl5cS5y1p<EO7p%`L5M?H%9x2>k<tLqCT{rinANbMp(o7MIBD8=HT& zwkbQif4Lw4`may+zjARgxmaK@C=C9W3&Ik>6qF0bdQ4#-_XSh<Z66--vya$#FD7JH zG_p%5nvwV%?)UEJmsFZQPX3Gb583|>EbRY9_Ajvi;Ti!PPzW=5P%eN1bo$AhDDWSp zF7;mfD^ii@gC3THp2x(yhIIP9JZ&0<OMmbvUA=$d)KjypY*;A!Y<lQ}!Jw_YF%7*1 z`v9N(@ox6@GLB1y-jtp6nft42$Cihd+l<o5eahCACGXT4QR~Ay9o^e?<P|Nk?;jq- zcf~gKhoFfJ;Lxv!7$I=pd7ePw$((mTSKH5<Kr6cMGp4rLoezS%T+6uDaSDWMb<kdy z1idK;2(g&qFQ`VWME_1_fbY%qR4a-JyvEdCxXNnfy*<}Y+FN_9gjwMLOXx_;FXZgQ z9P`N`%nCE?s17DWS({+}rHvMl!~jvKu2kKM4CSD{^Ug$Lau%m|4*rsSd7yeHNi!^v zb2%;crDaD~ZD3}=2dNFK13OrCuq`dq^wmRk2mQI&|Hn({=IM;;H5prEtov`;i5Hv$ zRa#=>I+z^oQV|AdUS0Spke)z!+PFoH)tg(M+!Rfs!?pnf{JQxP2ZA2*5wGy$jShWc zfbI1e28b?5MdLTh+Bvg=!cbOJSLjG#*`bt!_Y$h?@g1v)?E-|*=7hDS`+E6y6_w=u z`p54&uTLAX#TK9!+eqo_x)1gq^uQ}k?LB1E#>{oi9G=9dBg>A>2ul4pJ-XOpOTb&* zQ?66d`tk(%DPmN!8_mBzv+}yesO;3eOlkEKJu0^q`)8r`IZ-D;*@2!z=x#wZP)n5@ zsScE88>Z#n`_QV;gKk~j=?u!p+>Bs=Sv6wsPfD(J_ZMZEbgVSk)B-S|{var-?E_eA zhl{H2S`uVKp9_B!6&PAvv2c+(xHhz&#gU^g$)W{g0QIYx3}6l4i9&?^+@-%NR8c8g zXMp`6=sb&-1byeGalXor`wAJF(RAKEgJr|^Mr66M>i2S|=CG4akz<bd-qd~S`O9kG zb%eCoRrtX3=!{rdyjnN4GU{wuMw`!Er5?;YxaA;j-~~Au<HDf|6A3hK;@~?!Dt%!_ z6>*5_M9W8y8gsj69H4lOgd`v$%DiQRb%=YZvbX~@$7=+^#=^x6c!X(99YL;I?!>y< zZ{>I%4->nJx~k9Rgp$r57oAC_<XYnFhF&c(AZp+(X6pdPPk*~Z>57ui=pxxwMaA5@ zQoY(rp@k<ks(M)@yjKfljifZ}f9JbA*WDG!0GYu)S2rNb9}s!(cOPcPuY0UN3^^a{ z2nnO=kuQ5DT5K=?(a;CxEfOnSGQYy#HNS6zFPZPIP1xNrNi~;e)F4Vr2ZB3TPqt}I zI~FL2+$KgXH=P@*b=4L2(|<2`UPWKv$1Tpm<d*<gAy$NZDGZQ%#8KY>g*RRKa+?8a zi1jCVX4YD@h1kh5&9b98mu!Pj9QKMG_xn!9-rPIP?+WAY5dS?X%pY|z7*BY;6-B8( zA|yccD(+#sl@wUf^ETm9A2nWmgc71xwc#MWoO+roAw{d_>CY_0``0t{Q<B{e6T1XR zwDy&uNu>x!U*R=WbEWcEzTL|`o{lkMlqs<bWgQE&Y`UUCU|VyY+uf#b71bsf+i$5d zl`QeJ2xR7obLVdBPUM~4*De&X^@uXk$1Y54iwwIItJV@9?sdx&HXZzywDJ)xilB*z z%-*5zfsjvaXQe~sB}Fkd2jU8HSz{25`#9l}PTxvCu$2Dh)Ri69sKf53?obr>%qd|D zb~w^xi^dhhK_!|yrg(Er*w(t`cA=X(uCXOH<>OnM3of^@d3Kk2FV$YR%nPVd+?PfV z2<5t&bu?LU%JFga`)rA@4)NH;);2kjUizw&j4bvuI*Ywg;&~?nJXE3deylT|cR$#< zOs)T#Z>BHA0LUKbP>Rq30~E_RbU!zclITg89Oa9t(;KY0t2!Dtm9(ZMFq|5b&JAfi z%Cp=dAw}NxD?M>eW@e2I(h$_?W($Iw=v;L=Bn*B16jg)0+p{TIG-{Tf{8a+m(nxaO z-t({B#kg23YKhJ1@R~sExw5#GC;1)ePfKoL=XB@mCh&?{rZgO}DGob*YS1`jBzdwP z2mG6TGWLFAXFBP8nfLX_<F!1@UDvDP0#Ie!8I`5=(X?JIAv2!kAvKybpX8&=jZXd9 zg+)3`>ONIoV8ZcZPwu)T-onw{nj^6D;1B-Fs}bA2&*V!7Z%U(6)L6vZ<DP{ocR=H> z$J!*<mO{OB$8iQfyh_ub8$rd>9}?nIxzYetrLSyc&7fkVr<eg;a4B2tZEF$(Ik6!V z@_emMA8Q9#3DpGe7ZW?RF|A8B$Gr_G0Yo9C=rwMnKmAOK$M}J7nav2!&DXI(ZDNOf zg0}@iB+CiHMjzC1xE6f$!A*Yij^Mt)q5ZvMpA*(Hnf3WjChr6$-ke<j^-1|aiE>~m zP7d2Za5r&N@2tW3Pk3&B;m%l9%$-~us;aKt{vtA<EGlKWhaULq`m$z3zZ@4a??<uR z)B3e-GIpJMl+x%H=v9AwJ|TBp+gF&|j{Y6}_D0DMuFn;kSFL<m&5k9`|IW@Z;WW$W zwkHG|&%BO~E~L86yiu9P6+|P29tPx|Us54W9F!&wu5ULzD6;*G;&GkejN3C!y7WmP zVCZE^t8;8lnkwP-0Iu_9^CTxyrxeqP%Vt*N$4!GF`^cJAxnpjcN9)pTN598nFNfO| z<6hI`<@my9?7lfw_ueTt+%F-*-zglp7py^9_s;E)Z^Xuy6gCEK{Zi(digmaBd0HzG zg=@ra&3%abl~>ui(cB>;QEl|&BWw^Uu&1>=q;RE_DoNQW8E>wXwNjr9LmHOcu+1y2 z?BK(7WxpG%!z)%ok!O%TMn|09h!~zhOOEu8-aMoE1lNPt2qK@?RS(#5P9~n_T)cKY zr8aA<fn<yMte-jE8BQKRN+*3+P9;B0Ko=VL7Q4NSJ|6K*DYC>%$wU87bH7(UxkRG* zNc>~Eu-g}LQpGy%1o_1K987%knPH9j?CH<e)mPN;a!$QpGT$4%{WTJH;(bvk*3CJ( zXlW}xFZy{U5+2V0;b?O2Lfi`dFx6e9S4mf<faLY@;ExkM2BOj0pXO$3f&}eyDqP16 z)30{8-j4icNfU0IthRc(8Zw?0tVCJPnGn1_Vzb%JK}qW~STPz;sdXM%Rl---`t9kw zDQQSKAY9}nzakN~G(YcleHijfU>EoH4H5B_E;EEwlNA#u%ZC@+Q(j-`_4|DHv}Ia* zC;IlZw@_KQ+zOUx`QH8dEe3e<Xfu)+<9;QtDd?e-mvYc-W*j9vpve8)1V34BEvgu4 zlkKr35Sfih#!H=k{O6<G@74Uu{*^y8i0tOx<jP&~dCtnVKuOfk)reD&TI-8b2o`#e z!X)-CB79QBsJJq4!TKf}W#X>8L0I@kL4V@;*U_&&9K9kp)*X=~D`xW|e=V%uHC|w% z6I)1?qf8gO>)?yLag%V$+f(8Og0;g_p9XJLRY!H%&2(2kUA%kzYvjxcWeY&``omT- zsOz7>t~Bb6iVPn1E2v2^f8cri;zi@LLJlsqBVI|yQKyjY3&~EjQBv?wfY>3D=XP&u z*>Ms!-09=9LT%p8X__kj4xgn-_ReMLciF<0763lq-I)240a`~=aBn?s3j8+zs>$D1 zCWnTRVq3=FVQQPV`I9av*CmggGqZ2W{WX8ZI&E$q?-Bh(-pMa&$-|wd%OO}LEql37 z|NDOZB+31KigNh-ml9gZ4S5&C51+gQT!fAuFyheDrWRG3z!ItiKsvQ)2Rc%-(g8tD zY5!3U!=I5T)uUY76y^DaEdjPJo&_4uew{}Y;C$FnL?T9oUXQMtq=&~EQ$27fzl>$V z=+Omg8qq<LszVA1Z0V}jt6*l;+f0c1A&b-hpVkUaqP`jmHu>Udn91jnRXC34dOuNU zJ}RwguaNFiuSvG<{dL4b@x~2p7hbdR8wTimOTvCHdDIeZ3}p3P5?+mZ?K2>Ts&y;w zUox4t+5IyY?-Oq?Bkw;}>=RRFoGqS9zVoFn|AyTBt3broja;Xcbz8|kTSHtRccp<$ zaqKJLvJ=2(!l&aSaOBIEY<!cKQSX~_QizG~*X=IjY}F|f_+<vb&=KtxUiu;rhW63L z2cEy78??c<FCpgQH`2nXhDBU$!+IgN(i7s;q<uCUwvavW2L>93C;9?~>jCO3{x{{l z2^=;8WdQqa1l3Z7&U0XH3Rf$Zpej};JWpkSLnooVwR{qq{&TF_vWYTmKUZBZT!U%N zrX-Fz2#LO$IOpu(z)$kxK3;8F>nOr?OmN$}ao4wD%Eulvq1X?mM)sW94Y~)62o%3n z#X$tc5QkVBW`N1aLMS2Zxk3yB;G^1-GK%wPYE$=Afr&|?ut?lKF1=p@eDjX2yHC~l zW7oEVGO+TeW?E}$2Gvl)?`@F~NxB{LT>kHp)<w2e|IkpEU;1*zl#9KRK!h&ZyVB3$ zm$-lI?O34-x(9QZNhO$!e!lyX(D88wPW!#p2_E>vrG%u4kV%w`hhNhD@_s%EGZhvR zbh-vdO<aR1Y|64jA3d^Xc3te93x3sbrlBe|z?{&!?X+H#xjoNp2%-e(cCJu@MW9C| zYFysH`(*0(*#*y-*VpxT7{KzFYs1uuGpF|AOEt>RXoR)_KX4BYfJ`T9S`ja|EvN8Y zVfF?CkkO~9$A=;^Hx*W9$FO7z$fCOe6t$3cwr=e4Vy!CG#~zJ{_Ab}F;w)O82ua_9 zt!~ZIq?a)77Tb2;qrD9iMSRziIzhmdm>7|mIG>n}MO#yzHVpx)Os7DzfQ#EOL~@gj z7I$6@e+@5otlj~IcFz{zaIy(?(A69FjBgU*=z(XkTy;S$JS~+T&3rb&+rC%!u6#=_ zYw4Xa5r~i%JJI%nwf%pT(l*Q!*?y<ua3yucMy2aX&2-OUDQDv6`zBL!zII44fxg`S z5@lb{d3swXKLNSn74zL&uDIAS&eU^W|Lu$0ImfiavPCZdQ8`{yEmq}6N2iA$_RH%L z?w3;=3rw|m^D1nT`#d7&^o}Nb9|c-~s}iOGFZ#25qnZxsm6E@1XR1=Az%_XWR7lR; zSAa@BR}AmsWd(;uqQJ(f5MGSC*%dpF#~Lq9P2hxc%^R!6I5X^Hdw$;}X1U(7yXhXE z#Xsg!v|V;RClraHa*?7%Ua#?RW0#aB`zCU{A7vbJFsLKT8`E?lTT6EhXdc6bS)9Dy zp>$3M<E@ubFA&pqcUa;S+IGbPmjajEGD+D;VFtKxnjbs$8@nzVgyw}19j8<tH#GF) zt>X6%9;5pQW>Hy5zW30l`4Sl5c0S@;S6yWkAPv$osr-{0Jmh;Tt)J8wphHm~<+8>a zmsSwnMr*r3_jj)AiUMR#ii!Op2FNOl#*)wo1~^KHXfTFz?nF|`6DIfE6nAYPyH+*5 zT4K_gFpQSp0McfYKFs`WKKI=v#5a_&C1tLl_!wYn9!0-c=E{V2(}ZJ;G}AJpIQK@X z<#u0-&~a{>927e>JMHF7iee}Oti(PT=Y`A_k>Z;YYUcWp+6ghx!UnB2QO#x<2g3AL zr1R3E&%19Hk0V!S60FM%zi-{sC|Ah%Gj6JNxP|57yT~_D*=ES2I?!HIUZ)HLH8rWh Vx6Jy{U3>Xt7E9EBo;Vr9{{{DY0KWhL literal 0 HcmV?d00001 diff --git a/fhem/FHEM/lib/UPnP/sonos_dock_round.png b/fhem/FHEM/lib/UPnP/sonos_dock_round.png new file mode 100644 index 0000000000000000000000000000000000000000..a5a2e40eab949ba3887a7da59269900eb538c6aa GIT binary patch literal 5738 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 F{|^VnUhx0` literal 0 HcmV?d00001 diff --git a/fhem/FHEM/lib/UPnP/sonos_leer.gif b/fhem/FHEM/lib/UPnP/sonos_leer.gif new file mode 100644 index 0000000000000000000000000000000000000000..43e5d44a35b6b0dc613a49c78359811525d0bb7b GIT binary patch literal 814 zcmZ?wbhEHbWMyDw_|7m2Mnhmkhk)Ww7Dfh!{|q{yPypo#1`ck9|C}-&8x}Y=F)}h( F0|3qU2+aTh literal 0 HcmV?d00001 diff --git a/fhem/FHEM/lib/UPnP/sonos_linein_quadratic.jpg b/fhem/FHEM/lib/UPnP/sonos_linein_quadratic.jpg new file mode 100644 index 0000000000000000000000000000000000000000..bf094a685a832f37f1061d7bba37ca12d5cb9417 GIT binary patch literal 2948 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 QREYiF_joo@5e2{e2ZDHG2LJ#7 literal 0 HcmV?d00001 diff --git a/fhem/FHEM/lib/UPnP/sonos_linein_round.png b/fhem/FHEM/lib/UPnP/sonos_linein_round.png new file mode 100644 index 0000000000000000000000000000000000000000..a723d48173a6f85f0014770b069821fe6b569c31 GIT binary patch literal 1988 zcmeHH`&ZHj9R2#J2}aXmlpZP-n3iTwSGL@OK>1pdqAN`-m{{6sD!O&7bn*~Ud?bs~ zEIcSK(1{R7Gf#@4ORya^&9wPSMboh~#C)WV;os5jIrnqV=kwz|_j9v?NdYjZGZX*- z7)ZpEElOXbosDG%9F4hP5vwF}01lA1Bd08j?L}-5766p__A9h?0AK?Ip9~{d?kOoL z*REZ=e*OB58#hu@Q`6JanM@{&#bUGB91bTVBZJH3W@cvcc)YBvtnBP;KA)eHlarg9 zo0pfDpPw%f2!ujmVPRoWQBiSmu}CB;D=U*oBvPqVCX-cER8&@0R#jEW<?`z4YK20f zR4P>}RZUGzU0q#$eSJeiLt|s3TCHwsYSL&lEiEmrt*vcsZSC#tU0q$>-Q7JsJ+EH9 z>h0}){rYu(fB(S1z~JEE(9qEE@bJjU$mr<k*w~m}uOA;DpO~1KoSdARnlcy+@7}$e zo}QkWnK2rTCX;D?etuzLVR3PBX=&->$B)a)%PT7@tE;PKv)SUn|M+Abu**Xou>1*- zOeTd|!tf~r2wnILV+jHzff$hl02`avXw@1oU8@zRWI}jyX#9obt29Oo5KN2vG1;3$ z2y^-QfVYpg51763#?mNA5RVOuJwA9h?##=*aIO=@pAejIoStYy!NNl*4wQsw2j;)# zlKo%lnmZ4I?pNZMpT6xL&<`#6CP9M^XBMO#jj~5X^KK<uEBrWikQ8jJCsoRk;9_B9 zFITkn`V}}>n9p(DL4&9;)J}{CO##n2Vv9a0<QkD_5Y!hgP0!rLYBvtuNQ{+l*ylun zRQb3}!UJ&8yLrwx?{uEttRZQ^*a}!8^+c>=`Y|>}jJ0+1iGcLu94fTLJ~+GY)@{sX z2rj{2!}Y*F@}xy<)f6Z^^1%2I4cTULaUSh;P-Au%U*;h&@Z69<MqFDAtHCXh=)Yq% zi1Ljx690sLi634Z4%xwvm`}~UwH0(HF+QM*HZla|Hf~XWgI`|UQX+R|okxuy@<Vhy zgIKBQqf3Wy6-x6>m*c-ko<9h!_hdexu&NB4o%M44#2#luXQWHeNZ^I$Q@*0!Xh|GW zH|LrP&YCJ|C4aSA`60emaj@qYF&!Ib{2nJU%iH_75hlkEsC##w_^1q9WH5U_)jwnc zBl6Vtj?j&DIRoNsJzGR6Z)0?r>AQ%}@_OD(`v&rjLHF;LvGk4G?}^(EJksxPJ?C1h z7c!6{@uue6PtFLgKz%Rk<bvNrw6qgx9y(|SGRp8>b%*gPoYD7IS1*|J&?_+cLiA&Q z40rcqF(zKxqTQw*t{L|h!e!_m_NiwZz(A+jL)wre=AtS6tnJ&oN6O9@fDcm)UY;Kk zXEe|K1?YhD=NP#Ujg6)DFE;hhT7$f&G9-UtPgOZ;bR|rv_v$XunaHP0XZ-}Ei)oqo z&%YPUDWWL-eG|?xNus>)^_%hu&~3w|l6`zIKD6s*#{1163Q-z)`ykczu&3iAhvKeU zd>>Bg=IL1DAXB1Q2y0j^{!biIkH#S^$vA=c!ow2K)(93U5mU3fh5nv+>8zb@^w*a! z9H?V=!fN-5P%g6HXfItoWK>!w#%pIKO&d@k$>Cak`-cFIgNl8$!MX(4m34R`T<>ei iFfog3X4@b#yJyTc5a0WAKMm{(UaMM=K*Gy$RMvl8?@Y!3 literal 0 HcmV?d00001 diff --git a/fhem/FHEM/lib/UPnP/sonos_playbar_quadratic.jpg b/fhem/FHEM/lib/UPnP/sonos_playbar_quadratic.jpg new file mode 100644 index 0000000000000000000000000000000000000000..306467118c3940310d327e8999a04cfac1b5be6d GIT binary patch literal 5306 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 literal 0 HcmV?d00001 diff --git a/fhem/FHEM/lib/UPnP/sonos_playbar_round.png b/fhem/FHEM/lib/UPnP/sonos_playbar_round.png new file mode 100644 index 0000000000000000000000000000000000000000..c339ff9e8d6c0a0e2dacca130c9dbbd4916aefe8 GIT binary patch literal 9633 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 Zq_fTZ%8z(jcSlg+D85mXuat$t{s-6;Qpf-R literal 0 HcmV?d00001 diff --git a/fhem/FHEM/lib/UPnP/sonos_tunein_quadratic.jpg b/fhem/FHEM/lib/UPnP/sonos_tunein_quadratic.jpg new file mode 100644 index 0000000000000000000000000000000000000000..45b0d69c4c4e8bd6d2a73dc266b74fc6eea363dc GIT binary patch literal 2779 zcmbuAc{mhWAIHxaV=4D#tR<xwWh>VfN#<s4xVAB4mr^uEWFPB9U29QXDoTur293}V z%Gec2c1B@rS>oD8qnLR|@ALNl^ZxNZ@BN+Ud(L^z@;m2q&htBmGsGDOBIc$jQviWL zz&UOL90tId;4gatU}*`I0RRGk7a|Q{+!}-{1IWIgbyJ8Ufc|Lb;dZJ6ZVupFS#h;v z=eXLL^P{vmf7v(an3W0UV2I{1?PJ>9l}3Qk&boigcAWez2RoBF^#CpaY=8|EA_sWj z5GWkNX$G>~-NXLnn+x&7AUsfB7$3iY;I7@=0aOvd1A#($c%d*DFE4j=6t^Gn!eOF^ z)KBw?*`DW@^B32Mxtk{-Z&=nKf$m;LXnF+13ht7W+Ot<$;qVbfC1ow`<2t%0^o)#6 zOwG=q%<V8(dk04+=L;7-y)NM{UkMBf4hhAFh2OaOM_hbDVp3ZApBb6=?mx&P=98Wj z6h0*vl~=r=R#v^Nu4!y)ZfR|M^R~UGx37O-aA=s$n3$ZJp7}gGH@~vFw$9x6%G&(4 z!vz7*pGWpzx!_zb9$sE3FaHh~geR0MD4Z8|NS#mgv@QR6e=#|Y7y)s^yLn{|g7TW^ zWeJaf?p=}yEr!C%4%!d0{|zkm|04Sd_8%@f5Q0Lumj{IdB-lt&B+~H6`ekC0A|RHy z(A`E)r1T6-%+<fOz+Ze+&s32vgMPe)324$1)v{|rXq1koU~p5eYish?n^J8}z6Gmf zx)w#2Py4pJmS#}9%?D^1Z)S-}g1Z!n*MDeuc_}e$!cPPjy|1@2iq<UMnoS%IyP3cq zEIMm+!S~`pQ4vc$se__Y2&iL!A#wgIov)0vP;)VMznMv2NwBxz8CCh$y%gdr>fL+t z*H8>omAiKY-gGJj26SY89EhwM$)dZrf6qSu36_w%V(uqYjQ-YG_{7X8$DrTN&qT-i zfywI=C4_Sv06|_N#LHH6#RR4DvD8eSdly?c##K*{*S{OD@F!`bZ!BgN6o(gm<?p)Z zPc4%Y_Aa1r+K1$th#Xyeytw^cgC#uC)*!o&HF^|k{hXD&JlW*jmAsCG&ux4do|me0 zmUvi0Db1TtGbC>pdPWq|KW01rr9W6sgIiuk$hnYSGS!ABH=0=|SH3oc+gDwhC>3UT zw~TqjvqcHe1-+JMamB<dNlC{hsnR!`DvO_JRY&}S?nER-gq+r`s<lYQMTczHaez;1 zoPhufIf;xjfUnqROq3?jL|*K-XpC)Ys@kBF3}m{Q_Yu9!XV%Vj6`3yHN>-1w`zen- zXR_xgGbw(Dj^<^0Zlw!a9CvC_3l%M+IVC?+QX2ip0k1MpK6AQ(fhSjWPnwygG6$2_ ztOXUu2fXqV8P&@eme*_j$G^noz#IC${Yk4(^kju0{e(tyRE8oHbyb6fe8P%Xv4z;S z&8Ng96=u`c&41rNlH`6}cG>!`VTaZ+sDUuPt@*L=F{ZExtCUd~8)-&ymz88u#umft zsEkiYbf2%}Xs<(6WmSqx?Sm}EgK@g!2_$m?%eFxRgU$hEpSQ(!mu{(UUE+Wa`dbc& z4P$rb@UzIvD)sKdll|)*!U?orw$v{$j=D6W-C7P529j7_0_(3;n)QN9O47%M&R(_| zdP_J}ekM~d-POgqEV>K90l68QlF0qpjIx(mnyr&$U58>^lt;NM<!VFQSjCN`<m9sp zx1Z&)X)Hp0j>w7{Y`N7xX1os8sWegkd)YusTYR}Q^MHn~GQ8EU1;tQ6>$W@oq3b~& zxbi|pNtuk&WXQ5YXkS7(z_@XN19l_#a=_{aO^e{C#yZj##}>e&*uQScRQDqp|0t^~ zFJJMy$xQAgyW&*HR#!`yxA)m*B5EF^q*A>vgn1IQr<pIl`n$CRYv<*hRGST`)8rmQ z>=gSAaxb0(?i<`8tWI^mCTs^&un)a#-~D<wQxoni;V+KsD|9ZIZ?qw!wMNr@d(0hf z<$Gm0Q-ii>lKT6Wd&L-IQXap)+SjycfK+B|jp%f&3Efj}@Wj6}&{3^-XtjCmBO$F( zY)G0_#x66f=P*x{jyZN3USoA^V7MDN7pY#u3S)49Oe8)PyMj!NbRsz}gs{cS`~6;6 zN;TO_KPqw?rhn=vpi&B-E*OsDvo<@YoTFZhJj<<mxmq`@C0oe>Ur^t5whnA(=@V9j zxxQj3LDrHuhqJ<#nznvo_;i~y4%n5#`1OeCh*K7r+86gN3dcSf>ohv+1ZV7ZgGJY| zP9m2RST3gs44DsWO!hl!XS5p!G}v(fP8c$W<fB^0*z5Jj6;E=&o-3m=*_dwCODQv- zo5Bw#7CYfn7i-=mKJYnQSKV83wZvy>mz%4RewC8&@TA6|DO97=fAjtRs7qps7E)GN z#a^;*cgg{Y3(AST$u^it*yTXA2$RDjgP&h6G`S1ncQ^PRddl*PyT+o+>`!f+t{(8b zUO)K0Eb{cN_M)D8Z2o-@a@St{*2?pG!MHYI+bFj}Ws!W-BpY@xH-ff*<A9@vNs76G zi%Y&fGbuAYVT-<USG!$ZIuK&UL-IiZ&F!;82v0{DQ58S7C<jP#z=!S$wXOa0?px`u zi{cMGz9vPJk?-g^@^BMvbLtgTE!B!Np51GokI3%!o=Tk$ZE}Au`(=c{=n@`to2}E! znqM>Sp->8t5^VXrtci)nQK#vP@y%t1w-J>0_Irql7H}C{3?}mHchBfncL-aW-ANPO zy2`MWZOI%x_`yf3__kh@(ZrFI;9A}3duMwVT{{b|d}A&>3J}t4&vkV{J%65dDy>M5 zU4YkH8f3#|cN0h>9Pp+(j~mK34w&8R&F+u(Av9Rga9x1qH(o$p_O==b+74`^i7515 zo$d)5>4<1sUWzZA56OBoLw>d?irP3*bNuemTxNP$s1Dj%p$~WU%gmA0TUAn79-8_` z6({^Nqz1?}cQ&Z5*gbv8#qUc!VO9pr^~y)V9N_rMf`QzP4{cmh>@0KDY}oXY-^RRC z99Jg{?{$;GR79S2K&Q9~Atp~_GmjS>55n@JzMtu%TC|wjxp#@;Yg_z@X0nM}Ml$|I zL^*HgG@{&7tTDD4g2lkF0OSI7M-3FBN58zbLzU8lLfq;?q@7S*T57JSRabr2Tp@}= zWO+(_t3mmst)rsNHcy~mhZiY7yF$f8OQ1<J`MsaezH|j@CHEbDaL3j}9B>68)x4(^ zNd&v0u)krkbv3(+_;{`>)Prka4>Tzck!HS#psb$xtcJMt{!5h8{mctXr~+FQq7oG@ fH;pFs*gc%eY)Ai@wr*Jq6Sw{SI`_+ma)$p2WJ&gW literal 0 HcmV?d00001 diff --git a/fhem/FHEM/lib/UPnP/sonos_tunein_round.png b/fhem/FHEM/lib/UPnP/sonos_tunein_round.png new file mode 100644 index 0000000000000000000000000000000000000000..ec4a7064b97b89822084b596e4e5f662ce2fe0da GIT binary patch literal 1979 zcmeHH{Xf$Q9RIF`&Qpjw^2)=~BXdo9y2Qk7bG5^$QSBJTr95pOuDTAZj^&(6S+CNZ zE_t4XW)s^y&Wy<9;kArxqKM3Fwy~Rkqr1=R{r-GjpZAaN&qJ0!8ew8+V+a6%2@2^G zsH^^K+yK^P=IeD-T>%jS5#B&mkL|RsSsw*I2?qfBUq+g6Jpcd${wIQbb^3wY=LhRb z4%L+$d0BdN4Q0m|<(~EBUiIbh`ikSsN*`wB53DL=1I@3Y`eY+Lps6O9U3-dCcbfAO z%Y7NbWrQ{}a4q$?7AC%p717oZ)!ulqqv;~A>2fDKu9K6<=O*%-le$`x1ud!Ft=C_7 zWc2b#eVv*8UF3cNSt!UB3jP#!-y7`B8|=*=>Z1(3DHsv{EqeR(-C&7$xKun+Jua$# z|E}i!Xzj#k-GrDS5woP@4U^;S4-?!E67H0wc}m*;agz7(L)Xky_ssOb{OsWT=aDaS zq6Ho1M`d5ebXZ)F$`_}W<kO0!Ps*hkrDASbIj>TFQ7y|<sztR*u2wH;)C!G8xuQ|6 ztf*I4G^?wtTCL8a|Kn?Qz;y!6*4+s}3`AqJ|Lp)U3IiwIoWF@jh7bV2v}KK;j<}LY zooG$;#S$@b7l<k0iRS_T@LytyP_%E5eX<+W9qNuEYsPdRWfRH=9)v|S4yK&x_qR+- zz|(j28(Qsrnqd#M-NnD_@;1O0L?5}Pbq#p*h#}c=SaD&ItDr4Po`^|d$ERbSPe$*1 z5or*PPr&r7djV}XcYyGU_vJXj(k_KeL_3>DLfmr@7GCHmvQ_1FUSBrda<jqVe8}d8 zHZM~tIhdSb+iBeeH_rt#BhEb|iJ)d_RQ<#nf3pJb+%tOMKCjqJli=7B=(Breex^H& zu%SbjAG^O(sb(!U?WPcN>Ahn)2=?T+Ss)P1a!+H-_^&vE!%EBvJ*vi679+y%u|k4) z$Y^JcN`r%K#st~aXZj%WDL?M^OE<pZw-+h2mEOYY&o3MaWF*99Cc$xjayF>w)XlAl z(}gnQtQ_^jE<Jl!$!GgM{Rqr-Apv-%@^~!_(vM$sqlnw>F_>e#XY|s!=qC*KX2Dtg z-iJf0<1xS6xK_dFd3r>C3YAvXh<jY?Va+<fPPRQw+Gk$Mld<;O8s8I>lI^yHKccpp zKIk|!mN$9OQ@_P!M<}Yg^m?K9^NksdPUSL0L>RCSv@q7@w-W4@73GLmGsKDmj#7{Z zO~%=FNHRi;il@y8o+Qo=x($^aq16Y;jaitU<ajHgTPo}vP(i@*3zA0(rDZiw@2}54 z>}+LldbrOogJgm>YUA9q`92iY8v<)OjSq`CsWgEcy<BxPX&6a_P28AksIeMfcJXZ5 zb9JV*pX=)OI43f7+k-N<y5Y#WtKc3vQ(Z85@u0M0R-4LczjBS3I{L?NpLUwFO(~9_ z7hEt&*ZE5@4KC-^fM|DdF;u_yHj`saZ8A(hwQu9pnJwUv)(y(;rKp<=i5YT-Vm#T< z>vjpL(&-jHJ<sTD%_`gmnRX!nobpD?b%?eUv&yJHE9^o&<C^~=L4pT-&QsDIC}y?S zw$`jKB6(z)73{AzFz5VaV062p+%XJ(M_RZGi;TRF4+Gt4i%s~>wKEWFA#i$8<dimy z(k&zR(so3IHHUaNA}hPFK$4lOH`98>IhH>Rx5_5pp(m^DAdWqrup337wFipwMf+5F Hhh_W=x!!sr literal 0 HcmV?d00001