diff --git a/fhem/CHANGED b/fhem/CHANGED index 739a97a85..f6bad7683 100644 --- a/fhem/CHANGED +++ b/fhem/CHANGED @@ -1,5 +1,12 @@ # Add changes at the top of the list. Keep it in ASCII, and 80-char wide. # Do not insert empty lines here, update check depends on it. + - added: 98_DLNARenderer: Autodiscover, control and receive events from + DLNA MediaRenderer devices + define dlnasearch DLNARenderer + - added: 98_BOSEST: Autodiscover, control and receive events from + BOSE SoundTouch devices (e.g. BOSE SoundTouch 10, 20, 30) + See commandref for library dependencies + define bosesystem BOSEST - added: 77_SMASTP: Support for SMA Sunny Tripower Inverter - added: 77_SMAEM: Support for SMA Energy Meter - added: 00_HMUARTLGW: new module for eQ-3 HomeMatic UART/LanGateway diff --git a/fhem/FHEM/98_BOSEST.pm b/fhem/FHEM/98_BOSEST.pm new file mode 100755 index 000000000..1eeb8a182 --- /dev/null +++ b/fhem/FHEM/98_BOSEST.pm @@ -0,0 +1,2334 @@ +############################################################# +# +# BOSEST.pm (c) by Dominik Karall, 2016 +# dominik karall at gmail dot com +# $Id$ +# +# FHEM module to communicate with BOSE SoundTouch system +# API as defined in BOSE SoundTouchAPI_WebServices_v1.0.1.pdf +# +# Version: 2.0.0 +# +############################################################# +# +# v2.0.0 - 20160718 +# - CHANGE: first official release within fhem repository +# +# v1.5.7 - 20160623 +# - BUGFIX: fix off command if zone-play is active +# - BUGFIX: fix auto-zone if zone-play is already active +# - BUGFIX: do not create auto-zone if both players play nothing +# - BUGFIX: update most readings only on change (reduces number of events) +# - BUGFIX: fix autoAddDLNAServers functionality in main device +# +# v1.5.6 - 20160611 +# - FEATURE: auto-zone, start zone-play if speakers are playing the same (contentItemLocation) +# attr autoZone on (default: off) +# - BUGFIX: set zone only from master device as slave devices might not always report the truth (known bug at BOSE) +# - CHANGE: set zonemember_X to UDN instead of player name +# - CHANGE: delete TTS files after 30 days +# - CHANGE: reduce number of http calls after each discovery process +# - CHANGE: reduce number of http calls for key presses +# +# v1.5.5 - 20160510 +# - BUGFIX: fix unitiliazed value in handleDeviceByIp +# +# v1.5.4 - 20160509 +# - FEATURE: restore volume when speaker goes online +# allows to power off the box completely without loosing +# previous volume settings +# - BUGFIX: fix possible unitialized value +# - BUGFIX: fix next which should be return +# - BUGFIX: sometimes double-tap feature wasn't working due to BOSE not +# updating zones for slave speakers +# +# v1.5.3 - 20160425 +# - FEATURE: support static IPs (should only be used if device can't be discovered) +# attr bose_system staticIPs 192.168.1.52,192.168.1.53 +# - FEATURE: support speak channel name (useful for SoundTouch w/o display) +# attr speakChannel 1-6 +# attr speakChannel 2,3,5,6 +# - BUGFIX: retry websocket setup every 5s if it fails +# - BUGFIX: update supportClockDisplay reading only on reconnect +# - CHANGE: remove user attr from main device +# +# v1.5.2 - 20160403 +# - FEATURE: support clock display (SoundTouch 20/30) +# set clock enable/disable +# +# v1.5.1 - 20160330 +# - CHANGE: updated documentation (again many thx to Miami!) +# - FEATURE: support triple-tap (currently no function implemented: any ideas? :)) +# - CHANGE: change back channel even after speakOff +# - BUGFIX: unitialized value fixed +# +# v1.5.0 - 20160306 +# - FEATURE: support SetExtensions (on-for-timer,...) +# - FEATURE: support TTS (TextToSpeach) via Google Translate +# set speak "This is a test message" +# - FEATURE: support volume control for TTS +# set speak "This message has different volume" 30 +# - FEATURE: support different languages for TTS +# set speak "Das ist ein deutscher Test" de +# set speak "Das ist ein deutscher Test" 30 de +# - FEATURE: support off (instead of resume) after TTS messages (restores only volume settings) +# set speakOff "Music is going to switch off now. Good night." 30 en +# - FEATURE: speak "not available" text on Google Captcha +# can be disabled by ttsSpeakOnError = 0 +# - FEATURE: set default TTS language via ttsLanguage attribute +# - FEATURE: automatically add DLNA server running on the same +# server as FHEM to the BOSE library +# - FEATURE: automatically add all DLNA servers to BOSE library +# requires autoAddDLNAServers = 1 attribute for "main" (not players!) +# - FEATURE: reuse cached TTS files for 30 days +# - FEATURE: set DLNA TTS directory via ttsDirectory attribute +# - FEATURE: set DLNA TTS server via ttsDLNAServer attribute +# only needed if the DLNA server is not the FHEM server +# - FEATURE: support ttsVolume for speak +# ttsVolume = 20 (set volume 20 for speak) +# ttsVolume = +20 (increase volume by 20 from current level) +# - FEATURE: add html documentation (provided by Miami) +# - FEATURE: support relative volume settings with +/- +# set volume +3 +# set speak "This is a louder message" +10 +# - FEATURE: new reading "connectedDLNAServers" (blanks are replaced by "-") +# - FEATURE: support add/remove DLNA servers to the BOSE library +# set addDLNAServer RPi +# set removeDLNAServer RPi +# - FEATURE: add readings for channel_07-20 +# - FEATURE: support saveChannel to save current channel to channel_07-20 +# - FEATURE: support bass settings only if available (/bassCapabilities) +# - FEATURE: support bluetooth only if available (/sources) +# - FEATURE: support switch source to airplay (untested) +# - BUGFIX: update zone on Player discovery +# - BUGFIX: fixed some uninitialized variables +# - CHANGE: limit recent_X readings to 15 max +# +# v1.0.0 - 20160219 +# - FEATURE: support multi-room (playEverywhere, stopPlayEverywhere) +# - FEATURE: show current zone members in readings +# - FEATURE: support createZone ,,... +# - FEATURE: support addToZone ,,... +# - FEATURE: support removeFromZone ,,... +# - FEATURE: add "double-tap" multi-room feature +# double-tap (<1s) a hardware preset button to +# enable or disable the multi-room feature +# - FEATURE: support bass settings +# - FEATURE: support infoUpdated (e.g. deviceName change) +# - FEATURE: support mute on/off/toggle +# - FEATURE: support recent channel list +# set name recent X +# names for recent list entries are shown in readings +# - FEATURE: support channel_07-20 by attribute +# format:name|location|source|sourceAccount or +# name|location|source| if no sourceAccount +# - FEATURE: support bluetooth/bt-discover/aux mode +# - FEATURE: support ignoreDeviceIDs for main define +# format:B23C23FF,A2EC81EF +# - CHANGE: reading channel_X => channel_0X (e.g. channel_02) +# +# v0.9.7 - 20160214 +# - FEATURE: print module version on startup of main module +# - FEATURE: support device rename (e.g. BOSE_... => wz_BOSE) +# - FEATURE: show preset itemNames in channel_X reading +# - FEATURE: automatically update preset readings on change +# - FEATURE: add description reading (could be very long) +# - CHANGE: change log level for not implemented events to 4 +# - CHANGE: use only one processXml function for websocket and http +# - BUGFIX: fix set off/on more than once within 1 second +# - BUGFIX: fix warnings during setup process +# - BUGFIX: support umlauts in all readings +# - BUGFIX: handle XMLin errors with eval +# - BUGFIX: handle "set" when speaker wasn't found yet +# +# v0.9.6 - 20160210 +# - FEATURE: support prev/next track +# +# v0.9.5 - 20160210 +# - FEATURE: update channel based on websocket events +# - BUGFIX: specify minimum libmojolicious-perl version +# - BUGFIX: reconnect websocket if handshake fails +# - BUGFIX: presence reading fixed +# - CHANGE: websocket request timeout changed to 10s (prev. 5s) +# - CHANGE: clockDisplayUpdated message handled now +# +# v0.9.4 - 20160206 +# - CHANGE: completely drop ithreads (reduces memory usage) +# - CHANGE: search for new devices every 60s (BlockingCall) +# - CHANGE: check presence status based on websocket connection +# - BUGFIX: removed arguments and readings for main BOSEST +# - FEATURE: read volume on startup +# +# v0.9.3 - 20160125 +# - BUGFIX: fix "EV does not work with ithreads." +# +# v0.9.2 - 20160123 +# - BUGFIX: fix memory leak +# - BUGFIX: use select instead of usleep +# +# v0.9.1 - 20160122 +# - BUGFIX: bugfix for on/off support +# +# v0.9 - 20160121 +# - autodiscover BOSE SoundTouch players +# - add alias for newly created devices +# - update IP if the player IP changes +# - automatically re-connect websocket +# - support UTF-8 names with umlauts +# - reconnect websocket when connection closed +# - add firmware version & IP readings +# - automatically update /info on IP update +# - state: offline,playing,stopped,paused,online (online means standby) +# - support on/off commands based on current state +# - support more readings for now_playing +# +# v0.2 - 20160110 +# - support stop/play/pause/power +# - change preset to channel according to DevGuidelinesAV +# - read /info on startup +# - connect to websocket to receive speaker events +# +# v0.1 - 20160105 +# - define BOSE Soundtouch based on fixed IP +# - change volume via /volume +# - change preset via /key +# +# TODO +# - redesign multiroom functionality (virtual devices: represent the readings of master device +# and send the commands only to the master device (except volume?) +# automatically create group before playing +# - support multiroom volume (check with SoundTouch app to see commands) +# - use websocket frame ping (WS_PING) instead of websocket XML ping +# - TTS code cleanup (group functions logically) +# - check if Mojolicious should be used for HTTPGET/HTTPPOST +# - ramp up/down volume support in SetExtensions +# +############################################################# + +BEGIN { + $ENV{MOJO_REACTOR} = "Mojo::Reactor::Poll"; +} + +package main; + +use strict; +use warnings; + +use Blocking; +use Encode; +use SetExtensions; + +use Data::Dumper; +use Digest::MD5 qw(md5_hex); +use File::stat; +use IO::Socket::INET; +use LWP::UserAgent; +use Mojolicious 5.54; +use Net::Bonjour; +use Scalar::Util qw(looks_like_number); +use XML::Simple; + +my $BOSEST_GOOGLE_NOT_AVAILABLE_TEXT = "Hello, I'm sorry, but Google Translate is currently not available."; +my $BOSEST_GOOGLE_NOT_AVAILABLE_LANG = "en"; + +sub BOSEST_Initialize($) { + my ($hash) = @_; + + $hash->{DefFn} = 'BOSEST_Define'; + $hash->{UndefFn} = 'BOSEST_Undef'; + $hash->{GetFn} = 'BOSEST_Get'; + $hash->{SetFn} = 'BOSEST_Set'; + $hash->{AttrFn} = 'BOSEST_Attribute'; + + return undef; +} + +sub BOSEST_Define($$) { + my ($hash, $def) = @_; + my @a = split("[ \t]+", $def); + my $name = $a[0]; + + $hash->{DEVICEID} = "0"; + $hash->{STATE} = "initialized"; + + if (int(@a) > 3) { + return 'BOSEST: Wrong syntax, must be define BOSEST []'; + } elsif(int(@a) == 3) { + my $param = $a[2]; + #set device id from parameter + $hash->{DEVICEID} = $param; + + #set IP to unknown + $hash->{helper}{IP} = "unknown"; + readingsSingleUpdate($hash, "IP", "unknown", 1); + + #allow on/off commands (updateIP?) + $hash->{helper}{sent_on} = 0; + $hash->{helper}{sent_off} = 0; + + #no websockets connected + $hash->{helper}{wsconnected} = 0; + #create mojo useragent + $hash->{helper}{useragent} = Mojo::UserAgent->new() if(!defined($hash->{helper}{useragent})); + + #init statecheck + $hash->{helper}{stateCheck}{enabled} = 0; + + #init switchSource + $hash->{helper}{switchSource} = ""; + + #init speak channel functionality + $hash->{helper}{lastSpokenChannel} = ""; + + foreach my $attrname (qw(channel_07 channel_08 channel_09 channel_10 channel_11 + channel_12 channel_13 channel_14 channel_15 channel_16 + channel_17 channel_18 channel_19 channel_20 ignoreDeviceIDs + ttsDirectory ttsLanguage ttsSpeakOnError ttsDLNAServer ttsVolume + speakChannel autoZone)) { + addToDevAttrList($name, $attrname); + } + + BOSEST_deleteOldTTSFiles($hash); + + #FIXME reset all recent_$i entries on startup (must be done here, otherwise readings are displayed when player wasn't found) + } + + #init dlnaservers + $hash->{helper}{dlnaServers} = ""; + + #init supported source commands + $hash->{helper}{supportedSourcesCmds} = ""; + $hash->{helper}{supportedBassCmds} = ""; + + if (int(@a) < 3) { + Log3 $hash, 3, "BOSEST: BOSE SoundTouch v2.0.0"; + #start discovery process 30s delayed + InternalTimer(gettimeofday()+30, "BOSEST_startDiscoveryProcess", $hash, 0); + + foreach my $attrname (qw(staticIPs autoAddDLNAServers)) { + addToDevAttrList($name, $attrname); + } + } + + return undef; +} + +sub BOSEST_Attribute($$$$) { + my ($mode, $devName, $attrName, $attrValue) = @_; + + if($mode eq "set") { + if(substr($attrName, 0, 8) eq "channel_") { + #check if there are 3 | in the attrValue + my @value = split("\\|", $attrValue); + return "BOSEST: wrong format" if(!defined($value[2])); + #update reading for channel_X + readingsSingleUpdate($main::defs{$devName}, $attrName, $value[0], 1); + } elsif($attrName eq "ttsDLNAServer") { + BOSEST_addDLNAServer($main::defs{$devName}, $attrValue); + } + } elsif($mode eq "del") { + if(substr($attrName, 0, 8) eq "channel_") { + #update reading for channel_X + readingsSingleUpdate($main::defs{$devName}, $attrName, "-", 1); + } + } + + return undef; +} + +sub BOSEST_Set($@) { + my ($hash, $name, @params) = @_; + my $workType = shift(@params); + + #get quoted text from params + my $blankParams = join(" ", @params); + my @params2; + while($blankParams =~ /"?((?{helper}{supportedSourcesCmds}." + nextTrack:noArg prevTrack:noArg playTrack speak speakOff + playEverywhere:noArg stopPlayEverywhere:noArg createZone addToZone removeFromZone + clock:enable,disable + stop:noArg pause:noArg channel:1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20 + volume:slider,0,1,100 ".$hash->{helper}{supportedBassCmds}." + saveChannel:07,08,09,10,11,12,13,14,15,16,17,18,19,20 + addDLNAServer:".$hash->{helper}{dlnaServers}." + removeDLNAServer:".ReadingsVal($hash->{NAME}, "connectedDLNAServers", "noArg"); + + # check parameters for set function + #DEVELOPNEWFUNCTION-1 + if($workType eq "?") { + if($hash->{DEVICEID} eq "0") { + return ""; #no arguments for server + } else { + return SetExtensions($hash, $list, $name, $workType, @params); + } + } + + if($hash->{helper}{IP} eq "unknown") { + return "Searching for BOSE SoundTouch, try again later..."; + } + + if($workType eq "volume") { + return "BOSEST: volume requires volume as additional parameter" if(int(@params) < 1); + #params[0] = volume value + BOSEST_setVolume($hash, $params[0]); + } elsif($workType eq "zoneVolume") { + BOSEST_setZoneVolume($hash, $params[0]); + } elsif($workType eq "channel") { + return "BOSEST: channel requires preset id as additional parameter" if(int(@params) < 1); + #params[0] = preset channel + BOSEST_setPreset($hash, $params[0]); + } elsif($workType eq "saveChannel") { + return "BOSEST: saveChannel requires channel number as additional parameter" if(int(@params) < 1); + #params[09 = channel number (07-20) + BOSEST_saveChannel($hash, $params[0]); + } elsif($workType eq "bass") { + return "BOSEST: bass requires bass (1-10) as additional parameter" if(int(@params) < 1); + #params[0] = bass value + BOSEST_setBass($hash, $params[0]); + } elsif($workType eq "mute") { + return "BOSEST: mute requires on/off/toggle as additional parameter" if(int(@params) < 1); + #params[0] = mute value + BOSEST_setMute($hash, $params[0]); + } elsif($workType eq "recent") { + return "BOSEST: recebt requires number as additional parameter" if(int(@params) < 1); + #params[0] = recent value + BOSEST_setRecent($hash, $params[0]); + } elsif($workType eq "source") { + return "BOSEST: source requires bluetooth/aux as additional parameter" if(int(@params) < 1); + #params[0] = source value + BOSEST_setSource($hash, $params[0]); + } elsif($workType eq "addDLNAServer") { + return "BOSEST: addDLNAServer requires DLNA friendly name as additional parameter" if(int(@params) < 1); + #params[0] = friendly name + BOSEST_addDLNAServer($hash, $params[0]); + } elsif($workType eq "removeDLNAServer") { + return "BOSEST: removeDLNAServer requires DLNA friendly name as additional parameter" if(int(@params) < 1); + #params[0] = friendly name + BOSEST_removeDLNAServer($hash, $params[0]); + } elsif($workType eq "clock") { + return "BOSEST: clock requires enable/disable as additional parameter" if(int(@params) < 1); + #check if supported + return "BOSEST: clock not supported." if(ReadingsVal($hash->{NAME}, "supportClockDisplay", "false") eq "false"); + BOSEST_clockSettings($hash, $params[0]); + } elsif($workType eq "play") { + BOSEST_play($hash); + } elsif($workType eq "stop") { + BOSEST_stop($hash); + } elsif($workType eq "pause") { + BOSEST_pause($hash); + } elsif($workType eq "power") { + BOSEST_power($hash); + } elsif($workType eq "on") { + BOSEST_on($hash); + } elsif($workType eq "off") { + BOSEST_off($hash); + InternalTimer(gettimeofday()+2, "BOSEST_off", $hash, 0); + } elsif($workType eq "nextTrack") { + BOSEST_next($hash); + } elsif($workType eq "prevTrack") { + BOSEST_prev($hash); + } elsif($workType eq "playTrack") { + return "BOSEST: playTrack requires track name as additional parameters" if(int(@params) < 1); + #params[0] = track name for search + BOSEST_playTrack($hash, $params[0]); + } elsif($workType eq "speak" or $workType eq "speakOff") { + return "BOSEST: speak requires quoted text as additional parameters" if(int(@params) < 1); + return "BOSEST: speak requires quoted text" if(substr($blankParams, 0, 1) ne "\""); + return "BOSEST: speak maximum text length is 100 characters" if(length($params[0])>100); + if(AttrVal($hash->{NAME}, "ttsDirectory", "") eq "") { + return "BOSEST: Please set ttsDirectory attribute first. + FHEM user needs permissions to write to that directory. + It is also recommended to set ttsLanguage (default: en)."; + } + #set text (must be within quotes) + my $text = $params[0]; + my $volume = ""; + if(looks_like_number($params[1])) { + #set volume (default current volume) + $volume = $params[1] if(defined($params[1])); + } else { + #parameter is language + $params[2] = $params[1]; + } + #set language (default English) + my $lang = ""; + $lang = $params[2] if(defined($params[2])); + #stop after speak? + my $stopAfterSpeak = 0; + if($workType eq "speakOff") { + $stopAfterSpeak = 1; + } + BOSEST_speak($hash, $text, $volume, $lang, $stopAfterSpeak); + } elsif($workType eq "playEverywhere") { + BOSEST_playEverywhere($hash); + } elsif($workType eq "stopPlayEverywhere") { + BOSEST_stopPlayEverywhere($hash); + } elsif($workType eq "createZone") { + return "BOSEST: createZone requires deviceIDs as additional parameter" if(int(@params) < 1); + #params[0] = deviceID channel + BOSEST_createZone($hash, $params[0]); + } elsif($workType eq "addToZone") { + return "BOSEST: addToZone requires deviceID as additional parameter" if(int(@params) < 1); + #params[0] = deviceID channel + BOSEST_addToZone($hash, $params[0]); + } elsif($workType eq "removeFromZone") { + return "BOSEST: removeFromZone requires deviceID as additional parameter" if(int(@params) < 1); + #params[0] = deviceID channel + BOSEST_removeFromZone($hash, $params[0]); + } else { + return SetExtensions($hash, $list, $name, $workType, @params); + } + + return undef; +} + +#DEVELOPNEWFUNCTION-2 (create own function) +sub BOSEST_setZoneVolume { + my ($hash, $targetVolume) = @_; + #FIXME + # #change volume of this device + # DLNARenderer_volume($hash, $targetVolume); + + # #handle volume for all devices in the current group + # #iterate through group and change volume relative to the current volume of this device + # my $mainVolumeDiff = DLNARenderer_convertVolumeToAbsolute($hash, $targetVolume) - ReadingsVal($hash->{NAME}, "volume", 0); + # my $multiRoomUnits = ReadingsVal($hash->{NAME}, "multiRoomUnits", ""); + # my @multiRoomUnitsArray = split("," $multiRoomUnits); + # foreach my $unit (@multiRoomUnitsArray) { + # my $devHash = DLNARenderer_getHashByFriendlyName($hash, $unit); + # my $newVolume = ReadingsVal($devHash->{NAME}, "volume", 0) + $mainVolumeDiff); + # if($newVolume > 100) { + # $newVolume = 100; + # } elsif($newVolume < 0) { + # $newVolume = 0; + # } + # DLNARenderer_volume($devHash, $newVolume); + # } + + return undef; +} + +sub BOSEST_clockSettings($$) { + my ($hash, $val) = @_; + + if($val eq "disable") { + $val = "false"; + } else { + $val = "true"; + } + + my $postXml = ""; + if(BOSEST_HTTPPOST($hash, '/clockDisplay', $postXml)) { + } + #FIXME error handling + + return undef; +} + +sub BOSEST_addDLNAServer($$) { + my ($hash, $friendlyName) = @_; + + #retrieve uuid for friendlyname + my $listMediaServers = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/listMediaServers"); + foreach my $mediaServer (@{ $listMediaServers->{ListMediaServersResponse}->{media_server} }) { + $mediaServer->{friendly_name} =~ s/\ /_/g; + if($mediaServer->{friendly_name} eq $friendlyName) { + BOSEST_setMusicServiceAccount($hash, $friendlyName, $mediaServer->{id}); + } + } + + return undef; +} + +sub BOSEST_removeDLNAServer($$) { + my ($hash, $friendlyName) = @_; + + #retrieve uuid for friendlyname + my $sources = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/sources"); + foreach my $source (@{ $sources->{sources}->{sourceItem} }) { + next if($source->{source} ne "STORED_MUSIC"); + + $source->{content} =~ s/\ /_/g; + + if($source->{content} eq $friendlyName) { + BOSEST_removeMusicServiceAccount($hash, $friendlyName, $source->{sourceAccount}); + } + } + + return undef; +} + +sub BOSEST_saveChannel($$) { + my ($hash, $channel) = @_; + + if(ReadingsVal($hash->{NAME}, "state", "stopped") ne "playing") { + return "BOSEST: No playing channel. Start a channel and save afterwards."; + } + + #itemname, location, source, sourceaccount + my $itemName = ReadingsVal($hash->{NAME}, "contentItemItemName", ""); + my $location = ReadingsVal($hash->{NAME}, "contentItemLocation", ""); + my $source = ReadingsVal($hash->{NAME}, "contentItemSource", ""); + my $sourceAccount = ReadingsVal($hash->{NAME}, "contentItemSourceAccount", ""); + + fhem("attr $hash->{NAME} channel_$channel $itemName|$location|$source|$sourceAccount"); + return undef; +} + +sub BOSEST_stopPlayEverywhere($) { + my ($hash) = @_; + my $postXmlHeader = "{DEVICEID}\">"; + my $postXmlFooter = ""; + my $postXml = ""; + + my @players = BOSEST_getAllBosePlayers($hash); + foreach my $playerHash (@players) { + if($playerHash->{DEVICEID} ne $hash->{DEVICEID}) { + $postXml .= "{helper}{IP}."\">".$playerHash->{DEVICEID}."" if($playerHash->{helper}{IP} ne "unknown"); + } + } + + $postXml = $postXmlHeader.$postXml.$postXmlFooter; + + if(BOSEST_HTTPPOST($hash, '/removeZoneSlave', $postXml)) { + #ok + } +} + +sub BOSEST_playEverywhere($) { + my ($hash) = @_; + my $postXmlHeader = "{DEVICEID}\" senderIPAddress=\"$hash->{helper}{IP}\">"; + my $postXmlFooter = ""; + my $postXml = ""; + + my @players = BOSEST_getAllBosePlayers($hash); + foreach my $playerHash (@players) { + #don't add myself as member, I'm the master + if($playerHash->{DEVICEID} ne $hash->{DEVICEID}) { + $postXml .= "{helper}{IP}."\">".$playerHash->{DEVICEID}."" if($playerHash->{helper}{IP} ne "unknown"); + } + } + + $postXml = $postXmlHeader.$postXml.$postXmlFooter; + + if(BOSEST_HTTPPOST($hash, '/setZone', $postXml)) { + #ok + } + + return undef; +} + +sub BOSEST_createZone($$) { + my ($hash, $deviceIds) = @_; + my @devices = split(",", $deviceIds); + my $postXmlHeader = "{DEVICEID}\" senderIPAddress=\"$hash->{helper}{IP}\">"; + my $postXmlFooter = ""; + my $postXml = ""; + + foreach my $deviceId (@devices) { + my $playerHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId); + + return undef if(!defined($playerHash)); + + $postXml .= "{helper}{IP}."\">".$playerHash->{DEVICEID}."" if($playerHash->{helper}{IP} ne "unknown"); + } + + $postXml = $postXmlHeader.$postXml.$postXmlFooter; + + if(BOSEST_HTTPPOST($hash, '/setZone', $postXml)) { + #ok + } + + return undef; +} + +sub BOSEST_addToZone($$) { + my ($hash, $deviceIds) = @_; + my @devices = split(",", $deviceIds); + my $postXmlHeader = "{DEVICEID}\" senderIPAddress=\"$hash->{helper}{IP}\">"; + my $postXmlFooter = ""; + my $postXml = ""; + + foreach my $deviceId (@devices) { + my $playerHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId); + + return undef if(!defined($playerHash)); + + $postXml .= "{helper}{IP}."\">".$playerHash->{DEVICEID}."" if($playerHash->{helper}{IP} ne "unknown"); + } + + $postXml = $postXmlHeader.$postXml.$postXmlFooter; + + if(BOSEST_HTTPPOST($hash, '/addZoneSlave', $postXml)) { + #ok + } + + return undef; +} + +sub BOSEST_removeFromZone($$) { + my ($hash, $deviceIds) = @_; + my @devices = split(",", $deviceIds); + my $postXmlHeader = "{DEVICEID}\">"; + my $postXmlFooter = ""; + my $postXml = ""; + + foreach my $deviceId (@devices) { + my $playerHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId); + + return undef if(!defined($playerHash)); + + $postXml .= "{helper}{IP}."\">".$playerHash->{DEVICEID}."" if($playerHash->{helper}{IP} ne "unknown"); + } + + $postXml = $postXmlHeader.$postXml.$postXmlFooter; + + if(BOSEST_HTTPPOST($hash, '/removeZoneSlave', $postXml)) { + #ok + } + + return undef; +} + +sub BOSEST_on($) { + my ($hash) = @_; + + if(!$hash->{helper}{sent_on}) { + my $sourceState = ReadingsVal($hash->{NAME}, "source", "STANDBY"); + if($sourceState eq "STANDBY") { + BOSEST_power($hash); + } + $hash->{helper}{sent_on} = 1; + } +} + +sub BOSEST_off($) { + my ($hash) = @_; + + if(!$hash->{helper}{sent_off}) { + my $sourceState = ReadingsVal($hash->{NAME}, "source", "STANDBY"); + if($sourceState ne "STANDBY") { + BOSEST_power($hash); + } + $hash->{helper}{sent_off} = 1; + } +} + +sub BOSEST_setRecent($$) { + my ($hash, $nr) = @_; + + if(!defined($hash->{helper}{recents}{$nr}{itemName})) { + #recent entry not found + return undef; + } + + BOSEST_setContentItem($hash, + $hash->{helper}{recents}{$nr}{itemName}, + $hash->{helper}{recents}{$nr}{location}, + $hash->{helper}{recents}{$nr}{source}, + $hash->{helper}{recents}{$nr}{sourceAccount}); + + return undef; +} + +sub BOSEST_setContentItem($$$$$) { + my ($hash, $itemName, $location, $source, $sourceAccount) = @_; + + my $postXml = "". + "". + $itemName. + "". + ""; + + if(BOSEST_HTTPPOST($hash, "/select", $postXml)) { + #ok + } + return undef; +} + +sub BOSEST_setBass($$) { + my ($hash, $bass) = @_; + $bass = $bass - 10; + my $postXml = "$bass"; + if(BOSEST_HTTPPOST($hash, '/bass', $postXml)) { + } + #FIXME error handling + return undef; +} + +sub BOSEST_setVolume($$) { + my ($hash, $volume) = @_; + + if(substr($volume, 0, 1) eq "+" or + substr($volume, 0, 1) eq "-") { + $volume = ReadingsVal($hash->{NAME}, "volume", 0) + $volume; + } + + my $postXml = ''.$volume.''; + if(BOSEST_HTTPPOST($hash, '/volume', $postXml)) { + } + #FIXME error handling + return undef; +} + +sub BOSEST_setMute($$) { + my ($hash, $mute) = @_; + + if(($mute eq "on" && $hash->{READINGS}{mute}{VAL} eq "false") or + ($mute eq "off" && $hash->{READINGS}{mute}{VAL} eq "true") or + ($mute eq "toggle")) { + BOSEST_sendKey($hash, "MUTE"); + } + + return undef; +} + +sub BOSEST_setSource($$) { + my ($hash, $source) = @_; + + $hash->{helper}{switchSource} = uc $source; + + if($hash->{helper}{switchSource} eq "") { + return undef; + } + + if($hash->{helper}{switchSource} eq "BT-DISCOVER" && + ReadingsVal($hash->{NAME}, "connectionStatusInfo", "") eq "DISCOVERABLE") { + $hash->{helper}{switchSource} = ""; + return undef; + } + + if($hash->{helper}{switchSource} eq ReadingsVal($hash->{NAME}, "source", "") && + ReadingsVal($hash->{NAME}, "connectionStatusInfo", "") ne "DISCOVERABLE") { + $hash->{helper}{switchSource} = ""; + return undef; + } + + #source is not switchSource yet + BOSEST_sendKey($hash, "AUX_INPUT"); + + return undef; +} + +sub BOSEST_setPreset($$) { + my ($hash, $preset) = @_; + if($preset > 0 && $preset < 7) { + BOSEST_sendKey($hash, "PRESET_".$preset); + } else { + #set channel based on AttrVal + my $channelVal = AttrVal($hash->{NAME}, sprintf("channel_%02d", $preset), "0"); + return undef if($channelVal eq "0"); + my @channel = split("\\|", $channelVal); + $channel[3] = "" if(!defined($channel[3])); + Log3 $hash, 5, "BOSEST: AttrVal: $channel[0], $channel[1], $channel[2], $channel[3]"; + #format: itemName|location|source|sourceAccount + BOSEST_setContentItem($hash, $channel[0], $channel[1], $channel[2], $channel[3]); + } + return undef; +} + +sub BOSEST_play($) { + my ($hash) = @_; + BOSEST_sendKey($hash, "PLAY"); + return undef; +} + +sub BOSEST_stop($) { + my ($hash) = @_; + BOSEST_sendKey($hash, "STOP"); + return undef; +} + +sub BOSEST_pause($) { + my ($hash) = @_; + BOSEST_sendKey($hash, "PAUSE"); + return undef; +} + +sub BOSEST_power($) { + my ($hash) = @_; + BOSEST_sendKey($hash, "POWER"); + return undef; +} + +sub BOSEST_next($) { + my ($hash) = @_; + BOSEST_sendKey($hash, "NEXT_TRACK"); + return undef; +} + +sub BOSEST_prev($) { + my ($hash) = @_; + BOSEST_sendKey($hash, "PREV_TRACK"); + return undef; +} + +sub BOSEST_Undef($) { + my ($hash) = @_; + + #remove internal timer + RemoveInternalTimer($hash); + + #kill blocking + BlockingKill($hash->{helper}{DISCOVERY_PID}) if(defined($hash->{helper}{DISCOVERY_PID})); + + return undef; +} + +sub BOSEST_Get($$) { + return undef; +} + +sub BOSEST_speakChannel { + my ($hash) = @_; + + my $speakChannel = AttrVal($hash->{NAME}, "speakChannel", ""); + if($speakChannel ne "") { + my $channelNr = ReadingsVal($hash->{NAME}, "channel", ""); + if($channelNr =~ /[$speakChannel]/g) { + my $channelName = ReadingsVal($hash->{NAME}, "contentItemItemName", ""); + if($channelNr ne "" && $channelName ne "" && $hash->{helper}{lastSpokenChannel} ne $channelName) { + #speak channel name + $hash->{helper}{lastSpokenChannel} = $channelName; + BOSEST_speak($hash, $channelName, "", "", 0); + } + } else { + if($channelNr ne "") { + #delete lastSpokenChannel + $hash->{helper}{lastSpokenChannel} = ""; + } + } + } +} + +sub BOSEST_speak($$$$$) { + my ($hash, $text, $volume, $lang, $stopAfterSpeak) = @_; + + my $ttsDir = AttrVal($hash->{NAME}, "ttsDirectory", ""); + $lang = AttrVal($hash->{NAME}, "ttsLanguage", "en") if($lang eq ""); + $volume = AttrVal($hash->{NAME}, "ttsVolume", ReadingsVal($hash->{NAME}, "volume", 20)) if($volume eq ""); + + #download file and play + BOSEST_playGoogleTTS($hash, $ttsDir, $text, $volume, $lang, $stopAfterSpeak); + + return undef; +} + +sub BOSEST_saveCurrentState($) { + my ($hash) = @_; + + $hash->{helper}{savedState}{volume} = ReadingsVal($hash->{NAME}, "volume", 20); + $hash->{helper}{savedState}{source} = ReadingsVal($hash->{NAME}, "source", ""); + $hash->{helper}{savedState}{bass} = ReadingsVal($hash->{NAME}, "bass", ""); + $hash->{helper}{savedState}{contentItemItemName} = ReadingsVal($hash->{NAME}, "contentItemItemName", ""); + $hash->{helper}{savedState}{contentItemLocation} = ReadingsVal($hash->{NAME}, "contentItemLocation", ""); + $hash->{helper}{savedState}{contentItemSource} = ReadingsVal($hash->{NAME}, "contentItemSource", ""); + $hash->{helper}{savedState}{contentItemSourceAccount} = ReadingsVal($hash->{NAME}, "contentItemSourceAccount", ""); + + return undef; +} + +sub BOSEST_restoreSavedState($) { + my ($hash) = @_; + + BOSEST_setVolume($hash, $hash->{helper}{savedState}{volume}); + BOSEST_setBass($hash, $hash->{helper}{savedState}{bass}); + + #bose off when source was off + if($hash->{helper}{savedState}{source} eq "STANDBY") { + BOSEST_off($hash); + } else { + BOSEST_setContentItem($hash, $hash->{helper}{savedState}{contentItemItemName}, + $hash->{helper}{savedState}{contentItemLocation}, + $hash->{helper}{savedState}{contentItemSource}, + $hash->{helper}{savedState}{contentItemSourceAccount}); + } + + return undef; +} + +sub BOSEST_restoreVolumeAndOff($) { + my ($hash) = @_; + + BOSEST_setVolume($hash, $hash->{helper}{savedState}{volume}); + BOSEST_setBass($hash, $hash->{helper}{savedState}{bass}); + + BOSEST_setContentItem($hash, $hash->{helper}{savedState}{contentItemItemName}, + $hash->{helper}{savedState}{contentItemLocation}, + $hash->{helper}{savedState}{contentItemSource}, + $hash->{helper}{savedState}{contentItemSourceAccount}); + + BOSEST_off($hash); +} + +sub BOSEST_downloadGoogleNotAvailable($) { + my ($hash) = @_; + my $text = $BOSEST_GOOGLE_NOT_AVAILABLE_TEXT; + my $lang = $BOSEST_GOOGLE_NOT_AVAILABLE_LANG; + my $ttsDir = AttrVal($hash->{NAME}, "ttsDirectory", ""); + + my $md5 = md5_hex($lang.$text); + my $filename = $ttsDir."/".$md5.".mp3"; + if (-f $filename) { + #file exists already + return undef; + } + + BOSEST_downloadGoogleTTS($hash, $filename, $md5, $text, $lang); + + return undef; +} + +sub BOSEST_downloadGoogleTTS($$$$$;$) { + my ($hash, $filename, $md5, $text, $lang, $callback) = @_; + + $hash->{helper}{useragent}->get("http://translate.google.com/translate_tts?tl=$lang&client=tw-ob&q=$text" => sub { + my ($ua, $tx) = @_; + my $downloadOk = 0; + if($tx->res->headers->content_type eq "audio/mpeg") { + $tx->res->content->asset->move_to($filename); + $downloadOk = 1; + } + if(defined($callback)) { + $callback->($hash, $filename, $md5, $downloadOk); + } + }); + + return undef; +} + +sub BOSEST_playMessage($$$$) { + my ($hash, $trackname, $volume, $stopAfterSpeak) = @_; + + BOSEST_saveCurrentState($hash); + + if($volume ne ReadingsVal($hash->{NAME}, "volume", 0)) { + BOSEST_stop($hash); + BOSEST_setVolume($hash, $volume); + } + + BOSEST_playTrack($hash, $trackname); + + $hash->{helper}{stateCheck}{enabled} = 1; + $hash->{helper}{stateCheck}{always} = 0; + #after play the speaker sets INVALID_SOURCE + $hash->{helper}{stateCheck}{actionSource} = "INVALID_SOURCE"; + #check if we need to stop after speak + if(defined($stopAfterSpeak) && $stopAfterSpeak eq "1") { + $hash->{helper}{stateCheck}{function} = \&BOSEST_restoreVolumeAndOff; + } else { + $hash->{helper}{stateCheck}{function} = \&BOSEST_restoreSavedState; + } + + return undef; +} + +sub BOSEST_deleteOldTTSFiles { + my ($hash) = @_; + my ($err, $val) = getKeyValue("BOSEST_tts_files"); + my @ttsFiles = split(",", $val); + my $ttsDir = AttrVal($hash->{NAME}, "ttsDirectory", ""); + + return undef if($ttsDir eq ""); + + InternalTimer(gettimeofday()+86500, "BOSEST_deleteOldTTSFiles", $hash, 0); + + foreach my $ttsFile (@ttsFiles) { + ($err, $val) = getKeyValue($ttsFile); + my $now = gettimeofday(); + if($now - $val > 2592000) { + #delete file + unlink $ttsDir."/".$ttsFile.".mp3";; + #remove $ttsFile from BOSEST_tts_files array + @ttsFiles = grep { $_ != $ttsFile } @ttsFiles; + #remove key + $err = setKeyValue($ttsFile, undef); + } + } + + $err = setKeyValue("BOSEST_tts_files", join(",", @ttsFiles)); +} + +sub BOSEST_playGoogleTTS($$$$$$) { + my ($hash, $ttsDir, $text, $volume, $lang, $stopAfterSpeak) = @_; + + BOSEST_downloadGoogleNotAvailable($hash); + + my $md5 = md5_hex($lang.$text); + my $filename = $ttsDir."/".$md5.".mp3"; + + if(-f $filename) { + my $timestamp = (stat($filename))->mtime(); #last modification timestamp + my $now = time(); + if($now-$timestamp < 2592000) { + #file is not older than 30 days + #file exists, call play sub + BOSEST_playMessage($hash, $md5, $volume, $stopAfterSpeak); + return undef; + } + } + + BOSEST_downloadGoogleTTS($hash, $filename, $md5, $text, $lang, sub { + my ($hash, $filename, $md5, $downloadOk) = @_; + + if($downloadOk) { + my ($err, $val) = getKeyValue("BOSEST_tts_files"); + if(!defined($val)) { + $val = ""; + } else { + $val .= ","; + } + $err = setKeyValue("BOSEST_tts_files", $val.$md5); + $err = setKeyValue($md5, gettimeofday()); + BOSEST_playMessage($hash, $md5, $volume, $stopAfterSpeak); + } else { + if(AttrVal($hash->{NAME}, "ttsSpeakOnError", "1") eq "1") { + $md5 = md5_hex($BOSEST_GOOGLE_NOT_AVAILABLE_LANG.$BOSEST_GOOGLE_NOT_AVAILABLE_TEXT); + BOSEST_playMessage($hash, $md5, $volume, $stopAfterSpeak); + } else { + Log3 $hash, 3, "BOSEST: Download Google Translate failed ($text)."; + return undef; + } + } + }); + + return undef; +} + +sub BOSEST_setMusicServiceAccount($$$) { + my ($hash, $friendlyName, $uuid) = @_; + my $postXml = ''. + $uuid.'/0'. + ''; + if(BOSEST_HTTPPOST($hash, '/setMusicServiceAccount', $postXml)) { + #ok + } + return undef; +} + +sub BOSEST_removeMusicServiceAccount($$$) { + my ($hash, $friendlyName, $uuid) = @_; + my $postXml = ''. + $uuid. + ''; + if(BOSEST_HTTPPOST($hash, '/removeMusicServiceAccount', $postXml)) { + #ok + } + return undef; +} + +sub BOSEST_playTrack($$) { + my ($hash, $trackName) = @_; + + my $ttsDlnaServer = AttrVal($hash->{NAME}, "ttsDLNAServer", ""); + + foreach my $source (@{$hash->{helper}{sources}}) { + if($source->{source} eq "STORED_MUSIC" && $source->{status} eq "READY") { + #skip servers which don't equal to ttsDLNAServer attribute if set + if($ttsDlnaServer ne "") { + next if($ttsDlnaServer ne $source->{content}); + } + Log3 $hash, 4, "BOSEST: Search for $trackName on $source->{source}"; + if(my $xmlTrack = BOSEST_searchTrack($hash, $source->{sourceAccount}, $trackName)) { + BOSEST_setContentItem($hash, + $xmlTrack->{itemName}, + $xmlTrack->{location}, + $xmlTrack->{source}, + $xmlTrack->{sourceAccount}); + last; + } + } + } + + return undef; +} + +sub BOSEST_searchTrack($$$) { + my ($hash, $dlnaUid, $trackName) = @_; + + my $postXml = '1100'. + $trackName. + ''; + + if(my $xmlSearchResult = BOSEST_HTTPPOST($hash, '/search', $postXml)) { + #return first item from search results + if($xmlSearchResult->{searchResponse}->{items}) { + return $xmlSearchResult->{searchResponse}->{items}->{item}[0]->{ContentItem}; + } + } + return undef; +} + +###### UPDATE VIA HTTP ###### +sub BOSEST_updateClock($$) { + my ($hash, $deviceId) = @_; + my $clockDisplay = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/clockDisplay"); + BOSEST_processXml($hash, $clockDisplay); + return undef; +} + +sub BOSEST_updateInfo($$) { + my ($hash, $deviceId) = @_; + my $info = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/info"); + BOSEST_processXml($hash, $info); + return undef; +} + +sub BOSEST_updateSources($$) { + my ($hash, $deviceId) = @_; + my $sources = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/sources"); + BOSEST_processXml($hash, $sources); + return undef; +} + +sub BOSEST_updatePresets($$) { + my ($hash, $deviceId) = @_; + my $presets = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/presets"); + BOSEST_processXml($hash, $presets); + return undef; +} + +sub BOSEST_updateZone($$) { + my ($hash, $deviceId) = @_; + my $zone = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/getZone"); + BOSEST_processXml($hash, $zone); + return undef; +} + +sub BOSEST_updateVolume($$) { + my ($hash, $deviceId) = @_; + my $volume = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/volume"); + BOSEST_processXml($hash, $volume); + return undef; +} + +sub BOSEST_updateBass($$) { + my ($hash, $deviceId) = @_; + my $bass = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/bass"); + BOSEST_processXml($hash, $bass); + return undef; +} + +sub BOSEST_updateNowPlaying($$) { + my ($hash, $deviceId) = @_; + my $nowPlaying = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/now_playing"); + BOSEST_processXml($hash, $nowPlaying); + return undef; +} + +sub BOSEST_updateAutoZone { + my ($hash, $location) = @_; + + return undef if($location eq ""); + return undef if(AttrVal($hash->{NAME}, "autoZone", "off") eq "off"); + + my @allPlayers = BOSEST_getAllBosePlayers($hash); + my $newZoneMaster; + my $createZone = 0; + foreach my $playerHash (@allPlayers) { + next if($playerHash->{DEVICEID} eq $hash->{DEVICEID}); + + my $playerLocation = ReadingsVal($playerHash->{NAME}, "contentItemLocation", ""); + my $playerZoneMaster = ReadingsVal($playerHash->{NAME}, "zoneMaster", ""); + Log3 $hash, 5, "BOSEST: auto-zone $hash->{NAME}: $location = $playerHash->{NAME}: $playerLocation?"; + #make sure that $playerHash is master device + if($playerLocation eq $location && ($playerZoneMaster eq "" or $playerZoneMaster eq $playerHash->{DEVICEID})) { + #TODO: check if createZone is needed + $newZoneMaster = $playerHash; + $createZone = 1 if($playerZoneMaster eq ""); + } + } + + if($newZoneMaster) { + if($createZone) { + BOSEST_createZone($newZoneMaster, $hash->{DEVICEID}); + } else { + BOSEST_addToZone($newZoneMaster, $hash->{DEVICEID}); + } + } +} + +sub BOSEST_checkDoubleTap($$) { + my ($hash, $channel) = @_; + + return undef if($channel eq ""); + + if(!defined($hash->{helper}{dt_nowSelectionUpdatedTS}) or $channel ne $hash->{helper}{dt_nowSelectionUpdatedCH}) { + $hash->{helper}{dt_nowSelectionUpdatedTS} = gettimeofday(); + $hash->{helper}{dt_nowSelectionUpdatedCH} = $channel; + $hash->{helper}{dt_lastChange} = 0; + $hash->{helper}{dt_counter} = 1; + return undef; + } + + my $timeDiff = gettimeofday() - $hash->{helper}{dt_nowSelectionUpdatedTS}; + if($timeDiff < 1) { + $hash->{helper}{dt_counter}++; + + if($hash->{helper}{dt_counter} == 2) { + if(ReadingsVal($hash->{NAME}, "zoneMaster", "") eq $hash->{DEVICEID}) { + BOSEST_stopPlayEverywhere($hash); + $hash->{helper}{dt_lastChange} = gettimeofday(); + } elsif(ReadingsVal($hash->{NAME}, "zoneMaster", "") eq "") { + #make sure that play isn't started just after stop, that might confuse the player + my $timeDiffMasterChange = gettimeofday() - $hash->{helper}{dt_lastChange}; + if($timeDiffMasterChange > 2) { + BOSEST_playEverywhere($hash); + $hash->{helper}{dt_lastChange} = gettimeofday(); + } + } + } elsif($hash->{helper}{dt_counter} == 3) { + #handle three-tap function - ideas? + } + } else { + $hash->{helper}{dt_counter} = 1; + } + + $hash->{helper}{dt_nowSelectionUpdatedTS} = gettimeofday(); + + return undef; +} + +###### XML PROCESSING ###### +sub BOSEST_processXml($$) { + my ($hash, $wsxml) = @_; + + if($wsxml->{updates}) { + if($wsxml->{updates}->{nowPlayingUpdated}) { + if($wsxml->{updates}->{nowPlayingUpdated}->{nowPlaying}) { + BOSEST_parseAndUpdateNowPlaying($hash, $wsxml->{updates}->{nowPlayingUpdated}->{nowPlaying}); + if($hash->{helper}{switchSource} ne "") { + BOSEST_setSource($hash, $hash->{helper}{switchSource}); + } else { + BOSEST_speakChannel($hash); + } + } + } elsif ($wsxml->{updates}->{volumeUpdated}) { + BOSEST_parseAndUpdateVolume($hash, $wsxml->{updates}->{volumeUpdated}->{volume}); + } elsif ($wsxml->{updates}->{nowSelectionUpdated}) { + BOSEST_parseAndUpdateChannel($hash, $wsxml->{updates}->{nowSelectionUpdated}->{preset}); + BOSEST_checkDoubleTap($hash, $wsxml->{updates}->{nowSelectionUpdated}->{preset}->{id}); + } elsif ($wsxml->{updates}->{recentsUpdated}) { + BOSEST_parseAndUpdateRecents($hash, $wsxml->{updates}->{recentsUpdated}->{recents}); + } elsif ($wsxml->{updates}->{connectionStateUpdated}) { + #BOSE SoundTouch team says that it's not necessary to handle this one + } elsif ($wsxml->{updates}->{clockDisplayUpdated}) { + #TODO handle clockDisplayUpdated (feature currently unknown) + } elsif ($wsxml->{updates}->{presetsUpdated}) { + BOSEST_parseAndUpdatePresets($hash, $wsxml->{updates}->{presetsUpdated}->{presets}); + } elsif ($wsxml->{updates}->{zoneUpdated}) { + #zoneUpdated is just a notification with no data + BOSEST_updateZone($hash, $hash->{DEVICEID}); + } elsif ($wsxml->{updates}->{bassUpdated}) { + #bassUpdated is just a notification with no data + BOSEST_updateBass($hash, $hash->{DEVICEID}); + } elsif ($wsxml->{updates}->{infoUpdated}) { + #infoUpdated is just a notification with no data + BOSEST_updateInfo($hash, $hash->{DEVICEID}); + } elsif ($wsxml->{updates}->{sourcesUpdated}) { + #sourcesUpdated is just a notification with no data + BOSEST_updateSources($hash, $hash->{DEVICEID}); + } elsif ($wsxml->{updates}->{clockTimeUpdated}) { + BOSEST_parseAndUpdateClock($hash, $wsxml->{updates}->{clockTimeUpdated}); + } else { + Log3 $hash, 4, "BOSEST: Unknown event, please implement:\n".Dumper($wsxml); + } + } elsif($wsxml->{info}) { + BOSEST_parseAndUpdateInfo($hash, $wsxml->{info}); + } elsif($wsxml->{nowPlaying}) { + BOSEST_parseAndUpdateNowPlaying($hash, $wsxml->{nowPlaying}); + } elsif($wsxml->{volume}) { + BOSEST_parseAndUpdateVolume($hash, $wsxml->{volume}); + } elsif($wsxml->{presets}) { + BOSEST_parseAndUpdatePresets($hash, $wsxml->{presets}); + } elsif($wsxml->{bass}) { + BOSEST_parseAndUpdateBass($hash, $wsxml->{bass}); + } elsif($wsxml->{zone}) { + BOSEST_parseAndUpdateZone($hash, $wsxml->{zone}); + } elsif($wsxml->{sources}) { + BOSEST_parseAndUpdateSources($hash, $wsxml->{sources}->{sourceItem}); + } else { + Log3 $hash, 4, "BOSEST: Unknown event, please implement:\n".Dumper($wsxml); + } + + if($hash->{helper}{stateCheck}{enabled}) { + #check if state is action state + if(ReadingsVal($hash->{NAME}, "source", "") eq $hash->{helper}{stateCheck}{actionSource}) { + #call function with $hash as argument + $hash->{helper}{stateCheck}{function}->($hash); + + #reset if always is not enabled + if(!$hash->{helper}{stateCheck}{always}) { + $hash->{helper}{stateCheck}{enabled} = 0; + } + } + } + + return undef; +} + +sub BOSEST_parseAndUpdateClock($$) { + my ($hash, $clock) = @_; + + if($clock->{clockTime}->{brightness} eq "0") { + readingsSingleUpdate($hash, "clockDisplay", "off", 1); + } else { + readingsSingleUpdate($hash, "clockDisplay", "on", 1); + } + + return undef; +} + +sub BOSEST_parseAndUpdateSources($$) { + my ($hash, $sourceItems) = @_; + + $hash->{helper}->{sources} = (); + + foreach my $sourceItem (@{$sourceItems}) { + Log3 $hash, 5, "BOSEST: Add $sourceItem->{source}"; + #save source information + # - source (BLUETOOTH, STORED_MUSIC, ...) + # - sourceAccount + # - status + # - isLocal + # - name + $sourceItem->{isLocal} = "" if(!defined($sourceItem->{isLocal})); + $sourceItem->{sourceAccount} = "" if(!defined($sourceItem->{sourceAccount})); + $sourceItem->{sourceAccount} = "" if(!defined($sourceItem->{sourceAccount})); + + my %source = (source => $sourceItem->{source}, + sourceAccount => $sourceItem->{sourceAccount}, + status => $sourceItem->{status}, + isLocal => $sourceItem->{isLocal}, + name => $sourceItem->{content}); + + push @{$hash->{helper}->{sources}}, \%source; + } + + my $connectedDlnaServers = ""; + foreach my $sourceItem (@{ $hash->{helper}->{sources} }) { + if($sourceItem->{source} eq "STORED_MUSIC") { + $connectedDlnaServers .= $sourceItem->{name}.","; + } + } + #remove last comma + $connectedDlnaServers = substr($connectedDlnaServers, 0, length($connectedDlnaServers) - 1); + #replace blank with hyphen + $connectedDlnaServers =~ s/\ /_/g; + + readingsSingleUpdate($hash, "connectedDLNAServers", $connectedDlnaServers, 1); + + return undef; +} + +sub BOSEST_parseAndUpdateChannel($$) { + my ($hash, $preset) = @_; + + readingsBeginUpdate($hash); + if($preset->{id} ne "0") { + BOSEST_XMLUpdate($hash, "channel", $preset->{id}); + } else { + BOSEST_XMLUpdate($hash, "channel", ""); + $preset->{ContentItem}->{itemName} = "" if(!defined($preset->{ContentItem}->{itemName})); + $preset->{ContentItem}->{location} = "" if(!defined($preset->{ContentItem}->{location})); + $preset->{ContentItem}->{source} = "" if(!defined($preset->{ContentItem}->{source})); + $preset->{ContentItem}->{sourceAccount} = "" if(!defined($preset->{ContentItem}->{sourceAccount})); + + my $channelString = $preset->{ContentItem}->{itemName}."|".$preset->{ContentItem}->{location}."|". + $preset->{ContentItem}->{source}."|".$preset->{ContentItem}->{sourceAccount}; + + foreach my $channelNr (7..20) { + my $channelVal = AttrVal($hash->{NAME}, sprintf("channel_%02d", $channelNr), "0"); + if($channelVal eq $channelString) { + BOSEST_XMLUpdate($hash, "channel", $channelNr); + } + } + } + readingsEndUpdate($hash, 1); + + return undef; +} + +sub BOSEST_parseAndUpdateZone($$) { + my ($hash, $zone) = @_; + + #only update zone from master + if(defined($zone->{master})) { + my $masterHash = BOSEST_getBosePlayerByDeviceId($hash, $zone->{master}); + if($masterHash->{DEVICEID} ne $hash->{DEVICEID}) { + return undef; + } + } + + my $i = 1; + readingsBeginUpdate($hash); + BOSEST_XMLUpdate($hash, "zoneMaster", $zone->{master}); + readingsEndUpdate($hash, 1); + + if($zone->{member}) { + foreach my $member (@{$zone->{member}}) { + my $player = BOSEST_getBosePlayerByDeviceId($hash, $member->{content}); + readingsBeginUpdate($hash); + BOSEST_XMLUpdate($hash, "zoneMember_$i", $player->{DEVICEID}); + readingsEndUpdate($hash, 1); + + readingsBeginUpdate($player); + BOSEST_XMLUpdate($player, "zoneMaster", $zone->{master}); + readingsEndUpdate($player, 1); + $i++; + } + + my $memberCnt = $i - 1; + foreach my $member (@{$zone->{member}}) { + my $player = BOSEST_getBosePlayerByDeviceId($hash, $member->{content}); + readingsBeginUpdate($player); + foreach my $cnt ($memberCnt..1) { + BOSEST_XMLUpdate($player, "zoneMember_$cnt", ReadingsVal($hash->{NAME}, "zoneMember_$cnt", "")); + } + readingsEndUpdate($player, 1); + } + } + + while ($i < 20) { + if(defined($hash->{READINGS}{"zoneMember_$i"})) { + my $zoneMemberUdn = ReadingsVal($hash->{NAME}, "zoneMember_$i", ""); + if($zoneMemberUdn ne "") { + my $memberHash = BOSEST_getBosePlayerByDeviceId($hash, $zoneMemberUdn); + readingsBeginUpdate($memberHash); + BOSEST_XMLUpdate($memberHash, "zoneMaster", ""); + my $j = 1; + while($j < 20) { + BOSEST_XMLUpdate($memberHash, "zoneMember_$j", "") if(defined($hash->{READINGS}{"zoneMember_$j"})); + $j++; + } + readingsEndUpdate($memberHash, 1); + } + readingsBeginUpdate($hash); + BOSEST_XMLUpdate($hash, "zoneMember_$i", ""); + readingsEndUpdate($hash, 1); + } + $i++; + } + + return undef; +} + +sub BOSEST_parseAndUpdatePresets($$) { + my ($hash, $presets) = @_; + my $maxpresets = 6; + my %activePresets = (); + + readingsBeginUpdate($hash); + foreach my $preset (1..6) { + $activePresets{$preset} = "-"; + } + + foreach my $preset (@{ $presets->{preset} }) { + $activePresets{$preset->{id}} = $preset->{ContentItem}->{itemName}; + } + + foreach my $preset (1..6) { + BOSEST_XMLUpdate($hash, sprintf("channel_%02d", $preset), $activePresets{$preset}); + } + + readingsEndUpdate($hash, 1); + return undef; +} + +sub BOSEST_parseAndUpdateRecents($$) { + my ($hash, $recents) = @_; + my $i = 1; + + readingsBeginUpdate($hash); + + foreach my $recentEntry (@{$recents->{recent}}) { + BOSEST_XMLUpdate($hash, sprintf("recent_%02d", $i), $recentEntry->{contentItem}->{itemName}); + $hash->{helper}{recents}{$i}{location} = $recentEntry->{contentItem}->{location}; + $hash->{helper}{recents}{$i}{source} = $recentEntry->{contentItem}->{source}; + $hash->{helper}{recents}{$i}{sourceAccount} = $recentEntry->{contentItem}->{sourceAccount}; + $hash->{helper}{recents}{$i}{itemName} = $recentEntry->{contentItem}->{itemName}; + $i++; + last if($i > 15); + } + + foreach my $x ($i..15) { + BOSEST_XMLUpdate($hash, sprintf("recent_%02d", $x), "-"); + delete $hash->{helper}{recents}{$x}; + } + + readingsEndUpdate($hash, 1); + + return undef; +} + +sub BOSEST_parseAndUpdateVolume($$) { + my ($hash, $volume) = @_; + readingsBeginUpdate($hash); + BOSEST_XMLUpdate($hash, "volume", $volume->{actualvolume}); + BOSEST_XMLUpdate($hash, "mute", $volume->{muteenabled}); + readingsEndUpdate($hash, 1); + return undef; +} + +sub BOSEST_parseAndUpdateBass($$) { + my ($hash, $bass) = @_; + my $currBass = $bass->{actualbass} + 10; + readingsBeginUpdate($hash); + BOSEST_XMLUpdate($hash, "bass", $currBass); + readingsEndUpdate($hash, 1); + return undef; +} + +sub BOSEST_parseAndUpdateInfo($$) { + my ($hash, $info) = @_; + $info->{name} = Encode::encode('UTF-8', $info->{name}); + readingsBeginUpdate($hash); + BOSEST_XMLUpdate($hash, "deviceName", $info->{name}); + BOSEST_XMLUpdate($hash, "type", $info->{type}); + BOSEST_XMLUpdate($hash, "deviceID", $info->{deviceID}); + BOSEST_XMLUpdate($hash, "softwareVersion", $info->{components}->{component}[0]->{softwareVersion}); + readingsEndUpdate($hash, 1); + return undef; +} + +sub BOSEST_parseAndUpdateNowPlaying($$) { + my ($hash, $nowPlaying) = @_; + Log3 $hash, 5, "BOSEST: parseAndUpdateNowPlaying"; + + readingsBeginUpdate($hash); + + BOSEST_XMLUpdate($hash, "stationName", $nowPlaying->{stationName}); + BOSEST_XMLUpdate($hash, "track", $nowPlaying->{track}); + BOSEST_XMLUpdate($hash, "source", $nowPlaying->{source}); + BOSEST_XMLUpdate($hash, "album", $nowPlaying->{album}); + BOSEST_XMLUpdate($hash, "artist", $nowPlaying->{artist}); + BOSEST_XMLUpdate($hash, "playStatus", $nowPlaying->{playStatus}); + BOSEST_XMLUpdate($hash, "stationLocation", $nowPlaying->{stationLocation}); + BOSEST_XMLUpdate($hash, "trackID", $nowPlaying->{trackID}); + BOSEST_XMLUpdate($hash, "artistID", $nowPlaying->{artistID}); + BOSEST_XMLUpdate($hash, "rating", $nowPlaying->{rating}); + BOSEST_XMLUpdate($hash, "description", $nowPlaying->{description}); + if($nowPlaying->{time}) { + BOSEST_XMLUpdate($hash, "time", $nowPlaying->{time}->{content}); + BOSEST_XMLUpdate($hash, "timeTotal", $nowPlaying->{time}->{total}); + } else { + BOSEST_XMLUpdate($hash, "time", ""); + BOSEST_XMLUpdate($hash, "timeTotal", ""); + } + if($nowPlaying->{art}) { + BOSEST_XMLUpdate($hash, "art", $nowPlaying->{art}->{content}); + BOSEST_XMLUpdate($hash, "artStatus", $nowPlaying->{art}->{artImageStatus}); + } else { + BOSEST_XMLUpdate($hash, "art", ""); + BOSEST_XMLUpdate($hash, "artStatus", ""); + } + if($nowPlaying->{ContentItem}) { + BOSEST_XMLUpdate($hash, "contentItemItemName", $nowPlaying->{ContentItem}->{itemName}); + BOSEST_XMLUpdate($hash, "contentItemLocation", $nowPlaying->{ContentItem}->{location}); + BOSEST_XMLUpdate($hash, "contentItemSourceAccount", $nowPlaying->{ContentItem}->{sourceAccount}); + BOSEST_XMLUpdate($hash, "contentItemSource", $nowPlaying->{ContentItem}->{source}); + BOSEST_XMLUpdate($hash, "contentItemIsPresetable", $nowPlaying->{ContentItem}->{isPresetable}); + BOSEST_XMLUpdate($hash, "contentItemType", $nowPlaying->{ContentItem}->{type}); + #TODO + #if location is the same as on other speaker, start auto-zone + BOSEST_updateAutoZone($hash, ReadingsVal($hash->{NAME}, "contentItemLocation", 1)); + } else { + BOSEST_XMLUpdate($hash, "contentItemItemName", ""); + BOSEST_XMLUpdate($hash, "contentItemLocation", ""); + BOSEST_XMLUpdate($hash, "contentItemSourceAccount", ""); + BOSEST_XMLUpdate($hash, "contentItemSource", ""); + BOSEST_XMLUpdate($hash, "contentItemIsPresetable", ""); + BOSEST_XMLUpdate($hash, "contentItemType", ""); + } + if($nowPlaying->{connectionStatusInfo}) { + BOSEST_XMLUpdate($hash, "connectionStatusInfo", $nowPlaying->{connectionStatusInfo}->{status}); + } else { + BOSEST_XMLUpdate($hash, "connectionStatusInfo", ""); + } + #handle state based on play status and standby state + if($nowPlaying->{source} eq "STANDBY") { + BOSEST_XMLUpdate($hash, "state", "online"); + } else { + if(defined($nowPlaying->{playStatus})) { + if($nowPlaying->{playStatus} eq "BUFFERING_STATE") { + BOSEST_XMLUpdate($hash, "state", "buffering"); + } elsif($nowPlaying->{playStatus} eq "PLAY_STATE") { + BOSEST_XMLUpdate($hash, "state", "playing"); + } elsif($nowPlaying->{playStatus} eq "STOP_STATE") { + BOSEST_XMLUpdate($hash, "state", "stopped"); + } elsif($nowPlaying->{playStatus} eq "PAUSE_STATE") { + BOSEST_XMLUpdate($hash, "state", "paused"); + } elsif($nowPlaying->{playStatus} eq "INVALID_PLAY_STATUS") { + BOSEST_XMLUpdate($hash, "state", "invalid"); + } + } + } + + #reset sent_off/on to enable the command again + #it's not allowed to send 2 times off/on due to toggle + #therefore I'm waiting for one signal to be + #received via websocket + $hash->{helper}{sent_off} = 0; + $hash->{helper}{sent_on} = 0; + + readingsEndUpdate($hash, 1); + + return undef; +} + +###### DISCOVERY ####### +sub BOSEST_startDiscoveryProcess($) { + my ($hash) = @_; + + if(!$init_done) { + #init not done yet, wait 3 more seconds + InternalTimer(gettimeofday()+3, "BOSEST_startDiscoveryProcess", $hash, 0); + } + + if (!defined($hash->{helper}{DISCOVERY_PID})) { + $hash->{helper}{DISCOVERY_PID} = BlockingCall("BOSEST_Discovery", $hash->{NAME}."|".$hash, "BOSEST_finishedDiscovery"); + } +} + +sub BOSEST_handleDeviceByIp { + my ($hash, $ip) = @_; + my $return = ""; + + my $info = BOSEST_HTTPGET($hash, $ip, "/info"); + #remove info tag to reduce line length + $info = $info->{info} if (defined($info->{info})); + #skip entry if no deviceid was found + return "" if (!defined($info->{deviceID})); + + #TODO return if the device is already defined and IP is the same + # make sure that this can be done and no further code below is needed + + #create new device if it doesn't exist + if(!defined(BOSEST_getBosePlayerByDeviceId($hash, $info->{deviceID}))) { + $info->{name} = Encode::encode('UTF-8',$info->{name}); + Log3 $hash, 3, "BOSEST: Device $info->{name} ($info->{deviceID}) found."; + $return = $return."|commandDefineBOSE|$info->{deviceID},$info->{name}"; + + #set supported capabilities + my $capabilities = BOSEST_HTTPGET($hash, $ip, "/capabilities"); + $return .= "|capabilities|$info->{deviceID}"; + if($capabilities->{capabilities}->{clockDisplay}) { + $return .= ",".$capabilities->{capabilities}->{clockDisplay}; + } else { + $return .= ",false"; + } + + #set supported bass capabilities + my $bassCapabilities = BOSEST_HTTPGET($hash, $ip, "/bassCapabilities"); + $return .= "|bassCapabilities|$info->{deviceID}"; + if($bassCapabilities->{bassCapabilities}) { + my $bassCap = $bassCapabilities->{bassCapabilities}; + $return .= ",".$bassCap->{bassAvailable}.",".$bassCap->{bassMin}.",". + $bassCap->{bassMax}.",".$bassCap->{bassDefault}; + } + } + + #TODO create own function (add own DLNA server) + my $myIp = BOSEST_getMyIp($hash); + my $listMediaServers = BOSEST_HTTPGET($hash, $ip, "/listMediaServers"); + + #set supported sources + my $sources = BOSEST_HTTPGET($hash, $ip, "/sources"); + $return .= "|supportedSources|$info->{deviceID}"; + foreach my $source (@{ $sources->{sources}->{sourceItem} }) { + $return .= ",".$source->{source}; + } + + my $returnListMediaServers = "|listMediaServers|".$info->{deviceID}; + foreach my $mediaServer (@{ $listMediaServers->{ListMediaServersResponse}->{media_server} }) { + $returnListMediaServers .= ",".$mediaServer->{friendly_name}; + + #check if it is already connected + my $isConnected = 0; + foreach my $source (@{ $sources->{sources}->{sourceItem} }) { + next if($source->{source} ne "STORED_MUSIC"); + + if(substr($source->{sourceAccount}, 0, length($mediaServer->{id})) eq $mediaServer->{id}) { + #already connected + $isConnected = 1; + next; + } + } + + next if($isConnected); + + if(($myIp eq $mediaServer->{ip}) || + (AttrVal($hash->{NAME}, "autoAddDLNAServers", "0") eq "1" )) { + $return = $return."|setMusicServiceAccount|".$info->{deviceID}.",".$mediaServer->{friendly_name}.",".$mediaServer->{id}; + Log3 $hash, 3, "BOSEST: DLNA Server ".$mediaServer->{friendly_name}." added."; + } + } + + #append listMediaServers + $return .= $returnListMediaServers; + + #update IP address of the device + $return = $return."|updateIP|".$info->{deviceID}.",".$ip; + + return $return; +} + +sub BOSEST_Discovery($) { + my ($string) = @_; + my ($name, $hash) = split("\\|", $string); + my $return = "$name"; + + $hash = $main::defs{$name}; + + eval { + my $res = Net::Bonjour->new('soundtouch'); + $res->discover; + foreach my $device ($res->entries) { + $return .= BOSEST_handleDeviceByIp($hash, $device->address); + } + }; + + #update static players + my @staticIPs = split(",", AttrVal($hash->{NAME}, "staticIPs", "")); + foreach my $ip (@staticIPs) { + $return .= BOSEST_handleDeviceByIp($hash, $ip); + } + + if($@) { + Log3 $hash, 3, "BOSEST: Discovery failed with: $@"; + } + + return $return; +} + +sub BOSEST_finishedDiscovery($) { + my ($string) = @_; + my @commands = split("\\|", $string); + my $name = $commands[0]; + my $hash = $defs{$name}; + my $i = 0; + my $ignoreDeviceIDs = AttrVal($hash->{NAME}, "ignoreDeviceIDs", ""); + + delete($hash->{helper}{DISCOVERY_PID}); + + #start discovery again after 67s + InternalTimer(gettimeofday()+67, "BOSEST_startDiscoveryProcess", $hash, 1); + + for($i = 1; $i < @commands; $i = $i+2) { + my $command = $commands[$i]; + my @params = split(",", $commands[$i+1]); + my $deviceId = shift(@params); + + next if($ignoreDeviceIDs =~ /$deviceId/); + + if($command eq "commandDefineBOSE") { + my $deviceName = $params[0]; + BOSEST_commandDefine($hash, $deviceId, $deviceName); + } elsif($command eq "updateIP") { + my $ip = $params[0]; + BOSEST_updateIP($hash, $deviceId, $ip); + } elsif($command eq "setMusicServiceAccount") { + my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId); + #0...friendly name + #1...UUID + BOSEST_setMusicServiceAccount($deviceHash, $params[0], $params[1]); + } elsif($command eq "listMediaServers") { + my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId); + $deviceHash->{helper}{dlnaServers} = join(",", @params); + $deviceHash->{helper}{dlnaServers} =~ s/\ /_/g; + } elsif($command eq "bassCapabilities") { + my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId); + #bassAvailable, bassMin, bassMax, bassDefault + $deviceHash->{helper}{bassAvailable} = 1 if($params[0] eq "true"); + $deviceHash->{helper}{bassMin} = $params[1]; + $deviceHash->{helper}{bassMax} = $params[2]; + $deviceHash->{helper}{bassDefault} = $params[3]; + if($params[0] eq "true") { + $deviceHash->{helper}{supportedBassCmds} = "bass:slider,1,1,10"; + } else { + $deviceHash->{helper}{supportedBassCmds} = ""; + } + } elsif($command eq "supportedSources") { + my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId); + #list of supported sources + $deviceHash->{helper}{bluetoothSupport} = 0; + $deviceHash->{helper}{auxSupport} = 0; + $deviceHash->{helper}{airplaySupport} = 0; + $deviceHash->{helper}{supportedSourcesCmds} = ""; + foreach my $source (@params) { + if($source eq "BLUETOOTH") { + $deviceHash->{helper}{bluetoothSupport} = 1; + $deviceHash->{helper}{supportedSourcesCmds} .= "bluetooth,bt-discover,"; + } elsif($source eq "AUX") { + $deviceHash->{helper}{auxSupport} = 1; + $deviceHash->{helper}{supportedSourcesCmds} .= "aux,"; + } elsif($source eq "AIRPLAY") { + $deviceHash->{helper}{airplaySupport} = 1; + $deviceHash->{helper}{supportedSourcesCmds} .= "airplay,"; + } + } + $deviceHash->{helper}{supportedSourcesCmds} = substr($deviceHash->{helper}{supportedSourcesCmds}, 0, length($deviceHash->{helper}{supportedSourcesCmds})-1); + } elsif($command eq "capabilities") { + my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId); + if(ReadingsVal($deviceHash->{NAME}, "supportClockDisplay", "") ne $params[0]) { + readingsSingleUpdate($deviceHash, "supportClockDisplay", $params[0], 1); + } + } + } +} + +sub BOSEST_updateIP($$$) { + my ($hash, $deviceID, $ip) = @_; + my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceID); + #check current IP of the device + my $currentIP = $deviceHash->{helper}{IP}; + $currentIP = "unknown" if(!defined($currentIP)); + + #if update is needed, get info/now_playing + if($currentIP ne $ip) { + $deviceHash->{helper}{IP} = $ip; + readingsSingleUpdate($deviceHash, "IP", $ip, 1); + readingsSingleUpdate($deviceHash, "presence", "online", 1); + Log3 $hash, 3, "BOSEST: $deviceHash->{NAME}, new IP ($ip)"; + #get info + BOSEST_updateInfo($deviceHash, $deviceID); + #get now_playing + BOSEST_updateNowPlaying($deviceHash, $deviceID); + #set previous volume if not playing anything + if(ReadingsVal($deviceHash->{NAME}, "state", "") eq "online") { + BOSEST_setVolume($deviceHash, ReadingsVal($deviceHash->{NAME}, "volume", 10)); + } + #get current volume + BOSEST_updateVolume($deviceHash, $deviceID); + #get current presets + BOSEST_updatePresets($deviceHash, $deviceID); + #get current bass settings + BOSEST_updateBass($deviceHash, $deviceID); + #get current zone settings + BOSEST_updateZone($deviceHash, $deviceID); + #get current sources + BOSEST_updateSources($deviceHash, $deviceID); + #get current clock state + BOSEST_updateClock($deviceHash, $deviceID); + #connect websocket + Log3 $hash, 4, "BOSEST: $deviceHash->{NAME}, start new WebSocket."; + BOSEST_startWebSocketConnection($deviceHash); + BOSEST_checkWebSocketConnection($deviceHash); + } + return undef; +} + +sub BOSEST_commandDefine($$$) { + my ($hash, $deviceID, $deviceName) = @_; + #check if device exists already + if(!defined(BOSEST_getBosePlayerByDeviceId($hash, $deviceID))) { + CommandDefine(undef, "BOSE_$deviceID BOSEST $deviceID"); + CommandAttr(undef, "BOSE_$deviceID alias $deviceName"); + } + return undef; +} + +###### WEBSOCKET ####### +sub BOSEST_webSocketCallback($$$) { + my ($hash, $ua, $tx) = @_; + Log3 $hash, 5, "BOSEST: Callback called"; + + if(!$tx->is_websocket) { + Log3 $hash, 3, "BOSEST: $hash->{NAME}, WebSocket failed, retry."; + BOSEST_startWebSocketConnection($hash); + return undef; + } else { + #avoid multiple websocket connections to one speaker + $hash->{helper}{wsconnected} += 1; + + if($hash->{helper}{wsconnected} > 1) { + $tx->finish; + return undef; + } + + Log3 $hash, 3, "BOSEST: $hash->{NAME}, WebSocket connection succeed."; + } + + #register on message method + $tx->on(message => sub { my ($tx2, $msg) = @_; BOSEST_webSocketReceivedMsg($hash, $tx2, $msg); }); + #register on finish method + $tx->on(finish => sub { my $ws = shift; BOSEST_webSocketFinished($hash, $ws); }); + #add recurring ping to mojo ioloop due to inactivity timeout + $hash->{helper}{mojoping} = Mojo::IOLoop->recurring(19 => sub { BOSEST_webSocketPing($hash, $tx); }); + return undef; +} + +sub BOSEST_webSocketFinished($$) { + my ($hash, $ws) = @_; + Log3 $hash, 3, "BOSEST: $hash->{NAME}, WebSocket connection dropped - try reconnect."; + + #set IP to unknown due to connection drop + $hash->{helper}{IP} = "unknown"; + + #connection dropped + $hash->{helper}{wsconnected} -= 1; + + #set presence & state to offline due to connection drop + readingsBeginUpdate($hash); + BOSEST_readingsSingleUpdateIfChanged($hash, "IP", "unknown", 1); + BOSEST_readingsSingleUpdateIfChanged($hash, "presence", "offline", 1); + BOSEST_readingsSingleUpdateIfChanged($hash, "state", "offline", 1); + readingsEndUpdate($hash, 1); + + Mojo::IOLoop->remove($hash->{helper}{mojoping}); + $ws->finish; + return undef; +} + +sub BOSEST_webSocketPing($$) { + my ($hash, $tx) = @_; + #reset requestid for ping to avoid overflows + $hash->{helper}{requestId} = 1 if($hash->{helper}{requestId} > 9999); + + $tx->send('
'); + #$tx->send([1, 0, 0, 0, WS_PING, 'Hello World!']); + return undef; +} + +sub BOSEST_webSocketReceivedMsg($$$) { + my ($hash, $tx, $msg) = @_; + + Log3 $hash, 5, "BOSEST: $hash->{NAME}, received message."; + + #parse XML + my $xml = ""; + eval { + $xml = XMLin($msg, KeepRoot => 1, ForceArray => [qw(media_server item member recent)], KeyAttr => []); + }; + + if($@) { + Log3 $hash, 3, "BOSEST: Wrong XML format: $@"; + } + + #process message + BOSEST_processXml($hash, $xml); + + $tx->resume; +} + +sub BOSEST_startWebSocketConnection($) { + my ($hash) = @_; + + Log3 $hash, 5, "BOSEST: $hash->{NAME}, start WebSocket connection."; + + $hash->{helper}{requestId} = 1; + + if($hash->{helper}{wsconnected} > 0) { + Log3 $hash, 3, "BOSEST: There are already $hash->{helper}{wsconnected} WebSockets connected."; + Log3 $hash, 3, "BOSEST: Prevent new connections."; + return undef; + } + + eval { + $hash->{helper}{bosewebsocket} = $hash->{helper}{useragent}->websocket('ws://'.$hash->{helper}{IP}.':8080' + => ['gabbo'] => sub { + my ($ua, $tx) = @_; + BOSEST_webSocketCallback($hash, $ua, $tx); + return undef; + }); + }; + + if($@) { + InternalTimer(gettimeofday()+5, "BOSEST_startWebSocketConnection", $hash, 1); + } + + $hash->{helper}{useragent}->inactivity_timeout(25); + $hash->{helper}{useragent}->request_timeout(10); + + Log3 $hash, 4, "BOSEST: $hash->{NAME}, WebSocket connected."; + + return undef; +} + +sub BOSEST_checkWebSocketConnection($) { + my ($hash) = @_; + if(defined($hash->{helper}{bosewebsocket})) { + #run mojo loop not longer than 0.5ms + my $id = Mojo::IOLoop->timer(0.0005 => sub {}); + Mojo::IOLoop->one_tick; + Mojo::IOLoop->remove($id); + } + + InternalTimer(gettimeofday()+0.7, "BOSEST_checkWebSocketConnection", $hash, 1); + + return undef; +} + +###### GENERIC ###### +sub BOSEST_getMyIp($) { + #Attention: Blocking function + my ($hash) = @_; + + my $socket = IO::Socket::INET->new( + Proto => 'udp', + PeerAddr => '198.41.0.4', #a.root-servers.net + PeerPort => '53' #DNS + ); + + my $local_ip_address = $socket->sockhost; + + return $local_ip_address; +} + +sub BOSEST_getSourceAccountByName($$) { + my ($hash, $sourceName) = @_; + + foreach my $source (@{$hash->{helper}{sources}}) { + if($source->{name} eq $sourceName) { + return $source->{sourceAccount}; + } + } + + return undef; +} + +sub BOSEST_getBosePlayerByDeviceId($$) { + my ($hash, $deviceId) = @_; + + if (defined($deviceId)) { + foreach my $fhem_dev (sort keys %main::defs) { + return $main::defs{$fhem_dev} if($main::defs{$fhem_dev}{TYPE} eq 'BOSEST' && $main::defs{$fhem_dev}{DEVICEID} eq $deviceId); + } + } else { + return $hash; + } + + return undef; +} + +sub BOSEST_getAllBosePlayers($) { + my ($hash) = @_; + my @players = (); + + foreach my $fhem_dev (sort keys %main::defs) { + push @players, $main::defs{$fhem_dev} if($main::defs{$fhem_dev}{TYPE} eq 'BOSEST' && $main::defs{$fhem_dev}{DEVICEID} ne "0"); + } + + return @players; +} + +sub BOSEST_sendKey($$) { + my ($hash, $key) = @_; + my $postXml = ''.$key.''; + if(BOSEST_HTTPPOST($hash, '/key', $postXml)) { + $postXml = ''.$key.''; + if(BOSEST_HTTPPOST($hash, '/key', $postXml)) { + return undef; + } + } + #FIXME error handling + return undef; +} + +sub BOSEST_HTTPGET($$$) { + my ($hash, $ip, $getURI) = @_; + + if(!defined($ip) or $ip eq "unknown") { + Log3 $hash, 3, "BOSEST: $hash->{NAME}, Can't HTTP GET as long as IP is unknown."; + return undef; + } + + my $ua = LWP::UserAgent->new(); + my $req = HTTP::Request->new(GET => 'http://'.$ip.':8090'.$getURI); + my $response = $ua->request($req); + if($response->is_success) { + my $xmlres = ""; + eval { + $xmlres = XMLin($response->decoded_content, KeepRoot => 1, ForceArray => [qw(media_server item member recent)], KeyAttr => []); + }; + + if($@) { + Log3 $hash, 3, "BOSEST: Wrong XML format: $@"; + return undef; + } + + return $xmlres; + } + + return undef; +} + +sub BOSEST_HTTPPOST($$$) { + my ($hash, $postURI, $postXml) = @_; + my $ua = LWP::UserAgent->new(); + my $ip = $hash->{helper}{IP}; + my $req = HTTP::Request->new(POST => 'http://'.$ip.':8090'.$postURI); + Log3 $hash, 4, "BOSEST: set ".$postURI." => ".$postXml; + $req->content($postXml); + + my $response = $ua->request($req); + if($response->is_success) { + Log3 $hash, 4, "BOSEST: success: ".$response->decoded_content; + my $xmlres = ""; + eval { + $xmlres = XMLin($response->decoded_content, KeepRoot => 1, ForceArray => [qw(media_server item member recent)], KeyAttr => []); + }; + + if($@) { + Log3 $hash, 3, "BOSEST: Wrong XML format: $@"; + return undef; + } + + return $xmlres; + } else { + #TODO return error + Log3 $hash, 3, "BOSEST: failed: ".$response->status_line; + return undef; + } + + return undef; +} + +sub BOSEST_XMLUpdate($$$) { + my ($hash, $readingName, $xmlItem) = @_; + + my $curVal = ReadingsVal($hash->{NAME}, $readingName, ""); + my $newVal = ""; + + #TODO update only on change + if(ref $xmlItem eq ref {}) { + if(keys %{$xmlItem}) { + $newVal = Encode::encode('UTF-8', $xmlItem); + } + } elsif($xmlItem) { + $newVal = Encode::encode('UTF-8', $xmlItem); + } + + if($curVal ne $newVal) { + readingsBulkUpdate($hash, $readingName, $newVal); + } + + return undef; +} + +sub BOSEST_readingsSingleUpdateIfChanged { + my ($hash, $reading, $value, $trigger) = @_; + my $curVal = ReadingsVal($hash->{NAME}, $reading, ""); + + if($curVal ne $value) { + readingsSingleUpdate($hash, $reading, $value, $trigger); + } +} + +1; + +=pod +=begin html + + +

