############################################################################
# Author: dominik.karall@gmail.com
# $Id: 98_DLNARenderer.pm 12169 2016-09-18 16:03:37Z dominikkarall $
#
# v2.0.3 - 20160918
# - BUGFIX: fixed SyncPlay for CaskeId players
#
# v2.0.2 - 20160913
# - BUGFIX: fixed pauseToggle (thx@MattG)
# - BUGFIX: fixed next/previous (thx@MattG)
#
# v2.0.1 - 20160725
# - FEATURE: support DIDL-Lite in channel_X attribute (thx@Weissbrotgrill)
# - FEATURE: automatically generate DIDL-Lite based on URI (thx@Weissbrotgrill)
# - CHANGE: update CommandRef perl library requirements
# - BUGFIX: fix ignoreUDNs crash when device gets removed
#
# 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 <name> multiRoomVolume +10
#              set <name> multiRoomVolume 25
# - FEATURE: support channel_01-10 attribute
#              attr <name> channel_01 http://... (save URI to channel_01)
#              set <name> channel 1 (play channel_01)
# - FEATURE: support speak functionality via Google Translate
#              set <name> speak "This is a test."
#              attr <name> ttsLanguage de
#              set <name> speak "Das ist ein Test."
# - FEATURE: automatically retrieve stereo mode from speakers and update stereoId/Left/Right readings
# - FEATURE: support mute
#              set <name> 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 <device> 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 <name> saveGroupAs Bad
#                set <name> loadGroup Bad
# - FEATURE: support caskeid stereo mode
#                set <name> stereo MUNET1 MUNET2 MunetStereoPaar
#                set <name> 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 <name> playEverywhere
#                set <name> stopPlayEverywhere
#                set <name> addUnit <UNIT>
#                set <name> removeUnit <UNIT>
#                set <name> enableBTCaskeid
#                set <name> 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;
use LWP::UserAgent;

#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.3";
    $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 =~ /"?((?<!")\S+(?<!")|[^"]+)"?\s*/g) {
      push(@params2, $1);
  }
  @params = @params2;
  
  my $set_method_mapping = {
    volume            => {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.";
  }
  my $meta = "";
  if (substr($stream,0,10) eq "<DIDL-Lite") {
    eval {
      my $xml = XMLin($stream);
      $meta = $stream;
      $stream = $xml->{"item"}{"res"}{"content"};
    };

    if($@) {
      Log3 $hash, 2, "DLNARenderer: Incorrect DIDL-Lite format, $@"; 
    }
  }
  DLNARenderer_stream($hash, $stream, $meta);
  readingsSingleUpdate($hash, "channel", $channelNr, 1);
}

sub DLNARenderer_stream {
  my ($hash, $stream, $meta) = @_;
  if (!defined($meta)) {
    DLNARenderer_generateDidlLiteAndPlay($hash, $stream);
    return undef;
  }
  
  DLNARenderer_upnpSetAVTransportURI($hash, $stream, $meta);
  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}{VAL} eq "paused") {
      DLNARenderer_play($hash);
  } else {
      DLNARenderer_upnpPause($hash);
  }
}

sub DLNARenderer_play {
  my ($hash) = @_;
  
  #start play
  if($hash->{helper}{caskeid}) {
    if($hash->{READINGS}{sessionId}{VAL} eq "") {
      DLNARenderer_createSession($hash);
    }
    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, $meta) = @_;
  if (!defined($meta)) { $meta = ""; }
  return DLNARenderer_upnpCallAVTransport($hash, "SetAVTransportURI", 0, $stream, $meta);
}

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_upnpCallAVTransport($hash, "Next", 0);
}

sub DLNARenderer_upnpPrevious {
  my ($hash) = @_;
  return DLNARenderer_upnpCallAVTransport($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", "", "", "", "PUREDEVICECLOCK1");
}

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());
  return undef if(!defined($deviceHash));
  
  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_generateDidlLiteAndPlay {
  my ($hash, $stream) = @_;
  BlockingCall('DLNARenderer_generateDidlLiteBlocking', $hash->{NAME}."|".$stream, 'DLNARenderer_generateDidlLiteBlockingFinished');
  return undef;
}