BOSEST

+
    + BOSEST is used to control a BOSE SoundTouch system (one or more SoundTouch 10, 20 or 30 devices)

    + Note: The followig libraries are required for this module: +
    • libwww-perl
    • libmojolicious-perl
    • libxml-simple-perl
    • libnet-bonjour-perl
    • libev-perl

    • + Use sudo apt-get install libwww-perl libmojolicious-perl libxml-simple-perl libnet-bonjour-perl libev-perl to install this libraries.
      Please note: + libmojolicious-perl must be >=5.54, but under wheezy is only 2.x avaible.
      + Use sudo apt-get install cpanminus and sudo cpanm Mojolicious to update to the newest version

    + + + Define +
      + define <name> BOSEST
      +
      + Example: +
        + define bosesystem BOSEST
        + Defines BOSE SoundTouch system. All devices/speakers will show up after 60s under "Unsorted" in FHEM.
        +
      +
    + +
    + + + Set +
      + set <name> <command> [<parameter>]
      + The following commands are defined for the devices/speakers (execpt autoAddDLNAServers is for the "main" BOSEST) :

      +
        General commands +
      • on   -   power on the device
      • +
      • off   -   turn the device off
      • +
      • power   -   toggle on/off
      • +
      • volume [0...100] [+x|-x]   -   set the volume level in percentage or change volume by ±x from current level
      • +
      • channel 0...20   -   select present to play
      • +
      • saveChannel 07...20   -   save current channel to channel 07 to 20
      • +
      • play   -   start/resume to play
      • +
      • pause   -   pause the playback
      • +
      • stop   -   stop playback
      • +
      • nextTrack   -   play next track
      • +
      • prevTrack   -   play previous track
      • +
      • mute on|off|toggle   -   control volume mute
      • +
      • bass 0...10   -   set the bass level
      • +
      • recent 0...15   -   set number of names in the recent list in readings
      • +
      • source bluetooth,bt-discover,aux mode, airplay   -   select a local source

      • +
      • addDLNAServer Name1 [Name2] [Namex]   -   add DLNA servers Name1 (and Name2 to Namex) to the BOSE library
      • +
      • removeDLNAServer Name1 [Name2] [Namex]   -   remove DLNA servers Name1 (and Name2 to Namex) to the BOSE library
      • +

      Example: set BOSE_1234567890AB volume 25  Set volume on device with the name BOSE_1234567890AB


      + +
        Timer commands: +
      • on-for-timer 1...x   -   power on the device for x seconds
      • +
      • off-for-timer 1...x   -   turn the device off and power on again after x seconds
      • +
      • on-till hh:mm:ss   -   power on the device until defined time
      • +
      • off-till hh:mm:ss   -   turn the device off and power on again at defined time
      • +
      • on-till-overneight hh:mm:ss   -   power on the device until defined time on the next day
      • +
      • off-till-overneight hh:mm:ss   -   turn the device off at defined time on the next day
      • +

      Example: set BOSE_1234567890AB on-till 23:00:00  Switches device with the name BOSE_1234567890AB now on and at 23:00:00 off


      + +
        Multiroom commands: +
      • createZone deviceID   -   create multiroom zone (defines <name> as zoneMaster)
      • +
      • addToZone deviceID   -   add device <name> to multiroom zone
      • +
      • removeFromZone deviceID   -   remove device <name> from multiroom zone
      • +
      • playEverywhere   -   play sound of device <name> on all others devices
      • +
      • stopPlayEverywhere   -   stop playing sound on all devices
      • +

      Example: set BOSE_1234567890AB playEverywhere  Starts Multiroom with device with the name BOSE_1234567890AB as master


      + +
        TextToSpeach commands (needs Google Translate): +
      • speak "message" [0...100] [+x|-x] [en|de|xx]   -   Text to speak, optional with volume adjustment and language to use. The message to speak may have up to 100 letters
      • +
      • speakOff "message" [0...100] [+x|-x] [en|de|xx]   -   Text to speak, optional with volume adjustment and language to use. The message to speak may have up to 100 letters. Device is switched off after speak
      • +
      • ttsVolume [0...100] [+x|-x]   -   set the TTS volume level in percentage or change volume by ±x from current level
      • +
      • ttsDLNAServer "DLNA Server"   -   set DLNA TTS server, only needed if the DLNA server is not the FHEM server, a DLNA server running on the same server as FHEM is automatically added to the BOSE library
      • +
      • ttsDirectory "directory"   -   set DLNA TTS directory. FHEM user needs permissions to write to that directory.
      • +
      • ttsLanguage en|de|xx   -   set default TTS language (default: en)
      • +
      • ttsSpeakOnError 0|1   -   0=disable to speak "not available" text
      • +
      • autoAddDLNAServers 0|1   -   1=automatically add all DLNA servers to BOSE library. This command is only for "main" BOSEST, not for devices/speakers!

      • +

      Example: set BOSE_1234567890AB speakOff "Music is going to switch off now. Good night." 30 en  Speaks message at volume 30 and then switches off device.


      +

    + + + Get +
      + n/a +
    +
    + +