sub DLNARenderer_generateDidlLiteBlockingFinished {
  my ($string) = @_;
  
  return unless (defined($string));
  
  my ($name, $stream, $meta) = split("\\|",$string);
  my $hash = $defs{$name};
  
  DLNARenderer_upnpSetAVTransportURI($hash, $stream, $meta);
  DLNARenderer_play($hash);
  readingsSingleUpdate($hash, "stream", $stream, 1);
}

sub DLNARenderer_generateDidlLiteBlocking {
  my ($string) = @_;
  my ($name, $stream) = split("\\|", $string);
  my $hash = $main::defs{$name};
  my $ret = $name."|".$stream;
  
  if(index($stream, "http:") != 0) {
    return $ret;
  }
  
  my $ua = new LWP::UserAgent(agent => 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1');
  $ua->max_size(0);
  my $resp = $ua->get($stream);

  my $didl_header = '<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/" xmlns:sec="http://www.sec.co.kr/"><item id="-1" parentID="parent" restricted="1">';
  my $didl_footer = '</item></DIDL-Lite>';

  $stream = encode_entities($stream);

  my $size = "";
  my $protocolInfo = "";
  my $album = $stream;
  my $title = $stream;
  my $meta = "";

  if (defined($resp->header('content-length'))) {
    $size = ' size="'.$resp->header('content-length').'"';
  }

  my @header = split /;/, $resp->header('content-type');
  my $contenttype = $header[0];

  if (defined($resp->header('contentfeatures.dlna.org'))) {
    $protocolInfo = "http-get:*:".$contenttype.":".$resp->header('contentfeatures.dlna.org');
  } else {
    $protocolInfo = "http-get:*:".$contenttype.":DLNA.ORG_OP=01;DLNA.ORG_FLAGS=01700000000000000000000000000000";
  }

  if (defined($resp->header('icy-name'))) {
    $album = encode_entities($resp->header('icy-name'));
  }

  if (defined($resp->header('icy-genre'))) {
    $title = encode_entities($resp->header('icy-genre'));
  }

  if (substr($contenttype,0,5) eq "audio" or $contenttype eq "application/ogg") {
    $meta = $didl_header.'<upnp:class>object.item.audioItem.musicTrack</upnp:class><dc:title>'.$title.'</dc:title><upnp:album>'.$album.'</upnp:album><res protocolInfo="'.$protocolInfo.'"'.$size.'>'.$stream.'</res>'.$didl_footer;
  } elsif (substr($contenttype,0,5) eq "video") {
    $meta = $didl_header.'<upnp:class>object.item.videoItem</upnp:class><dc:title>'.$title.'</dc:title><upnp:album>'.$album.'</upnp:album><res protocolInfo="'.$protocolInfo.'"'.$size.'>'.$stream.'</res>'.$didl_footer;
  } else {
    $meta = "";
  }
  $ret .= "|".$meta;

  return $ret;
}

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
=item device
=item summary Autodiscover and control your DLNA renderer devices easily
=item summary_DE Autodiscover und einfache Steuerung deiner DLNA Renderer Geräte
=begin html

<a name="DLNARenderer"></a>
<h3>DLNARenderer</h3>
<ul>

  DLNARenderer automatically discovers all your MediaRenderer devices in your local network and allows you to fully control them.<br>
  It also supports multiroom audio for Caskeid and Bluetooth Caskeid speakers (e.g. MUNET).<br><br>
        <b>Note:</b> The followig libraries are required for this module:
		<ul><li>SOAP::Lite</li> <li>LWP::Simple</li> <li>XML::Simple</li> <li>XML::Parser::Lite</li> <li>LWP::UserAgent</li><br>
		</ul>

  <a name="DLNARendererdefine"></a>
  <b>Define</b>
  <ul>
    <code>define &lt;name&gt; DLNARenderer</code>
    <br><br>

    Example:
    <ul>
      <code>define dlnadevices DLNARenderer</code><br>
      After about 2 minutes you can find all automatically created DLNA devices under "Unsorted".<br/>
    </ul>
  </ul>
  <br>

  <a name="DLNARendererset"></a>
  <b>Set</b>
  <ul>
    <br><code>set &lt;name&gt; stream &lt;value&gt</code><br>
    Set any URL to play.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; on</code><br>
    Starts playing the last stream (reading stream).
  </ul>
  <ul>
    <br><code>set &lt;name&gt; off</code><br>
    Sends stop command to device.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; stop</code><br>
    Stop playback.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; volume 0-100</code><br>
    <code>set &lt;name&gt; volume +/-0-100</code><br>
    Set volume of the device.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; channel 1-10</code><br>
    Start playing channel X which must be configured as channel_X attribute first.<br>
    You can specify your channel also in DIDL-Lite XML format if your player doesn't support plain URIs.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; mute on/off</code><br>
    Mute the device.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; pause</code><br>
    Pause playback of the device. No toggle.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; pauseToggle</code><br>
    Toggle pause/play for the device.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; play</code><br>
    Initiate play command. Only makes your player play if a stream was loaded (currentTrackURI is set).
  </ul>
  <ul>
    <br><code>set &lt;name&gt; next</code><br>
    Play next track.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; previous</code><br>
    Play previous track.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; seek &lt;seconds&gt;</code><br>
    Seek to position of track in seconds.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; speak "This is a test. 1 2 3."</code><br>
    Speak the text followed after speak within quotes. Works with Google Translate.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; playEverywhere</code><br>
    Only available for Caskeid players.<br>
    Play current track on all available Caskeid players in sync.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; stopPlayEverywhere</code><br>
    Only available for Caskeid players.<br>
    Stops multiroom audio.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; addUnit &lt;unitName&gt;</code><br>
    Only available for Caskeid players.<br>
    Adds unit to multiroom audio session.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; removeUnit &lt;unitName&gt;</code><br>
    Only available for Caskeid players.<br>
    Removes unit from multiroom audio session.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; multiRoomVolume 0-100</code><br>
    <code>set &lt;name&gt; multiRoomVolume +/-0-100</code><br>
    Only available for Caskeid players.<br>
    Set volume of all devices within this session.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; enableBTCaskeid</code><br>
    Only available for Caskeid players.<br>
    Activates Bluetooth Caskeid for this device.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; disableBTCaskeid</code><br>
    Only available for Caskeid players.<br>
    Deactivates Bluetooth Caskeid for this device.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; stereo &lt;left&gt; &lt;right&gt; &lt;pairName&gt;</code><br>
    Only available for Caskeid players.<br>
    Sets stereo mode for left/right speaker and defines the name of the stereo pair.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; standalone</code><br>
    Only available for Caskeid players.<br>
    Puts the speaker into standalone mode if it was member of a stereo pair before.
  </ul>
  <ul>
    <br><code>set &lt;name&gt; saveGroupAs &lt;groupName&gt;</code><br>
    Only available for Caskeid players.<br>
    Saves the current group configuration (e.g. saveGroupAs LivingRoom).
  </ul>
  <ul>
    <br><code>set &lt;name&gt; loadGroup &lt;groupName&gt;</code><br>
    Only available for Caskeid players.<br>
    Loads the configuration previously saved (e.g. loadGroup LivingRoom).
  </ul>
  <br>
  
  <a name="DLNARendererattr"></a>
  <b>Attributes</b>
  <ul>
    <br><code>ignoreUDNs</code><br>
    Define list (comma or blank separated) of UDNs which should prevent automatic device creation.<br>
    It is important that uuid: is also part of the UDN and must be included.
  </ul>

</ul>

=end html
=cut