+ +=end html +=cut + diff --git a/fhem/FHEM/98_DLNARenderer.pm b/fhem/FHEM/98_DLNARenderer.pm new file mode 100644 index 000000000..4a2a20029 --- /dev/null +++ b/fhem/FHEM/98_DLNARenderer.pm @@ -0,0 +1,1598 @@ +############################################################################ +# 2016-07-18, v2.0.0, dominik.karall@gmail.com +# $Id$ +# +# v2.0.0 - 20160718 +# - CHANGE: first official release within fhem repository +# - BUGFIX: support device events without / at the end of the xmlns (thx@MichaelT) +# - FEATURE: support defaultRoom attribute, defines the room to which new devices are assigned +# +# v2.0.0 RC5 - 20160614 +# - BUGFIX: support events from devices with wrong serviceId +# - BUGFIX: fix perl warning on startup +# - BUGFIX: fix error if LastChange event is empty +# +# v2.0.0 RC4 - 20160613 +# - FEATURE: support devices with wrong serviceId +# - BUGFIX: fix crash during stereo mode update for caskeid players +# - FEATURE: add stereoPairName reading +# - CHANGE: add version string to main device internals +# - BUGFIX: fix error when UPnP method is not implemented +# - FEATURE: identify stereo support (reading: stereoSupport) +# +# v2.0.0 RC3 - 20160609 +# - BUGFIX: check correct number of params for all commands +# - BUGFIX: fix addUnitToSession/removeUnitFromSession for MUNET/Caskeid devices +# - BUGFIX: support devices with non-standard UUIDs +# - CHANGE: use BlockingCall for subscription renewal +# - CHANGE: remove ignoreUDNs attribute from play devices +# - CHANGE: remove multiRoomGroups attribute from main device +# - CHANGE: split stereoDevices reading into stereoLeft/stereoRight +# - FEATURE: support multiRoomVolume to change volume of all group speakers e.g. +# set multiRoomVolume +10 +# set multiRoomVolume 25 +# - FEATURE: support channel_01-10 attribute +# attr channel_01 http://... (save URI to channel_01) +# set channel 1 (play channel_01) +# - FEATURE: support speak functionality via Google Translate +# set speak "This is a test." +# attr ttsLanguage de +# set speak "Das ist ein Test." +# - FEATURE: automatically retrieve stereo mode from speakers and update stereoId/Left/Right readings +# - FEATURE: support mute +# set mute on/off +# +# v2.0.0 RC2 - 20160510 +# - BUGFIX: fix multiroom for MUNET/Caskeid devices +# +# v2.0.0 RC1 - 20160509 +# - CHANGE: change state to offline/playing/stopped/paused/online +# - CHANGE: removed on/off devstateicon on creation due to changed state values +# - CHANGE: play is NOT setting AVTransport any more +# - CHANGE: code cleanup +# - CHANGE: handle socket via fhem main loop instead of InternalTimer +# - BUGFIX: do not create new search objects every 30 minutes +# - FEATURE: support pauseToggle +# - FEATURE: support SetExtensions (on-for-timer, off-for-timer, ...) +# - FEATURE: support relative volume changes (e.g. set volume +10) +# +# v2.0.0 BETA3 - 20160504 +# - BUGFIX: XML parsing error "NOT_IMPLEMENTED" +# - CHANGE: change readings to lowcaseUppercase format +# - FEATURE: support pause +# - FEATURE: support seek REL_TIME +# - FEATURE: support next/prev +# +# v2.0.0 BETA2 - 20160403 +# - FEATURE: support events from DLNA devices +# - FEATURE: support caskeid group definitions +# set saveGroupAs Bad +# set loadGroup Bad +# - FEATURE: support caskeid stereo mode +# set stereo MUNET1 MUNET2 MunetStereoPaar +# set standalone +# - CHANGE: use UPnP::ControlPoint from FHEM library +# - BUGFIX: fix presence status +# +# v2.0.0 BETA1 - 20160321 +# - FEATURE: autodiscover and autocreate DLNA devices +# just use "define dlnadevices DLNARenderer" and wait 2 minutes +# - FEATURE: support Caskeid (e.g. MUNET devices) with following commands +# set playEverywhere +# set stopPlayEverywhere +# set addUnit +# set removeUnit +# set enableBTCaskeid +# set disableBTCaskeid +# - FEATURE: display multiroom speakers in multiRoomUnits reading +# - FEATURE: automatically set alias for friendlyname +# - FEATURE: automatically set webCmd volume +# - FEATURE: automatically set devStateIcon audio icons +# - FEATURE: ignoreUDNs attribute in main +# - FEATURE: scanInterval attribute in main +# +# DLNA Module to play given URLs on a DLNA Renderer +# and control their volume. Just define +# define dlnadevices DLNARenderer +# and look for devices in Unsorted section after 2 minutes. +# +#TODO +# - speak: support continue stream after speak finished +# - redesign multiroom functionality (virtual devices: represent the readings of master device +# and send the commands only to the master device (except volume?) +# automatically create group before playing +# - use bulk update for readings +# +############################################################################ + +package main; + +use strict; +use warnings; + +use Blocking; +use SetExtensions; + +use HTML::Entities; +use XML::Simple; +use Data::Dumper; +use Data::UUID; + +#get UPnP::ControlPoint loaded properly +my $gPath = ''; +BEGIN { + $gPath = substr($0, 0, rindex($0, '/')); +} +if (lc(substr($0, -7)) eq 'fhem.pl') { + $gPath = $attr{global}{modpath}.'/FHEM'; +} +use lib ($gPath.'/lib', $gPath.'/FHEM/lib', './FHEM/lib', './lib', './FHEM', './', '/usr/local/FHEM/share/fhem/FHEM/lib'); + +use UPnP::ControlPoint; + +sub DLNARenderer_Initialize($) { + my ($hash) = @_; + + $hash->{SetFn} = "DLNARenderer_Set"; + $hash->{DefFn} = "DLNARenderer_Define"; + $hash->{ReadFn} = "DLNARenderer_Read"; + $hash->{UndefFn} = "DLNARenderer_Undef"; + $hash->{AttrFn} = "DLNARenderer_Attribute"; +} + +sub DLNARenderer_Attribute { + my ($mode, $devName, $attrName, $attrValue) = @_; + #ignoreUDNs, multiRoomGroups, channel_01-10 + + if($mode eq "set") { + + } elsif($mode eq "del") { + + } + + return undef; +} + +sub DLNARenderer_Define($$) { + my ($hash, $def) = @_; + my @param = split("[ \t][ \t]*", $def); + + #init caskeid clients for multiroom + $hash->{helper}{caskeidClients} = ""; + $hash->{helper}{caskeid} = 0; + + if(@param < 3) { + #main + $hash->{UDN} = 0; + my $VERSION = "v2.0.0"; + $hash->{VERSION} = $VERSION; + Log3 $hash, 3, "DLNARenderer: DLNA Renderer $VERSION"; + DLNARenderer_setupControlpoint($hash); + DLNARenderer_startDlnaRendererSearch($hash); + readingsSingleUpdate($hash,"state","initialized",1); + addToDevAttrList($hash->{NAME}, "ignoreUDNs"); + addToDevAttrList($hash->{NAME}, "defaultRoom"); + return undef; + } + + #device specific + my $name = shift @param; + my $type = shift @param; + my $udn = shift @param; + $hash->{UDN} = $udn; + + readingsSingleUpdate($hash,"presence","offline",1); + readingsSingleUpdate($hash,"state","offline",1); + + addToDevAttrList($hash->{NAME}, "multiRoomGroups"); + addToDevAttrList($hash->{NAME}, "ttsLanguage"); + addToDevAttrList($hash->{NAME}, "channel_01"); + addToDevAttrList($hash->{NAME}, "channel_02"); + addToDevAttrList($hash->{NAME}, "channel_03"); + addToDevAttrList($hash->{NAME}, "channel_04"); + addToDevAttrList($hash->{NAME}, "channel_05"); + addToDevAttrList($hash->{NAME}, "channel_06"); + addToDevAttrList($hash->{NAME}, "channel_07"); + addToDevAttrList($hash->{NAME}, "channel_08"); + addToDevAttrList($hash->{NAME}, "channel_09"); + addToDevAttrList($hash->{NAME}, "channel_10"); + + InternalTimer(gettimeofday() + 200, 'DLNARenderer_renewSubscriptions', $hash, 0); + InternalTimer(gettimeofday() + 60, 'DLNARenderer_updateStereoMode', $hash, 0); + + return undef; +} + +sub DLNARenderer_Undef($) { + my ($hash) = @_; + + RemoveInternalTimer($hash); + return undef; +} + +sub DLNARenderer_Read($) { + my ($hash) = @_; + my $name = $hash->{NAME}; + my $phash = $hash->{phash}; + my $cp = $phash->{helper}{controlpoint}; + + eval { + $cp->handleOnce($hash->{CD}); + }; + + if($@) { + Log3 $hash, 3, "DLNARenderer: handleOnce failed, $@"; + } + + return undef; +} + +sub DLNARenderer_Set($@) { + my ($hash, $name, @params) = @_; + my $dev = $hash->{helper}{device}; + + # check parameters + return "no set value specified" if(int(@params) < 1); + my $ctrlParam = shift(@params); + + # check device presence + if ($ctrlParam ne "?" and (!defined($dev) or ReadingsVal($hash->{NAME}, "presence", "") eq "offline")) { + return "DLNARenderer: Currently searching for device..."; + } + + #get quoted text from params + my $blankParams = join(" ", @params); + my @params2; + while($blankParams =~ /"?((? {method => \&DLNARenderer_volume, args => 1, argdef => "slider,0,1,100"}, + mute => {method => \&DLNARenderer_mute, args => 1, argdef => "on,off"}, + pause => {method => \&DLNARenderer_upnpPause, args => 0}, + pauseToggle => {method => \&DLNARenderer_pauseToggle, args => 0}, + play => {method => \&DLNARenderer_play, args => 0}, + next => {method => \&DLNARenderer_upnpNext, args => 0}, + previous => {method => \&DLNARenderer_upnpPrevious, args => 0}, + seek => {method => \&DLNARenderer_seek, args => 1}, + multiRoomVolume => {method => \&DLNARenderer_setMultiRoomVolume, args => 1, argdef => "slider,0,1,100", caskeid => 1}, + stereo => {method => \&DLNARenderer_setStereoMode, args => 3, caskeid => 1}, + standalone => {method => \&DLNARenderer_setStandaloneMode, args => 0, caskeid => 1}, + playEverywhere => {method => \&DLNARenderer_playEverywhere, args => 0, caskeid => 1}, + stopPlayEverywhere => {method => \&DLNARenderer_stopPlayEverywhere, args => 0, caskeid => 1}, + addUnit => {method => \&DLNARenderer_addUnit, args => 1, argdef => $hash->{helper}{caskeidClients}, caskeid => 1}, + removeUnit => {method => \&DLNARenderer_removeUnit, args => 1, argdef => ReadingsVal($hash->{NAME}, "multiRoomUnits", ""), caskeid => 1}, + saveGroupAs => {method => \&DLNARenderer_saveGroupAs, args => 1, caskeid => 1}, + enableBTCaskeid => {method => \&DLNARenderer_enableBTCaskeid, args => 0, caskeid => 1}, + disableBTCaskeid => {method => \&DLNARenderer_disableBTCaskeid, args => 0, caskeid => 1}, + off => {method => \&DLNARenderer_upnpStop, args => 0}, + stop => {method => \&DLNARenderer_upnpStop, args => 0}, + loadGroup => {method => \&DLNARenderer_loadGroup, args => 1, caskeid => 1}, + on => {method => \&DLNARenderer_on, args => 0}, + stream => {method => \&DLNARenderer_stream, args => 1}, + channel => {method => \&DLNARenderer_channel, args => 1, argdef => "1,2,3,4,5,6,7,8,9,10"}, + speak => {method => \&DLNARenderer_speak, args => 1} + }; + + if($set_method_mapping->{$ctrlParam}) { + if($set_method_mapping->{$ctrlParam}{args} != int(@params)) { + return "DLNARenderer: $ctrlParam requires $set_method_mapping->{$ctrlParam}{args} parameter."; + } + #params array till args number + my @args = @params[0 .. $set_method_mapping->{$ctrlParam}{args}]; + $set_method_mapping->{$ctrlParam}{method}->($hash, @args); + } else { + my $cmdList; + foreach my $cmd (keys %$set_method_mapping) { + next if($hash->{helper}{caskeid} == 0 && $set_method_mapping->{$cmd}{caskeid} && $set_method_mapping->{$cmd}{caskeid} == 1); + if($set_method_mapping->{$cmd}{args} == 0) { + $cmdList .= $cmd.":noArg "; + } else { + if($set_method_mapping->{$cmd}{argdef}) { + $cmdList .= $cmd.":".$set_method_mapping->{$cmd}{argdef}." "; + } else { + $cmdList .= $cmd." "; + } + } + } + return SetExtensions($hash, $cmdList, $name, $ctrlParam, @params); + } + return undef; +} + +############################## +##### SET FUNCTIONS ########## +############################## +sub DLNARenderer_speak { + my ($hash, $ttsText) = @_; + my $ttsLang = AttrVal($hash->{NAME}, "ttsLanguage", "en"); + return "DLNARenderer: Maximum text length is 100 characters." if(length($ttsText) > 100); + + DLNARenderer_stream($hash, "http://translate.google.com/translate_tts?tl=$ttsLang&client=tw-ob&q=$ttsText"); +} + +sub DLNARenderer_channel { + my ($hash, $channelNr) = @_; + my $stream = AttrVal($hash->{NAME}, sprintf("channel_%02d", $channelNr), ""); + if($stream eq "") { + return "DLNARenderer: Set channel_XX attribute first."; + } + DLNARenderer_stream($hash, $stream); + readingsSingleUpdate($hash, "channel", $channelNr, 1); +} + +sub DLNARenderer_stream { + my ($hash, $stream) = @_; + DLNARenderer_upnpSetAVTransportURI($hash, $stream); + DLNARenderer_play($hash); + readingsSingleUpdate($hash, "stream", $stream, 1); +} + +sub DLNARenderer_on { + my ($hash) = @_; + if (defined($hash->{READINGS}{stream})) { + my $lastStream = $hash->{READINGS}{stream}{VAL}; + if ($lastStream) { + DLNARenderer_upnpSetAVTransportURI($hash, $lastStream); + DLNARenderer_play($hash); + } + } +} + +sub DLNARenderer_convertVolumeToAbsolute { + my ($hash, $targetVolume) = @_; + + if(substr($targetVolume, 0, 1) eq "+" or + substr($targetVolume, 0, 1) eq "-") { + $targetVolume = ReadingsVal($hash->{NAME}, "volume", 0) + $targetVolume; + } + return $targetVolume; +} + +sub DLNARenderer_volume { + my ($hash, $targetVolume) = @_; + + $targetVolume = DLNARenderer_convertVolumeToAbsolute($hash, $targetVolume); + + DLNARenderer_upnpSetVolume($hash, $targetVolume); +} + +sub DLNARenderer_mute { + my ($hash, $muteState) = @_; + + if($muteState eq "on") { + $muteState = 1; + } else { + $muteState = 0; + } + + DLNARenderer_upnpSetMute($hash, $muteState); +} + +sub DLNARenderer_removeUnit { + my ($hash, $unitToRemove) = @_; + DLNARenderer_removeUnitToPlay($hash, $unitToRemove); + + my $multiRoomUnitsReading = ""; + my @multiRoomUnits = split(",", ReadingsVal($hash->{NAME}, "multiRoomUnits", "")); + + foreach my $unit (@multiRoomUnits) { + $multiRoomUnitsReading .= ",".$unit if($unit ne $unitToRemove); + } + $multiRoomUnitsReading = substr($multiRoomUnitsReading, 1) if($multiRoomUnitsReading ne ""); + readingsSingleUpdate($hash, "multiRoomUnits", $multiRoomUnitsReading, 1); + + return undef; +} + +sub DLNARenderer_loadGroup { + my ($hash, $groupName) = @_; + my $groupMembers = DLNARenderer_getGroupDefinition($hash, $groupName); + return "DLNARenderer: Group $groupName not defined." if(!defined($groupMembers)); + DLNARenderer_destroyCurrentSession($hash); + + my $leftSpeaker = ""; + my $rightSpeaker = ""; + my @groupMembersArray = split(",", $groupMembers); + + foreach my $member (@groupMembersArray) { + if($member =~ /^R:([a-zA-Z0-9äöüßÄÜÖ_]+)/) { + $rightSpeaker = $1; + } elsif($member =~ /^L:([a-zA-Z0-9äöüßÄÜÖ_]+)/) { + $leftSpeaker = $1; + } else { + DLNARenderer_addUnit($hash, $member); + } + } + + if($leftSpeaker ne "" && $rightSpeaker ne "") { + DLNARenderer_setStereoMode($hash, $leftSpeaker, $rightSpeaker, $groupName); + } +} + +sub DLNARenderer_stopPlayEverywhere { + my ($hash) = @_; + DLNARenderer_destroyCurrentSession($hash); + readingsSingleUpdate($hash, "multiRoomUnits", "", 1); + return undef; +} + +sub DLNARenderer_playEverywhere { + my ($hash) = @_; + my $multiRoomUnits = ""; + my @caskeidClients = DLNARenderer_getAllDLNARenderersWithCaskeid($hash); + foreach my $client (@caskeidClients) { + if($client->{UDN} ne $hash->{UDN}) { + DLNARenderer_addUnitToPlay($hash, substr($client->{UDN},5)); + + my $multiRoomUnits = ReadingsVal($hash->{NAME}, "multiRoomUnits", ""); + + $multiRoomUnits .= "," if($multiRoomUnits ne ""); + $multiRoomUnits .= ReadingsVal($client->{NAME}, "friendlyName", ""); + readingsSingleUpdate($hash, "multiRoomUnits", $multiRoomUnits, 1); + } + } + return undef; +} + +sub DLNARenderer_setMultiRoomVolume { + my ($hash, $targetVolume) = @_; + + #change volume of this device + DLNARenderer_volume($hash, $targetVolume); + + #handle volume for all devices in the current group + #iterate through group and change volume relative to the current volume of this device + my $mainVolumeDiff = DLNARenderer_convertVolumeToAbsolute($hash, $targetVolume) - ReadingsVal($hash->{NAME}, "volume", 0); + my $multiRoomUnits = ReadingsVal($hash->{NAME}, "multiRoomUnits", ""); + my @multiRoomUnitsArray = split(",", $multiRoomUnits); + foreach my $unit (@multiRoomUnitsArray) { + my $devHash = DLNARenderer_getHashByFriendlyName($hash, $unit); + my $newVolume = ReadingsVal($devHash->{NAME}, "volume", 0) + $mainVolumeDiff; + if($newVolume > 100) { + $newVolume = 100; + } elsif($newVolume < 0) { + $newVolume = 0; + } + DLNARenderer_volume($devHash, $newVolume); + } + + return undef; +} + +sub DLNARenderer_pauseToggle { + my ($hash) = @_; + if($hash->{READINGS}{state} eq "paused") { + DLNARenderer_play($hash); + } else { + DLNARenderer_upnpPause($hash); + } +} + +sub DLNARenderer_play { + my ($hash) = @_; + + #start play + if($hash->{helper}{caskeid}) { + DLNARenderer_upnpSyncPlay($hash); + } else { + DLNARenderer_upnpPlay($hash); + } + + return undef; +} + +########################### +##### CASKEID ############# +########################### +# BTCaskeid +sub DLNARenderer_enableBTCaskeid { + my ($hash) = @_; + DLNARenderer_upnpAddToGroup($hash, "4DAA44C0-8291-11E3-BAA7-0800200C9A66", "Bluetooth"); +} + +sub DLNARenderer_disableBTCaskeid { + my ($hash) = @_; + DLNARenderer_upnpRemoveFromGroup($hash, "4DAA44C0-8291-11E3-BAA7-0800200C9A66"); +} + +# Stereo Mode +sub DLNARenderer_setStereoMode { + my ($hash, $leftSpeaker, $rightSpeaker, $name) = @_; + + DLNARenderer_destroyCurrentSession($hash); + + my @multiRoomDevices = DLNARenderer_getAllDLNARenderersWithCaskeid($hash); + my $uuid = DLNARenderer_createUuid($hash); + + foreach my $device (@multiRoomDevices) { + if(ReadingsVal($device->{NAME}, "friendlyName", "") eq $leftSpeaker) { + DLNARenderer_setMultiChannelSpeaker($device, "left", $uuid, $leftSpeaker); + readingsSingleUpdate($hash, "stereoLeft", $leftSpeaker, 1); + } elsif(ReadingsVal($device->{NAME}, "friendlyName", "") eq $rightSpeaker) { + DLNARenderer_setMultiChannelSpeaker($device, "right", $uuid, $rightSpeaker); + readingsSingleUpdate($hash, "stereoRight", $rightSpeaker, 1); + } + } +} + +sub DLNARenderer_updateStereoMode { + my ($hash) = @_; + + if(!defined($hash->{helper}{device})) { + InternalTimer(gettimeofday() + 10, 'DLNARenderer_updateStereoMode', $hash, 0); + return undef; + } + + if($hash->{helper}{caskeid} == 0) { + return undef; + } + + my $result = DLNARenderer_upnpGetMultiChannelSpeaker($hash); + if($result) { + InternalTimer(gettimeofday() + 300, 'DLNARenderer_updateStereoMode', $hash, 0); + DLNARenderer_readingsSingleUpdateIfChanged($hash, "stereoSupport", 1, 1); + } else { + #speaker does not support multi channel + DLNARenderer_readingsSingleUpdateIfChanged($hash, "stereoSupport", 0, 1); + return undef; + } + + my $mcsType = $result->getValue("CurrentMCSType"); + my $mcsId = $result->getValue("CurrentMCSID"); + my $mcsFriendlyName = $result->getValue("CurrentMCSFriendlyName"); + my $mcsSpeakerChannel = $result->getValue("CurrentSpeakerChannel"); + + DLNARenderer_readingsSingleUpdateIfChanged($hash, "stereoPairName", $mcsFriendlyName, 1); + DLNARenderer_readingsSingleUpdateIfChanged($hash, "stereoId", $mcsId, 1); + + if($mcsId eq "") { + DLNARenderer_readingsSingleUpdateIfChanged($hash, "stereoLeft", "", 1); + DLNARenderer_readingsSingleUpdateIfChanged($hash, "stereoRight", "", 1); + } else { + #THIS speaker is the left or right speaker + DLNARenderer_setStereoSpeakerReading($hash, $hash, $mcsType, $mcsId, $mcsFriendlyName, $mcsSpeakerChannel); + #set left/right speaker for OTHER speaker if OTHER speaker has same mcsId + my @allHashes = DLNARenderer_getAllDLNARenderersWithCaskeid($hash); + foreach my $hash2 (@allHashes) { + my $result2 = DLNARenderer_upnpGetMultiChannelSpeaker($hash2); + next if(!defined($result2)); + + my $mcsType2 = $result2->getValue("CurrentMCSType"); + my $mcsId2 = $result2->getValue("CurrentMCSID"); + my $mcsFriendlyName2 = $result2->getValue("CurrentMCSFriendlyName"); + my $mcsSpeakerChannel2 = $result2->getValue("CurrentSpeakerChannel"); + + if($mcsId2 eq $mcsId) { + DLNARenderer_setStereoSpeakerReading($hash, $hash2, $mcsType2, $mcsId2, $mcsFriendlyName2, $mcsSpeakerChannel2); + } + } + } +} + +sub DLNARenderer_setStereoSpeakerReading { + my ($hash, $speakerHash, $mcsType, $mcsId, $mcsFriendlyName, $mcsSpeakerChannel) = @_; + DLNARenderer_readingsSingleUpdateIfChanged($hash, "stereoId", $mcsId, 1); + if($mcsSpeakerChannel eq "LEFT_FRONT") { + DLNARenderer_readingsSingleUpdateIfChanged($hash, "stereoLeft", ReadingsVal($speakerHash->{NAME}, "friendlyName", ""), 1); + } elsif($mcsSpeakerChannel eq "RIGHT_FRONT") { + DLNARenderer_readingsSingleUpdateIfChanged($hash, "stereoRight", ReadingsVal($speakerHash->{NAME}, "friendlyName", ""), 1); + } +} + +sub DLNARenderer_readingsSingleUpdateIfChanged { + my ($hash, $reading, $value, $trigger) = @_; + my $curVal = ReadingsVal($hash->{NAME}, $reading, ""); + + if($curVal ne $value) { + readingsSingleUpdate($hash, $reading, $value, $trigger); + } +} + +sub DLNARenderer_setMultiChannelSpeaker { + my ($hash, $mode, $uuid, $name) = @_; + my $uuidStr; + + if($mode eq "standalone") { + DLNARenderer_upnpSetMultiChannelSpeaker($hash, "STANDALONE", "", "", "STANDALONE_SPEAKER"); + } elsif($mode eq "left") { + DLNARenderer_upnpSetMultiChannelSpeaker($hash, "STEREO", $uuid, $name, "LEFT_FRONT"); + } elsif($mode eq "right") { + DLNARenderer_upnpSetMultiChannelSpeaker($hash, "STEREO", $uuid, $name, "RIGHT_FRONT"); + } + + return undef; +} + +sub DLNARenderer_setStandaloneMode { + my ($hash) = @_; + my @multiRoomDevices = DLNARenderer_getAllDLNARenderersWithCaskeid($hash); + my $rightSpeaker = ReadingsVal($hash->{NAME}, "stereoRight", ""); + my $leftSpeaker = ReadingsVal($hash->{NAME}, "stereoLeft", ""); + + foreach my $device (@multiRoomDevices) { + if(ReadingsVal($device->{NAME}, "friendlyName", "") eq $leftSpeaker or + ReadingsVal($device->{NAME}, "friendlyName", "") eq $rightSpeaker) { + DLNARenderer_setMultiChannelSpeaker($device, "standalone", "", ""); + } + } + + readingsSingleUpdate($hash, "stereoLeft", "", 1); + readingsSingleUpdate($hash, "stereoRight", "", 1); + readingsSingleUpdate($hash, "stereoId", "", 1); + + return undef; +} + +sub DLNARenderer_createUuid { + my ($hash) = @_; + my $ug = Data::UUID->new(); + my $uuid = $ug->create(); + my $uuidStr = $ug->to_string($uuid); + + return $uuidStr; +} + +# SessionManagement +sub DLNARenderer_createSession { + my ($hash) = @_; + return DLNARenderer_upnpCreateSession($hash, "FHEM_Session"); +} + +sub DLNARenderer_getSession { + my ($hash) = @_; + return DLNARenderer_upnpGetSession($hash); +} + +sub DLNARenderer_destroySession { + my ($hash, $session) = @_; + + return DLNARenderer_upnpDestroySession($hash, $session); +} + +sub DLNARenderer_destroyCurrentSession { + my ($hash) = @_; + + my $result = DLNARenderer_getSession($hash); + if($result->getValue("SessionID") ne "") { + DLNARenderer_destroySession($hash, $result->getValue("SessionID")); + } +} + +sub DLNARenderer_addUnitToPlay { + my ($hash, $unit) = @_; + + my $session = DLNARenderer_getSession($hash)->getValue("SessionID"); + + if($session eq "") { + $session = DLNARenderer_createSession($hash)->getValue("SessionID"); + } + + DLNARenderer_addUnitToSession($hash, $unit, $session); +} + +sub DLNARenderer_removeUnitToPlay { + my ($hash, $unit) = @_; + + my $session = DLNARenderer_getSession($hash)->getValue("SessionID"); + + if($session ne "") { + DLNARenderer_removeUnitFromSession($hash, $unit, $session); + } +} + +sub DLNARenderer_addUnitToSession { + my ($hash, $uuid, $session) = @_; + + return DLNARenderer_upnpAddUnitToSession($hash, $session, $uuid); +} + +sub DLNARenderer_removeUnitFromSession { + my ($hash, $uuid, $session) = @_; + + return DLNARenderer_upnpRemoveUnitFromSession($hash, $session, $uuid); +} + +# Group Definitions +sub DLNARenderer_getGroupDefinition { + #used for ... play Bad ... + my ($hash, $groupName) = @_; + my $currentGroupSettings = AttrVal($hash->{NAME}, "multiRoomGroups", ""); + + #regex Bad[MUNET1,MUNET2],WZ[L:MUNET2,R:MUNET3],... + while ($currentGroupSettings =~ /([a-zA-Z0-9äöüßÄÜÖ_]+)\[([a-zA-Z,0-9:äöüßÄÜÖ_]+)/g) { + my $group = $1; + my $groupMembers = $2; + + Log3 $hash, 4, "DLNARenderer: Groupdefinition $group => $groupMembers"; + + if($group eq $groupName) { + return $groupMembers; + } + } + + return undef; +} + +sub DLNARenderer_saveGroupAs { + my ($hash, $groupName) = @_; + my $currentGroupSettings = AttrVal($hash->{NAME}, "multiRoomGroups", ""); + $currentGroupSettings .= "," if($currentGroupSettings ne ""); + + #session details + my $currentSession = ReadingsVal($hash->{NAME}, "multiRoomUnits", ""); + #stereo mode + my $stereoLeft = ReadingsVal($hash->{NAME}, "stereoLeft", ""); + my $stereoRight = ReadingsVal($hash->{NAME}, "stereoRight", ""); + my $stereoDevices = "L:$stereoLeft,R:$stereoRight" if($stereoLeft ne "" && $stereoRight ne ""); + + return undef if($currentSession eq "" && $stereoLeft eq "" && $stereoRight eq ""); + $stereoDevices .= "," if($currentSession ne "" && $stereoDevices ne ""); + + my $groupDefinition = $currentGroupSettings.$groupName."[".$stereoDevices.$currentSession."]"; + + #save current session as group + CommandAttr(undef, "$hash->{NAME} multiRoomGroups $groupDefinition"); + + return undef; +} + +sub DLNARenderer_addUnit { + my ($hash, $unitName) = @_; + + my @caskeidClients = DLNARenderer_getAllDLNARenderersWithCaskeid($hash); + foreach my $client (@caskeidClients) { + if(ReadingsVal($client->{NAME}, "friendlyName", "") eq $unitName) { + my @multiRoomUnits = split(",", ReadingsVal($hash->{NAME}, "multiRoomUnits", "")); + foreach my $unit (@multiRoomUnits) { + #skip if unit is already part of the session + return undef if($unit eq $unitName); + } + #add unit to session + DLNARenderer_addUnitToPlay($hash, substr($client->{UDN},5)); + return undef; + } + } + return "DLNARenderer: No unit $unitName found."; +} + +############################## +####### UPNP FUNCTIONS ####### +############################## +sub DLNARenderer_upnpPause { + my ($hash) = @_; + return DLNARenderer_upnpCallAVTransport($hash, "Pause", 0); +} + +sub DLNARenderer_upnpSetAVTransportURI { + my ($hash, $stream) = @_; + return DLNARenderer_upnpCallAVTransport($hash, "SetAVTransportURI", 0, $stream, ""); +} + +sub DLNARenderer_upnpStop { + my ($hash) = @_; + return DLNARenderer_upnpCallAVTransport($hash, "Stop", 0); +} + +sub DLNARenderer_upnpSeek { + my ($hash, $seekTime) = @_; + return DLNARenderer_upnpCallAVTransport($hash, "Seek", 0, "REL_TIME", $seekTime); +} + +sub DLNARenderer_upnpNext { + my ($hash) = @_; + return DLNARenderer_upnpCallAVTrasnport($hash, "Next", 0); +} + +sub DLNARenderer_upnpPrevious { + my ($hash) = @_; + return DLNARenderer_upnpCallAVTrasnport($hash, "Previous", 0); +} + +sub DLNARenderer_upnpPlay { + my ($hash) = @_; + return DLNARenderer_upnpCallAVTransport($hash, "Play", 0, 1); +} + +sub DLNARenderer_upnpSyncPlay { + my ($hash) = @_; + return DLNARenderer_upnpCallAVTransport($hash, "SyncPlay", 0, 1, "REL_TIME", "", "", "", "DeviceClockId"); +} + +sub DLNARenderer_upnpCallAVTransport { + my ($hash, $method, @args) = @_; + return DLNARenderer_upnpCall($hash, 'AVTransport', $method, @args); +} + +sub DLNARenderer_upnpGetMultiChannelSpeaker { + my ($hash) = @_; + return DLNARenderer_upnpCallSpeakerManagement($hash, "GetMultiChannelSpeaker"); +} + +sub DLNARenderer_upnpSetMultiChannelSpeaker { + my ($hash, @args) = @_; + return DLNARenderer_upnpCallSpeakerManagement($hash, "SetMultiChannelSpeaker", @args); +} + +sub DLNARenderer_upnpCallSpeakerManagement { + my ($hash, $method, @args) = @_; + return DLNARenderer_upnpCall($hash, 'SpeakerManagement', $method, @args); +} + +sub DLNARenderer_upnpAddUnitToSession { + my ($hash, $session, $uuid) = @_; + return DLNARenderer_upnpCallSessionManagement($hash, "AddUnitToSession", $session, $uuid); +} + +sub DLNARenderer_upnpRemoveUnitFromSession { + my ($hash, $session, $uuid) = @_; + return DLNARenderer_upnpCallSessionManagement($hash, "RemoveUnitFromSession", $session, $uuid); +} + +sub DLNARenderer_upnpDestroySession { + my ($hash, $session) = @_; + return DLNARenderer_upnpCallSessionManagement($hash, "DestroySession", $session); +} + +sub DLNARenderer_upnpCreateSession { + my ($hash, $name) = @_; + return DLNARenderer_upnpCallSessionManagement($hash, "CreateSession", $name); +} + +sub DLNARenderer_upnpGetSession { + my ($hash) = @_; + return DLNARenderer_upnpCallSessionManagement($hash, "GetSession"); +} + +sub DLNARenderer_upnpAddToGroup { + my ($hash, $unit, $name) = @_; + return DLNARenderer_upnpCallSpeakerManagement($hash, "AddToGroup", $unit, $name, ""); +} + +sub DLNARenderer_upnpRemoveFromGroup { + my ($hash, $unit) = @_; + return DLNARenderer_upnpCallSpeakerManagement($hash, "RemoveFromGroup", $unit); +} + +sub DLNARenderer_upnpCallSessionManagement { + my ($hash, $method, @args) = @_; + return DLNARenderer_upnpCall($hash, 'SessionManagement', $method, @args); +} + +sub DLNARenderer_upnpSetVolume { + my ($hash, $targetVolume) = @_; + return DLNARenderer_upnpCallRenderingControl($hash, "SetVolume", 0, "Master", $targetVolume); +} + +sub DLNARenderer_upnpSetMute { + my ($hash, $muteState) = @_; + return DLNARenderer_upnpCallRenderingControl($hash, "SetMute", 0, "Master", $muteState); +} + +sub DLNARenderer_upnpCallRenderingControl { + my ($hash, $method, @args) = @_; + return DLNARenderer_upnpCall($hash, 'RenderingControl', $method, @args); +} + +sub DLNARenderer_upnpCall { + my ($hash, $service, $method, @args) = @_; + my $upnpService = DLNARenderer_upnpGetService($hash, $service); + my $ret = undef; + + eval { + my $upnpServiceCtrlProxy = $upnpService->controlProxy(); + my $methodExists = $upnpService->getAction($method); + if($methodExists) { + $ret = $upnpServiceCtrlProxy->$method(@args); + Log3 $hash, 5, "DLNARenderer: $service, $method(".join(",",@args).") succeed."; + } else { + Log3 $hash, 4, "DLNARenderer: $service, $method(".join(",",@args).") does not exist."; + } + }; + + if($@) { + Log3 $hash, 3, "DLNARenderer: $service, $method(".join(",",@args).") failed, $@"; + return undef; + } + return $ret; +} + +sub DLNARenderer_upnpGetService { + my ($hash, $service) = @_; + my $upnpService; + + foreach my $srvc ($hash->{helper}{device}->services) { + my @srvcParts = split(":", $srvc->serviceType); + my $serviceName = $srvcParts[-2]; + if($serviceName eq $service) { + Log3 $hash, 5, "DLNARenderer: $service: ".$srvc->serviceType." found. OK."; + $upnpService = $srvc; + } + } + + if(!defined($upnpService)) { + Log3 $hash, 4, "DLNARenderer: $service unknown for $hash->{NAME}."; + return undef; + } + + return $upnpService; +} + + +############################## +####### EVENT HANDLING ####### +############################## +sub DLNARenderer_processEventXml { + my ($hash, $property, $xml) = @_; + + Log3 $hash, 4, "DLNARenderer: ".Dumper($xml); + + if($property eq "LastChange") { + return undef if($xml eq ""); + + if($xml->{Event}) { + if (index($xml->{Event}{xmlns},"urn:schemas-upnp-org:metadata-1-0/AVT")==0) { + #process AV Transport + my $e = $xml->{Event}{InstanceID}; + #DLNARenderer_updateReadingByEvent($hash, "NumberOfTracks", $e->{NumberOfTracks}); + DLNARenderer_updateReadingByEvent($hash, "transportState", $e->{TransportState}); + DLNARenderer_updateReadingByEvent($hash, "transportStatus", $e->{TransportStatus}); + #DLNARenderer_updateReadingByEvent($hash, "TransportPlaySpeed", $e->{TransportPlaySpeed}); + #DLNARenderer_updateReadingByEvent($hash, "PlaybackStorageMedium", $e->{PlaybackStorageMedium}); + #DLNARenderer_updateReadingByEvent($hash, "RecordStorageMedium", $e->{RecordStorageMedium}); + #DLNARenderer_updateReadingByEvent($hash, "RecordMediumWriteStatus", $e->{RecordMediumWriteStatus}); + #DLNARenderer_updateReadingByEvent($hash, "CurrentRecordQualityMode", $e->{CurrentRecordQualityMode}); + #DLNARenderer_updateReadingByEvent($hash, "PossibleRecordQualityMode", $e->{PossibleRecordQualityMode}); + DLNARenderer_updateReadingByEvent($hash, "currentTrackURI", $e->{CurrentTrackURI}); + #DLNARenderer_updateReadingByEvent($hash, "AVTransportURI", $e->{AVTransportURI}); + DLNARenderer_updateReadingByEvent($hash, "nextAVTransportURI", $e->{NextAVTransportURI}); + #DLNARenderer_updateReadingByEvent($hash, "RelativeTimePosition", $e->{RelativeTimePosition}); + #DLNARenderer_updateReadingByEvent($hash, "AbsoluteTimePosition", $e->{AbsoluteTimePosition}); + #DLNARenderer_updateReadingByEvent($hash, "RelativeCounterPosition", $e->{RelativeCounterPosition}); + #DLNARenderer_updateReadingByEvent($hash, "AbsoluteCounterPosition", $e->{AbsoluteCounterPosition}); + #DLNARenderer_updateReadingByEvent($hash, "CurrentTrack", $e->{CurrentTrack}); + #DLNARenderer_updateReadingByEvent($hash, "CurrentMediaDuration", $e->{CurrentMediaDuration}); + #DLNARenderer_updateReadingByEvent($hash, "CurrentTrackDuration", $e->{CurrentTrackDuration}); + #DLNARenderer_updateReadingByEvent($hash, "CurrentPlayMode", $e->{CurrentPlayMode}); + #handle metadata + #DLNARenderer_updateReadingByEvent($hash, "AVTransportURIMetaData", $e->{AVTransportURIMetaData}); + #DLNARenderer_updateMetaData($hash, "current", $e->{AVTransportURIMetaData}); + #DLNARenderer_updateReadingByEvent($hash, "NextAVTransportURIMetaData", $e->{NextAVTransportURIMetaData}); + DLNARenderer_updateMetaData($hash, "next", $e->{NextAVTransportURIMetaData}); + #use only CurrentTrackMetaData instead of AVTransportURIMetaData + #DLNARenderer_updateReadingByEvent($hash, "CurrentTrackMetaData", $e->{CurrentTrackMetaData}); + DLNARenderer_updateMetaData($hash, "current", $e->{CurrentTrackMetaData}); + + #update state + my $transportState = ReadingsVal($hash->{NAME}, "transportState", ""); + if(ReadingsVal($hash->{NAME}, "presence", "") ne "offline") { + if($transportState eq "PAUSED_PLAYBACK") { + readingsSingleUpdate($hash, "state", "paused", 1); + } elsif($transportState eq "PLAYING") { + readingsSingleUpdate($hash, "state", "playing", 1); + } elsif($transportState eq "TRANSITIONING") { + readingsSingleUpdate($hash, "state", "buffering", 1); + } elsif($transportState eq "STOPPED") { + readingsSingleUpdate($hash, "state", "stopped", 1); + } elsif($transportState eq "NO_MEDIA_PRESENT") { + readingsSingleUpdate($hash, "state", "online", 1); + } + } + } elsif (index($xml->{Event}{xmlns},"urn:schemas-upnp-org:metadata-1-0/RCS")==0) { + #process RenderingControl + my $e = $xml->{Event}{InstanceID}; + DLNARenderer_updateVolumeByEvent($hash, "mute", $e->{Mute}); + DLNARenderer_updateVolumeByEvent($hash, "volume", $e->{Volume}); + readingsSingleUpdate($hash, "multiRoomVolume", ReadingsVal($hash->{NAME}, "volume", 0), 1); + } elsif ($xml->{Event}{xmlns} eq "FIXME SpeakerManagement") { + #process SpeakerManagement + } + } + } elsif($property eq "Groups") { + #handle BTCaskeid + my $btCaskeidState = 0; + foreach my $group (@{$xml->{groups}{group}}) { + #"4DAA44C0-8291-11E3-BAA7-0800200C9A66", "Bluetooth" + if($group->{id} eq "4DAA44C0-8291-11E3-BAA7-0800200C9A66") { + $btCaskeidState = 1; + } + } + #TODO update only if changed + readingsSingleUpdate($hash, "btCaskeid", $btCaskeidState, 1); + } elsif($property eq "SessionID") { + #TODO search for other speakers with same sessionId and add them to multiRoomUnits + readingsSingleUpdate($hash, "sessionId", $xml, 1); + } + + return undef; +} + +sub DLNARenderer_updateReadingByEvent { + my ($hash, $readingName, $xmlEvent) = @_; + + my $currVal = ReadingsVal($hash->{NAME}, $readingName, ""); + + if($xmlEvent) { + Log3 $hash, 4, "DLNARenderer: Update reading $readingName with ".$xmlEvent->{val}; + my $val = $xmlEvent->{val}; + $val = "" if(ref $val eq ref {}); + if($val ne $currVal) { + readingsSingleUpdate($hash, $readingName, $val, 1); + } + } + + return undef; +} + +sub DLNARenderer_updateVolumeByEvent { + my ($hash, $readingName, $volume) = @_; + my $balance = 0; + my $balanceSupport = 0; + + foreach my $vol (@{$volume}) { + my $channel = $vol->{Channel} ? $vol->{Channel} : $vol->{channel}; + if($channel) { + if($channel eq "Master") { + DLNARenderer_updateReadingByEvent($hash, $readingName, $vol); + } elsif($channel eq "LF") { + $balance -= $vol->{val}; + $balanceSupport = 1; + } elsif($channel eq "RF") { + $balance += $vol->{val}; + $balanceSupport = 1; + } + } else { + DLNARenderer_updateReadingByEvent($hash, $readingName, $vol); + } + } + + if($readingName eq "volume" && $balanceSupport == 1) { + readingsSingleUpdate($hash, "balance", $balance, 1); + } + + return undef; +} + +sub DLNARenderer_updateMetaData { + my ($hash, $prefix, $metaData) = @_; + my $metaDataAvailable = 0; + + $metaDataAvailable = 1 if(defined($metaData) && $metaData->{val} && $metaData->{val} ne ""); + + if($metaDataAvailable) { + my $xml; + if($metaData->{val} eq "NOT_IMPLEMENTED") { + readingsSingleUpdate($hash, $prefix."Title", "", 1); + readingsSingleUpdate($hash, $prefix."Artist", "", 1); + readingsSingleUpdate($hash, $prefix."Album", "", 1); + readingsSingleUpdate($hash, $prefix."AlbumArtist", "", 1); + readingsSingleUpdate($hash, $prefix."AlbumArtURI", "", 1); + readingsSingleUpdate($hash, $prefix."OriginalTrackNumber", "", 1); + readingsSingleUpdate($hash, $prefix."Duration", "", 1); + } else { + eval { + $xml = XMLin($metaData->{val}, KeepRoot => 1, ForceArray => [], KeyAttr => []); + Log3 $hash, 4, "DLNARenderer: MetaData: ".Dumper($xml); + }; + + if(!$@) { + DLNARenderer_updateMetaDataItemPart($hash, $prefix."Title", $xml->{"DIDL-Lite"}{item}{"dc:title"}); + DLNARenderer_updateMetaDataItemPart($hash, $prefix."Artist", $xml->{"DIDL-Lite"}{item}{"dc:creator"}); + DLNARenderer_updateMetaDataItemPart($hash, $prefix."Album", $xml->{"DIDL-Lite"}{item}{"upnp:album"}); + DLNARenderer_updateMetaDataItemPart($hash, $prefix."AlbumArtist", $xml->{"DIDL-Lite"}{item}{"r:albumArtist"}); + if($xml->{"DIDL-Lite"}{item}{"upnp:albumArtURI"}) { + DLNARenderer_updateMetaDataItemPart($hash, $prefix."AlbumArtURI", $xml->{"DIDL-Lite"}{item}{"upnp:albumArtURI"}); + } else { + readingsSingleUpdate($hash, $prefix."AlbumArtURI", "", 1); + } + DLNARenderer_updateMetaDataItemPart($hash, $prefix."OriginalTrackNumber", $xml->{"DIDL-Lite"}{item}{"upnp:originalTrackNumber"}); + if($xml->{"DIDL-Lite"}{item}{res}) { + DLNARenderer_updateMetaDataItemPart($hash, $prefix."Duration", $xml->{"DIDL-Lite"}{item}{res}{duration}); + } else { + readingsSingleUpdate($hash, $prefix."Duration", "", 1); + } + } else { + Log3 $hash, 1, "DLNARenderer: XML parsing error: ".$@; + } + } + } + + return undef; +} + +sub DLNARenderer_updateMetaDataItemPart { + my ($hash, $readingName, $item) = @_; + + my $currVal = ReadingsVal($hash->{NAME}, $readingName, ""); + if($item) { + $item = "" if(ref $item eq ref {}); + if($currVal ne $item) { + readingsSingleUpdate($hash, $readingName, $item, 1); + } + } + + return undef; +} + +############################## +####### DISCOVERY ############ +############################## +sub DLNARenderer_setupControlpoint { + my ($hash) = @_; + my %empty = (); + my $error; + my $cp; + + do { + eval { + $cp = UPnP::ControlPoint->new(SearchPort => 0, SubscriptionPort => 0, MaxWait => 30, UsedOnlyIP => \%empty, IgnoreIP => \%empty); + $hash->{helper}{controlpoint} = $cp; + + DLNARenderer_addSocketsToMainloop($hash); + }; + $error = $@; + } while($error); + + return undef; +} + +sub DLNARenderer_startDlnaRendererSearch { + my ($hash) = @_; + + eval { + $hash->{helper}{controlpoint}->searchByType('urn:schemas-upnp-org:device:MediaRenderer:1', sub { DLNARenderer_discoverCallback($hash, @_); }); + }; + if($@) { + Log3 $hash, 2, "DLNARenderer: Search failed with error $@"; + } + return undef; +} + +sub DLNARenderer_discoverCallback { + my ($hash, $search, $device, $action) = @_; + + Log3 $hash, 4, "DLNARenderer: $action, ".$device->friendlyName(); + + if($action eq "deviceAdded") { + DLNARenderer_addedDevice($hash, $device); + } elsif($action eq "deviceRemoved") { + DLNARenderer_removedDevice($hash, $device); + } + return undef; +} + +sub DLNARenderer_subscriptionCallback { + my ($hash, $service, %properties) = @_; + + Log3 $hash, 4, "DLNARenderer: Received event: ".Dumper(%properties); + + foreach my $property (keys %properties) { + + $properties{$property} = decode_entities($properties{$property}); + + my $xml; + eval { + if($properties{$property} =~ /xml/) { + $xml = XMLin($properties{$property}, KeepRoot => 1, ForceArray => [qw(Volume Mute Loudness VolumeDB group)], KeyAttr => []); + } else { + $xml = $properties{$property}; + } + }; + + if($@) { + Log3 $hash, 2, "DLNARenderer: XML formatting error: ".$@.", ".$properties{$property}; + next; + } + + DLNARenderer_processEventXml($hash, $property, $xml); + } + + return undef; +} + +sub DLNARenderer_renewSubscriptions { + my ($hash) = @_; + my $dev = $hash->{helper}{device}; + + InternalTimer(gettimeofday() + 200, 'DLNARenderer_renewSubscriptions', $hash, 0); + + return undef if(!defined($dev)); + + BlockingCall('DLNARenderer_renewSubscriptionBlocking', $hash->{NAME}); + + return undef; +} + +sub DLNARenderer_renewSubscriptionBlocking { + my ($string) = @_; + my ($name) = split("\\|", $string); + my $hash = $main::defs{$name}; + + #register callbacks + #urn:upnp-org:serviceId:AVTransport + eval { + if(defined($hash->{helper}{avTransportSubscription})) { + $hash->{helper}{avTransportSubscription}->renew(); + } + }; + + #urn:upnp-org:serviceId:RenderingControl + eval { + if(defined($hash->{helper}{renderingControlSubscription})) { + $hash->{helper}{renderingControlSubscription}->renew(); + } + }; + + #urn:pure-com:serviceId:SpeakerManagement + eval { + if(defined($hash->{helper}{speakerManagementSubscription})) { + $hash->{helper}{speakerManagementSubscription}->renew(); + } + }; +} + +sub DLNARenderer_addedDevice { + my ($hash, $dev) = @_; + + my $udn = $dev->UDN(); + + #TODO check for BOSE UDN + + #ignoreUDNs + return undef if(AttrVal($hash->{NAME}, "ignoreUDNs", "") =~ /$udn/); + + my $foundDevice = 0; + my @allDLNARenderers = DLNARenderer_getAllDLNARenderers($hash); + foreach my $DLNARendererHash (@allDLNARenderers) { + if($DLNARendererHash->{UDN} eq $dev->UDN()) { + $foundDevice = 1; + } + } + + if(!$foundDevice) { + my $uniqueDeviceName = "DLNA_".substr($dev->UDN(),29,12); + if(length($uniqueDeviceName) < 17) { + $uniqueDeviceName = "DLNA_".substr($dev->UDN(),5); + $uniqueDeviceName =~ tr/-/_/; + } + CommandDefine(undef, "$uniqueDeviceName DLNARenderer ".$dev->UDN()); + CommandAttr(undef,"$uniqueDeviceName alias ".$dev->friendlyName()); + CommandAttr(undef,"$uniqueDeviceName webCmd volume"); + if(AttrVal($hash->{NAME}, "defaultRoom", "") ne "") { + CommandAttr(undef,"$uniqueDeviceName room ".AttrVal($hash->{NAME}, "defaultRoom", "")); + } + Log3 $hash, 3, "DLNARenderer: Created device $uniqueDeviceName for ".$dev->friendlyName(); + + #update list + @allDLNARenderers = DLNARenderer_getAllDLNARenderers($hash); + } + + foreach my $DLNARendererHash (@allDLNARenderers) { + if($DLNARendererHash->{UDN} eq $dev->UDN()) { + #device found, update data + $DLNARendererHash->{helper}{device} = $dev; + + #update device information (FIXME only on change) + readingsSingleUpdate($DLNARendererHash, "friendlyName", $dev->friendlyName(), 1); + readingsSingleUpdate($DLNARendererHash, "manufacturer", $dev->manufacturer(), 1); + readingsSingleUpdate($DLNARendererHash, "modelDescription", $dev->modelDescription(), 1); + readingsSingleUpdate($DLNARendererHash, "modelName", $dev->modelName(), 1); + readingsSingleUpdate($DLNARendererHash, "modelNumber", $dev->modelNumber(), 1); + readingsSingleUpdate($DLNARendererHash, "modelURL", $dev->modelURL(), 1); + readingsSingleUpdate($DLNARendererHash, "manufacturerURL", $dev->manufacturerURL(), 1); + readingsSingleUpdate($DLNARendererHash, "presentationURL", $dev->presentationURL(), 1); + readingsSingleUpdate($DLNARendererHash, "manufacturer", $dev->manufacturer(), 1); + + #register callbacks + #urn:upnp-org:serviceId:AVTransport + if(DLNARenderer_upnpGetService($DLNARendererHash, "AVTransport")) { + $DLNARendererHash->{helper}{avTransportSubscription} = DLNARenderer_upnpGetService($DLNARendererHash, "AVTransport")->subscribe(sub { DLNARenderer_subscriptionCallback($DLNARendererHash, @_); }); + } + #urn:upnp-org:serviceId:RenderingControl + if(DLNARenderer_upnpGetService($DLNARendererHash, "RenderingControl")) { + $DLNARendererHash->{helper}{renderingControlSubscription} = DLNARenderer_upnpGetService($DLNARendererHash, "RenderingControl")->subscribe(sub { DLNARenderer_subscriptionCallback($DLNARendererHash, @_); }); + } + #urn:pure-com:serviceId:SpeakerManagement + if(DLNARenderer_upnpGetService($DLNARendererHash, "SpeakerManagement")) { + $DLNARendererHash->{helper}{speakerManagementSubscription} = DLNARenderer_upnpGetService($DLNARendererHash, "SpeakerManagement")->subscribe(sub { DLNARenderer_subscriptionCallback($DLNARendererHash, @_); }); + } + + #set online + readingsSingleUpdate($DLNARendererHash,"presence","online",1); + if(ReadingsVal($DLNARendererHash->{NAME}, "state", "") eq "offline") { + readingsSingleUpdate($DLNARendererHash,"state","online",1); + } + + #check caskeid + if(DLNARenderer_upnpGetService($DLNARendererHash, "SessionManagement")) { + $DLNARendererHash->{helper}{caskeid} = 1; + readingsSingleUpdate($DLNARendererHash,"multiRoomSupport","1",1); + } else { + readingsSingleUpdate($DLNARendererHash,"multiRoomSupport","0",1); + } + + #update list of caskeid clients + my @caskeidClients = DLNARenderer_getAllDLNARenderersWithCaskeid($hash); + $DLNARendererHash->{helper}{caskeidClients} = ""; + foreach my $client (@caskeidClients) { + #do not add myself + if($client->{UDN} ne $DLNARendererHash->{UDN}) { + $DLNARendererHash->{helper}{caskeidClients} .= ",".ReadingsVal($client->{NAME}, "friendlyName", ""); + } + } + $DLNARendererHash->{helper}{caskeidClients} = substr($DLNARendererHash->{helper}{caskeidClients}, 1) if($DLNARendererHash->{helper}{caskeidClients} ne ""); + } + } + + return undef; +} + +sub DLNARenderer_removedDevice($$) { + my ($hash, $device) = @_; + my $deviceHash = DLNARenderer_getHashByUDN($hash, $device->UDN()); + + readingsSingleUpdate($deviceHash, "presence", "offline", 1); + readingsSingleUpdate($deviceHash, "state", "offline", 1); +} + +############################### +##### GET PLAYER FUNCTIONS #### +############################### +sub DLNARenderer_getMainDLNARenderer($) { + my ($hash) = @_; + + foreach my $fhem_dev (sort keys %main::defs) { + return $main::defs{$fhem_dev} if($main::defs{$fhem_dev}{TYPE} eq 'DLNARenderer' && $main::defs{$fhem_dev}{UDN} eq "0"); + } + + return undef; +} + +sub DLNARenderer_getHashByUDN($$) { + my ($hash, $udn) = @_; + + foreach my $fhem_dev (sort keys %main::defs) { + return $main::defs{$fhem_dev} if($main::defs{$fhem_dev}{TYPE} eq 'DLNARenderer' && $main::defs{$fhem_dev}{UDN} eq $udn); + } + + return undef; +} + +sub DLNARenderer_getHashByFriendlyName { + my ($hash, $friendlyName) = @_; + + foreach my $fhem_dev (sort keys %main::defs) { + my $devHash = $main::defs{$fhem_dev}; + return $devHash if($devHash->{TYPE} eq 'DLNARenderer' && ReadingsVal($devHash->{NAME}, "friendlyName", "") eq $friendlyName); + } + + return undef; +} + +sub DLNARenderer_getAllDLNARenderers($) { + my ($hash) = @_; + my @DLNARenderers = (); + + foreach my $fhem_dev (sort keys %main::defs) { + push @DLNARenderers, $main::defs{$fhem_dev} if($main::defs{$fhem_dev}{TYPE} eq 'DLNARenderer' && $main::defs{$fhem_dev}{UDN} ne "0" && $main::defs{$fhem_dev}{UDN} ne "-1"); + } + + return @DLNARenderers; +} + +sub DLNARenderer_getAllDLNARenderersWithCaskeid($) { + my ($hash) = @_; + my @caskeidClients = (); + + my @DLNARenderers = DLNARenderer_getAllDLNARenderers($hash); + foreach my $DLNARenderer (@DLNARenderers) { + push @caskeidClients, $DLNARenderer if($DLNARenderer->{helper}{caskeid}); + } + + return @caskeidClients; +} + +############################### +###### UTILITY FUNCTIONS ###### +############################### +sub DLNARenderer_newChash($$$) { + my ($hash,$socket,$chash) = @_; + + $chash->{TYPE} = $hash->{TYPE}; + $chash->{UDN} = -1; + + $chash->{NR} = $devcount++; + + $chash->{phash} = $hash; + $chash->{PNAME} = $hash->{NAME}; + + $chash->{CD} = $socket; + $chash->{FD} = $socket->fileno(); + + $chash->{PORT} = $socket->sockport if( $socket->sockport ); + + $chash->{TEMPORARY} = 1; + $attr{$chash->{NAME}}{room} = 'hidden'; + + $defs{$chash->{NAME}} = $chash; + $selectlist{$chash->{NAME}} = $chash; +} + +sub DLNARenderer_closeSocket($) { + my ($hash) = @_; + my $name = $hash->{NAME}; + + RemoveInternalTimer($hash); + + close($hash->{CD}); + delete($hash->{CD}); + delete($selectlist{$name}); + delete($hash->{FD}); +} + +sub DLNARenderer_addSocketsToMainloop { + my ($hash) = @_; + + my @sockets = $hash->{helper}{controlpoint}->sockets(); + + #check if new sockets need to be added to mainloop + foreach my $s (@sockets) { + #create chash and add to selectlist + my $chash = DLNARenderer_newChash($hash, $s, {NAME => "DLNASocket-".$s->fileno()}); + } + + return undef; +} + + +1; + +=pod +=begin html + + +

DLNARenderer

+
    + + DLNARenderer automatically discovers all your MediaRenderer devices in your local network and allows you to fully control them.
    + It also supports multiroom audio for Caskeid and Bluetooth Caskeid speakers (e.g. MUNET). + +

    + + + Define +
      + define <name> DLNARenderer +

      + + Example: +
        + define dlnadevices DLNARenderer
        + After about 2 minutes you can find all automatically created DLNA devices under "Unsorted".
        +
      +
    +
    + + + Set +
      +
      set <name> stream <value>
      + Set any URL to play. +
    +
      +
      set <name> on
      + Starts playing the last stream (reading stream). +
    +
      +
      set <name> off
      + Sends stop command to device. +
    +
      +
      set <name> stop
      + Stop playback. +
    +
      +
      set <name> volume 0-100
      + set <name> volume +/-0-100
      + Set volume of the device. +
    +
      +
      set <name> channel 1-10
      + Start playing channel X which must be configured as channel_X attribute first. +
    +
      +
      set <name> mute on/off
      + Mute the device. +
    +
      +
      set <name> pause
      + Pause playback of the device. No toggle. +
    +
      +
      set <name> pauseToggle
      + Toggle pause/play for the device. +
    +
      +
      set <name> play
      + Initiate play command. Only makes your player play if a stream was loaded (currentTrackURI is set). +
    +
      +
      set <name> next
      + Play next track. +
    +
      +
      set <name> previous
      + Play previous track. +
    +
      +
      set <name> seek <seconds>
      + Seek to position of track in seconds. +
    +
      +
      set <name> speak "This is a test. 1 2 3."
      + Speak the text followed after speak within quotes. Works with Google Translate. +
    +
      +
      set <name> playEverywhere
      + Only available for Caskeid players.
      + Play current track on all available Caskeid players in sync. +
    +
      +
      set <name> stopPlayEverywhere
      + Only available for Caskeid players.
      + Stops multiroom audio. +
    +
      +
      set <name> addUnit <unitName>
      + Only available for Caskeid players.
      + Adds unit to multiroom audio session. +
    +
      +
      set <name> removeUnit <unitName>
      + Only available for Caskeid players.
      + Removes unit from multiroom audio session. +
    +
      +
      set <name> multiRoomVolume 0-100
      + set <name> multiRoomVolume +/-0-100
      + Only available for Caskeid players.
      + Set volume of all devices within this session. +
    +
      +
      set <name> enableBTCaskeid
      + Only available for Caskeid players.
      + Activates Bluetooth Caskeid for this device. +
    +
      +
      set <name> disableBTCaskeid
      + Only available for Caskeid players.
      + Deactivates Bluetooth Caskeid for this device. +
    +
      +
      set <name> stereo <left> <right> <pairName>
      + Only available for Caskeid players.
      + Sets stereo mode for left/right speaker and defines the name of the stereo pair. +
    +
      +
      set <name> standalone
      + Only available for Caskeid players.
      + Puts the speaker into standalone mode if it was member of a stereo pair before. +
    +
      +
      set <name> saveGroupAs <groupName>
      + Only available for Caskeid players.
      + Saves the current group configuration (e.g. saveGroupAs LivingRoom). +
    +
      +
      set <name> loadGroup <groupName>
      + Only available for Caskeid players.
      + Loads the configuration previously saved (e.g. loadGroup LivingRoom). +
    +
    + +
+ +=end html +=cut diff --git a/fhem/MAINTAINER.txt b/fhem/MAINTAINER.txt index 7017bf19f..cf7e08817 100644 --- a/fhem/MAINTAINER.txt +++ b/fhem/MAINTAINER.txt @@ -315,8 +315,10 @@ FHEM/95_holiday.pm rudolfkoenig http://forum.fhem.de Sonstiges FHEM/95_remotecontrol.pm ulimaass http://forum.fhem.de Frontends FHEM/98_Text2Speech.pm tobiasfaust http://forum.fhem.de Unterstuetzende Dienste FHEM/98_apptime.pm martinp876 http://forum.fhem.de Sonstiges +FHEM/98_BOSEST.pm dominikkarall http://forum.fhem.de Multimedia FHEM/98_ComfoAir.pm StefanStrobel http://forum.fhem.de Sonstiges FHEM/98_CULflash.pm rudolfkoenig http://forum.fhem.de Sonstiges +FHEM/98_DLNARenderer.pm dominikkarall http://forum.fhem.de Multimedia FHEM/98_DOIF.pm damian-s http://forum.fhem.de Automatisierung/DOIF FHEM/98_EDIPLUG.pm Wzut http://forum.fhem.de Sonstige Systeme FHEM/98_FReplacer.pm stefanstrobel http://forum.fhem.de Sonstiges