############################################################# # # BOSEST.pm # # Version 3.x by others # Version 1-2 by Dominik Karall, 2016-2017 # # FHEM module to communicate with BOSE SoundTouch system # $Id: 98_BOSEST.pm 30000 2026-02-01 18:00:00Z phenning $ # # Version: 3.0beta8 # ############################################################# # # This programm is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # The GNU General Public License can be found at # http://www.gnu.org/copyleft/gpl.html. # A copy is found in the textfile GPL.txt and important notices to the license # from the author is found in LICENSE.txt distributed with these scripts. # # This script is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # ############################################################# # TODO # - set title/album/artist for TTS files (--comment "Title=Title..") # - 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; use URI::Escape; my $BOSEST_GOOGLE_NOT_AVAILABLE_TEXT = "Hello, I'm sorry, but Google Translate is currently not available."; my $BOSEST_GOOGLE_NOT_AVAILABLE_LANG = "en"; my $BOSEST_READ_CMDREF_TEXT = "Hello, I'm sorry, but you need to install new libraries, please read command reference."; my $BOSEST_READ_CMDREF_LANG = "en"; my $BOSEST_VERSION = "3.0beta8"; ############################################################################# # # Initialization and basic device functions # ############################################################################# 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'; $hash->{AttrList} = $readingFnAttributes; 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; $hash->{helper}{stateCheck}{actionActive} = 0; #init switchSource $hash->{helper}{switchSource} = ""; #init speak channel functionality $hash->{helper}{lastSpokenChannel} = ""; my $attrList = "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 ttsVolume ". "originHandler ". "speakChannel autoZone"; my @attrListArr = split(" ", $attrList); foreach my $attrname (@attrListArr) { 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 commands $hash->{helper}{supportedSourcesCmds} = ""; $hash->{helper}{supportedBassTrebleCmds} = ""; $hash->{helper}{supportedBalanceCmds} = ""; $hash->{helper}{supportedDialogCmds} = ""; if (int(@a) < 3) { Log3 $hash, 3, "BOSEST: BOSE SoundTouch $BOSEST_VERSION"; #--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($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_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; } ############################################################################# # # Set # ############################################################################# sub BOSEST_Set($@) { my ($hash, $name, $workType, @params) = @_; #Log3 $hash, 3, "BOSEST: BOSEST_Set: $name, $workType\n".Dumper(\@params) if ($workType ne "?"); #--get quoted text from params my $blankParams = join(" ", @params); my @params2; while($blankParams =~ /"?((?{helper}{supportedSourcesCmds}. " shuffle:on,off repeat:all,one,off ". "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}{supportedBassTrebleCmds}." ". $hash->{helper}{supportedDialogCmds}." ". $hash->{helper}{supportedBalanceCmds}." ". "saveChannel:07,08,09,10,11,12,13,14,15,16,17,18,19,20 ". "origin saveState:noArg restoreState restoreVolAndOff ". "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 ". (($hash->{helper}{bassAvailable} == 1)?"(1-10)":"(-4..4)")." as additional parameter" if(int(@params) < 1); #params[0] = bass value BOSEST_setBassTreble($hash, $params[0],"b"); } elsif($workType eq "treble") { return "BOSEST: treble requires (-4..4) as additional parameter" if(int(@params) < 1); #params[0] = treble value BOSEST_setBassTreble($hash, $params[0],"t"); } elsif($workType eq "dialog") { return "BOSEST: dialog requires on/off/toggle as additional parameter" if(int(@params) < 1); #params[0] = dialog value BOSEST_setDialog($hash, $params[0]); } elsif($workType eq "balance") { return "BOSEST: balance requires (-7..7) as additional parameter" if(int(@params) < 1); #params[0] = balance value BOSEST_setBalance($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 "shuffle") { return "BOSEST: shuffle requires on/off as additional parameter" if(int(@params) < 1); #params[0] = shuffle value BOSEST_setShuffle($hash, $params[0]); } elsif($workType eq "repeat") { return "BOSEST: repeat requires all/one/off as additional parameter" if(int(@params) < 1); return "BOSEST: repeat one not supported by spotify" if ($params[0] eq "one" && ReadingsVal($hash->{NAME}, "source", "") eq "SPOTIFY"); #params[0] = repeat value BOSEST_setRepeat($hash, $params[0]); } elsif($workType eq "recent") { return "BOSEST: recent 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 tv/bluetooth/aux as additional parameter" if(int(@params) < 1); #params[0] = source value fhem("setreading $hash->{NAME} origin ".uc($params[0])); 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 "playPause") { BOSEST_playPause($hash); } elsif($workType eq "playNotification") { BOSEST_playNotification($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 "\""); #set text (must be within quotes) my $text = $params[0]; if(length($text) > 100 and AttrVal($hash->{NAME}, "ttsDirectory", "") eq "") { return "BOSEST: Text >100 characters => minidlna needed. Please set ttsDirectory attribute first. FHEM user needs permissions to write to that directory. It is also recommended to set ttsLanguage (default: en)."; } 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]); } elsif($workType eq "origin"){ return "BOSEST: origin requires string as additional parameter" if(int(@params) < 1); if( lc($params[0]) =~ /(tv)|(bluetooth)|(aux)/ ){ readingsSingleUpdate($hash,"origin",uc($params[0]),0); return BOSEST_setSource($hash, lc($params[0])); } readingsSingleUpdate($hash,"origin",$params[0],1); my $handler = AttrVal($hash->{NAME}, "originHandler",undef); if(defined($handler)){ $handler =~ s/\$DEVICE/$name/; $handler =~ s/\$ORIGIN/$params[0]/; eval($handler); } else{ Log 3,"BOSEST: no originHandler defined, would call originHandler(\"$name\",\"$params[0]\")"; } } elsif($workType eq "saveState") { BOSEST_saveCurrentState($hash); } elsif($workType eq "restoreState") { my $wait = (defined($params[0]) && looks_like_number($params[0]))?$params[0]:0; InternalTimer(gettimeofday()+$wait, "BOSEST_restoreSavedState", $hash, 0); } elsif($workType eq "restoreVolAndOff") { my $wait = (defined($params[0]) && looks_like_number($params[0]))?$params[0]:0; InternalTimer(gettimeofday()+$wait, "BOSEST_restoreVolumeAndOff", $hash, 0); #-- end NEW } else { return SetExtensions($hash, $list, $name, $workType, @params); } return undef; } ############################################################################# # # Setter functions # ############################################################################# #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}, "contentItemLocation", "") eq "") { return "BOSEST: No active channel."; } #--itemname, type, location, source, sourceaccount my $itemName = ReadingsVal($hash->{NAME}, "contentItemItemName", ""); my $location = ReadingsVal($hash->{NAME}, "contentItemLocation", ""); my $type = ReadingsVal($hash->{NAME}, "contentItemType", ""); my $source = ReadingsVal($hash->{NAME}, "contentItemSource", ""); my $sourceAccount = ReadingsVal($hash->{NAME}, "contentItemSourceAccount", ""); fhem("attr $hash->{NAME} channel_$channel $itemName|$type|$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}{type}, $hash->{helper}{recents}{$nr}{location}, $hash->{helper}{recents}{$nr}{source}, $hash->{helper}{recents}{$nr}{sourceAccount}); return undef; } ############################################################################# sub BOSEST_setContentItem { my ($hash, $itemName, $type, $location, $source, $sourceAccount) = @_; $type = "" if(!defined($type)); my $postXml = "". "". $itemName. "". ""; if(BOSEST_HTTPPOST($hash, "/select", $postXml)) { #ok } return undef; } ############################################################################# sub BOSEST_setBassTreble($$$) { my ($hash, $value,$type) = @_; my ($bass,$treble,$value2,$postXml); if( $hash->{helper}{bassAvailable} == 1 && $type eq "b"){ #-- readings 0 .. 10 if( !defined($value) || $value > 10 || $value < 1 ){ Log 1,$hash->{NAME}." invalid argument for bass, must be 1 .. 10"; $value = 0; } $bass = $value - 10; $postXml = "$bass"; if(BOSEST_HTTPPOST($hash, '/bass', $postXml)) { } #FIXME error handling }elsif( $hash->{helper}{bassTrebleAvailable} == 1){ #-- readings -4 .. 4 if( !defined($value) || $value > 4 || $value < -4 ){ Log 1,$hash->{NAME}." invalid argument for ".($type eq "b"?"bass":"treble").", must be -4 .. 4"; $value = 0; } #-- second value $value2 = ReadingsVal($hash->{NAME},($type eq "b"?"treble":"bass"),0); if( !defined($value2) || $value2 > 4 || $value2 < -4 ){ Log 1,$hash->{NAME}." invalid reading for ".($type eq "b"?"bass":"treble").", must be -4 .. 4"; $value2 = 0; } if( $type eq "b" ){ $bass = $value*25; $treble = $value2*25; }else{ $treble = $value*25; $bass = $value2*25; } $postXml = ""; if(BOSEST_HTTPPOST($hash, '/audioproducttonecontrols', $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_setDialog($$) { my ($hash, $dialog) = @_; my $postXml; my $old=ReadingsVal($hash->{NAME},"dialog",""); if($dialog =~ /(on)|(toggle)/ && $old =~ /(false)|(off)/){ $postXml = ""; }elsif($dialog =~ /(off)|(toggle)/ && $old =~ /(true)|(on)/){ $postXml = ""; } if($postXml){ BOSEST_HTTPPOST($hash, '/audiodspcontrols', $postXml); } return undef; } ############################################################################# sub BOSEST_setBalance($$) { my ($hash, $value) = @_; my $postXml; #-- readings -7 .. 7 if( !defined($value) || $value > 7 || $value < -7 ){ Log 1,$hash->{NAME}." invalid argument for balance, must be -7 .. 7"; $value = 0; } $postXml = "$value"; BOSEST_HTTPPOST($hash, '/balance', $postXml); return undef; } ############################################################################# sub BOSEST_setMute($$) { my ($hash, $mute) = @_; if(($mute eq "on" && $hash->{READINGS}{mute}{VAL} =~ /((off)|(false))/) or ($mute eq "off" && $hash->{READINGS}{mute}{VAL} =~ /((on)|(true))/) or ($mute eq "toggle")) { BOSEST_sendKey($hash, "MUTE"); } return undef; } ############################################################################# sub BOSEST_setShuffle($$) { my ($hash, $shuffle) = @_; if($shuffle eq "on") { BOSEST_sendKey($hash, "SHUFFLE_ON"); } if ($shuffle eq "off") { BOSEST_sendKey($hash, "SHUFFLE_OFF"); } return undef; } ############################################################################# sub BOSEST_setRepeat($$) { my ($hash, $repeat) = @_; if($repeat eq "one") { BOSEST_sendKey($hash, "REPEAT_ONE"); } if ($repeat eq "all") { BOSEST_sendKey($hash, "REPEAT_ALL"); } if ($repeat eq "off") { BOSEST_sendKey($hash, "REPEAT_OFF"); } return undef; } ############################################################################# sub BOSEST_setSource($$) { my ($hash, $source) = @_; $hash->{helper}{switchSource} = uc $source; if($hash->{helper}{switchSource} eq "") { #-- nothing }elsif($hash->{helper}{switchSource} eq "TV" || $hash->{helper}{switchSource} eq "HDMI1") { my $sourceAccount = $hash->{helper}{switchSource} eq "TV" ? "TV" : "HDMI_1"; my $postXml = "". ""; if(BOSEST_HTTPPOST($hash, "/select", $postXml)) { #-- Fix: Error handling } $hash->{helper}{switchSource} = ""; }elsif($hash->{helper}{switchSource} eq "BT-DISCOVER" && ReadingsVal($hash->{NAME}, "connectionStatusInfo", "") eq "DISCOVERABLE") { $hash->{helper}{switchSource} = ""; }elsif($hash->{helper}{switchSource} eq ReadingsVal($hash->{NAME}, "source", "") && ReadingsVal($hash->{NAME}, "connectionStatusInfo", "") ne "DISCOVERABLE") { $hash->{helper}{switchSource} = ""; }else{ #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[1] = "" if(!defined($channel[1])); $channel[2] = "" if(!defined($channel[2])); $channel[3] = "" if(!defined($channel[3])); $channel[4] = "" if(!defined($channel[4])); Log3 $hash, 5, "BOSEST: AttrVal: $channel[0], $channel[1], $channel[2], $channel[3], $channel[4]"; #format: itemName|location|source|sourceAccount BOSEST_setContentItem($hash, $channel[0], $channel[1], $channel[2], $channel[3], $channel[4]); } return undef; } ############################################################################# sub BOSEST_playNotification($) { my ($hash) = @_; my $ret = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/playNotification"); return undef; } ############################################################################# sub BOSEST_play($) { my ($hash) = @_; BOSEST_sendKey($hash, "PLAY"); return undef; } sub BOSEST_playPause($) { my ($hash) = @_; BOSEST_sendKey($hash, "PLAY_PAUSE"); 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; } ############################################################################# # # speak functions # ############################################################################# sub BOSEST_speakChannel { my ($hash) = @_; my $speakChannel = AttrVal($hash->{NAME}, "speakChannel", ""); if($speakChannel ne "") { my $channelNr = ReadingsVal($hash->{NAME}, "channel", ""); Log3 $hash, 5, "BOSEST: speakChannel, $channelNr is in $speakChannel range?"; if($channelNr =~ /[$speakChannel]/g) { my $channelName = ReadingsVal($hash->{NAME}, "contentItemItemName", ""); Log3 $hash, 5, "BOSEST: speakChannel, start speak for channl $channelName"; 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) = @_; $lang = AttrVal($hash->{NAME}, "ttsLanguage", "en") if($lang eq ""); $volume = AttrVal($hash->{NAME}, "ttsVolume", ReadingsVal($hash->{NAME}, "volume", 20)) if($volume eq ""); if(length($text) < 100) { my $uri_text = uri_escape($text); my $translateUrl = "http://translate.google.com/translate_tts?ie=UTF-8&tl=$lang&client=tw-ob&q=$uri_text"; $translateUrl =~ s/\&/\&\;/g; if(substr($volume, 0, 1) eq "+" or substr($volume, 0, 1) eq "-") { $volume = ReadingsVal($hash->{NAME}, "volume", 0) + $volume; } my $postXml = 'Ml7YGAI9JWjFhU7D348e86JPXtisddBa'.$translateUrl.''.$text.''.$volume.''; if(BOSEST_HTTPPOST($hash, '/speaker', $postXml)) { } if(defined($stopAfterSpeak) && $stopAfterSpeak eq "1") { $hash->{helper}{stateCheck}{enabled} = 1; #after play the speaker changes contentItemItemName $hash->{helper}{stateCheck}{actionContentItemItemName} = ""; $hash->{helper}{stateCheck}{function} = \&BOSEST_off; } return undef; } my $ttsDir = AttrVal($hash->{NAME}, "ttsDirectory", ""); my $sox = qx(which sox); chomp $sox; if(!-x $sox) { BOSEST_playGoogleTTS($hash, $ttsDir, $BOSEST_READ_CMDREF_TEXT, $volume, $BOSEST_READ_CMDREF_LANG, $stopAfterSpeak); return undef; } #download file and play BOSEST_playGoogleTTS($hash, $ttsDir, $text, $volume, $lang, $stopAfterSpeak); return undef; } ############################################################################# # # save and restore current state # ############################################################################# 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}{dialog} = ReadingsVal($hash->{NAME}, "dialog", ""); $hash->{helper}{savedState}{balance} = ReadingsVal($hash->{NAME}, "balance", ""); $hash->{helper}{savedState}{bass} = ReadingsVal($hash->{NAME}, "bass", ""); $hash->{helper}{savedState}{treble} = ReadingsVal($hash->{NAME}, "treble", ""); $hash->{helper}{savedState}{origin} = ReadingsVal($hash->{NAME}, "origin", ""); $hash->{helper}{savedState}{playStatus} = ReadingsVal($hash->{NAME}, "playStatus", "STOP_STATE"); $hash->{helper}{savedState}{contentItemItemName} = ReadingsVal($hash->{NAME}, "contentItemItemName", ""); $hash->{helper}{savedState}{contentItemType} = ReadingsVal($hash->{NAME}, "contentItemType", ""); $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_setDialog($hash, $hash->{helper}{savedState}{dialog}); BOSEST_setBalance($hash, $hash->{helper}{savedState}{balance}); BOSEST_setBassTreble($hash, $hash->{helper}{savedState}{bass},"b"); BOSEST_setBassTreble($hash, $hash->{helper}{savedState}{treble},"t"); readingsSingleUpdate($hash,"origin",$hash->{helper}{savedState}{origin},0); #-- switch off when source was off if($hash->{helper}{savedState}{source} eq "STANDBY" or $hash->{helper}{savedState}{source} eq "INVALID_SOURCE") { BOSEST_off($hash); } else { BOSEST_setContentItem($hash, $hash->{helper}{savedState}{contentItemItemName}, $hash->{helper}{savedState}{contentItemType}, $hash->{helper}{savedState}{contentItemLocation}, $hash->{helper}{savedState}{contentItemSource}, $hash->{helper}{savedState}{contentItemSourceAccount}); } if($hash->{helper}{savedState}{playStatus} eq "STOP_STATE") { InternalTimer(gettimeofday()+0.8, "BOSEST_stop", $hash, 0); } elsif($hash->{helper}{savedState}{playStatus} eq "PAUSE_STATE") { InternalTimer(gettimeofday()+0.8, "BOSEST_pause", $hash, 0); } return undef; } ############################################################################# sub BOSEST_restoreVolumeAndOff($) { my ($hash) = @_; BOSEST_setVolume($hash, $hash->{helper}{savedState}{volume}); BOSEST_setBassTreble($hash, $hash->{helper}{savedState}{bass},"b"); BOSEST_setBassTreble($hash, $hash->{helper}{savedState}{treble},"t"); readingsSingleUpdate($hash,"origin",$hash->{helper}{savedState}{origin},0); BOSEST_setContentItem($hash, $hash->{helper}{savedState}{contentItemItemName}, $hash->{helper}{savedState}{contentItemType}, $hash->{helper}{savedState}{contentItemLocation}, $hash->{helper}{savedState}{contentItemSource}, $hash->{helper}{savedState}{contentItemSourceAccount}); BOSEST_off($hash); } ############################################################################# # # Google speech service # ############################################################################# 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) { BOSEST_retrieveGooglTTSFile($hash, $filename, $md5, $text, $lang); } return undef; } ############################################################################# sub BOSEST_retrieveGooglTTSFile($$$$$;$) { my ($hash, $filename, $md5, $text, $lang, $callback) = @_; my $uri_text = uri_escape($text); $hash->{helper}{useragent}->get("http://translate.google.com/translate_tts?ie=UTF-8&tl=$lang&client=tw-ob&q=$uri_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_generateSilence { my ($hash) = @_; my $ttsDir = AttrVal($hash->{NAME}, "ttsDirectory", ""); my $silenceFile = $ttsDir."/BOSEST_silence.mp3"; my $soxCmd; if(!-f $silenceFile) { #generate silence file $soxCmd = "sox -n -r 24000 -c 1 $silenceFile trim 0.0 1"; qx($soxCmd); } return undef; } ############################################################################# sub BOSEST_joinAudioFilesBlocking { my ($string) = @_; my ($name, $outputFile, @inputFiles) = split("\\|", $string); my $ttsDir = AttrVal($name, "ttsDirectory", ""); my $hash = $main::defs{$name}; my $inputF = join(" ", map { $ttsDir."/".$_ } @inputFiles); my $outputF = $ttsDir."/".$outputFile; my $outputFileTmp = $ttsDir."/tmp_".$outputFile; BOSEST_generateSilence($hash); my $soxCmd = "sox $inputF $outputFileTmp"; Log3 $hash, 5, "SOX: $soxCmd"; my $soxRes = qx($soxCmd); qx(mv $outputFileTmp $outputF); return $name; } ############################################################################# sub BOSEST_playMessageStringArg { my ($name) = @_; my $hash = $main::defs{$name}; BOSEST_playMessage($hash, "v1_".$hash->{helper}{tts}{fulltextmd5}, $hash->{helper}{tts}{volume}, $hash->{helper}{tts}{stopAfterSpeak}); return undef; } ############################################################################# sub BOSEST_playMessage($$$$) { my ($hash, $trackname, $volume, $stopAfterSpeak) = @_; Log3 $hash, 4, "BOSEST: playMessage $trackname, $volume, $stopAfterSpeak"; BOSEST_saveCurrentState($hash); if($volume ne ReadingsVal($hash->{NAME}, "volume", 0)) { BOSEST_pause($hash); BOSEST_setVolume($hash, $volume); } BOSEST_playTrack($hash, $trackname); $hash->{helper}{stateCheck}{enabled} = 1; #after play the speaker changes contentItemItemName $hash->{helper}{stateCheck}{actionContentItemItemName} = $trackname; #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"); return undef unless defined($val); 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) = @_; $hash->{helper}{tts}{volume} = $volume; $hash->{helper}{tts}{stopAfterSpeak} = $stopAfterSpeak; $hash->{helper}{tts}{fulltextmd5} = md5_hex($lang.$text); my $filename = $ttsDir."/v1_".$hash->{helper}{tts}{fulltextmd5}.".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 Log3 $hash, 5, "BOSEST: File $filename found. No new download required."; BOSEST_playMessageStringArg($hash->{NAME}); return undef; } } my @sentences = split (/(?<=[.?!])/, $text); $hash->{helper}{tts}{downloads}{all} = ""; foreach my $sentence (@sentences) { my $md5 = md5_hex($lang.$sentence); $hash->{helper}{tts}{downloads}{$md5} = 0; $hash->{helper}{tts}{downloads}{all} .= $md5.","; BOSEST_downloadGoogleTTS($hash, $ttsDir, $sentence, $lang); } InternalTimer(gettimeofday()+1, "BOSEST_checkTTSDownloadFinished", $hash, 0); return undef; } ############################################################################# sub BOSEST_checkTTSDownloadFinished { my ($hash) = @_; my @allMd5 = split(",", $hash->{helper}{tts}{downloads}{all}); my $msgStatus = 1; foreach my $md5 (@allMd5) { if($hash->{helper}{tts}{downloads}{$md5} == 10) { $msgStatus = 10; } elsif($hash->{helper}{tts}{downloads}{$md5} == 0) { $msgStatus = 0; } } if($msgStatus == 10) { if(AttrVal($hash->{NAME}, "ttsSpeakOnError", "1") eq "1") { my $md5 = md5_hex($BOSEST_GOOGLE_NOT_AVAILABLE_LANG.$BOSEST_GOOGLE_NOT_AVAILABLE_TEXT); BOSEST_playMessage($hash, $md5, $hash->{helper}{tts}{volume}, $hash->{helper}{tts}{stopAfterSpeak}); } else { Log3 $hash, 3, "BOSEST: Google translate download failed."; } } elsif($msgStatus == 0) { #check again in 1s InternalTimer(gettimeofday()+1, "BOSEST_checkTTSDownloadFinished", $hash, 0); } else { BlockingCall("BOSEST_joinAudioFilesBlocking", $hash->{NAME}."|v1_".$hash->{helper}{tts}{fulltextmd5}.".mp3|BOSEST_silence.mp3|".join(".mp3|", @allMd5).".mp3", "BOSEST_playMessageStringArg"); } return undef; } ############################################################################# sub BOSEST_downloadGoogleTTS { my ($hash, $ttsDir, $text, $lang) = @_; 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 $hash->{helper}{tts}{downloads}{$md5} = 1; return undef; } } BOSEST_retrieveGooglTTSFile($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()); $hash->{helper}{tts}{downloads}{$md5} = 1; #add silence and play message afterwards } else { $hash->{helper}{tts}{downloads}{$md5} = 10; #download error } }); return undef; } ############################################################################# # # Account # ############################################################################# 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 = $hash->{helper}{ttsdlnaserver}; if(defined($ttsDlnaServer) && $ttsDlnaServer ne "") { Log3 $hash, 4, "BOSEST: Search for $trackName on $ttsDlnaServer"; if(my $xmlTrack = BOSEST_searchTrack($hash, $ttsDlnaServer, $trackName)) { BOSEST_setContentItem($hash, $xmlTrack->{itemName}, $xmlTrack->{type}, $xmlTrack->{location}, $xmlTrack->{source}, $xmlTrack->{sourceAccount}); return undef; } } foreach my $source (@{$hash->{helper}{sources}}) { if($source->{source} eq "STORED_MUSIC" && $source->{status} eq "READY") { Log3 $hash, 4, "BOSEST: Search for $trackName on $source->{sourceAccount}"; if(my $xmlTrack = BOSEST_searchTrack($hash, $source->{sourceAccount}, $trackName)) { BOSEST_setContentItem($hash, $xmlTrack->{itemName}, $xmlTrack->{type}, $xmlTrack->{location}, $xmlTrack->{source}, $xmlTrack->{sourceAccount}); $hash->{helper}{ttsdlnaserver} = $source->{sourceAccount}; last; } #sleep 100ms, otherwise internal server error from BOSE speaker select(undef, undef, undef, 0.1); } } return undef; } ############################################################################# # # playInfo # ############################################################################# sub BOSEST_playInfo($){ my ($hash) = @_; my $name = $hash->{NAME}; my $retl; #-- current state my $pst = ReadingsVal($name,"playStatus",""); my $cur = ReadingsVal($name,"state","unknown"); my $cha = ReadingsVal($name,"channel",""); my $itm = ReadingsVal($name,"contentItemItemName", ""); my $loc = ReadingsVal($name,"contentItemLocation", ""); my $typ = ReadingsVal($name,"contentItemType", ""); my $src = ReadingsVal($name,"contentItemSource",""); my $acc = ReadingsVal($name,"contentItemSourceAccount",""); my $trk = ReadingsVal($name,"track",""); my $art = ReadingsVal($name,"artist",""); my $sna = ReadingsVal($name,"stationName",""); my $pli = ReadingsVal($name,"playinfosrc",""); my $cov; #-- check if this comes from a Fritz #-- return values if( $cur eq "playing" || $cur eq "buffering" ){ #-- STORED_MUSIC: if( $src =~ /STORED.*/ ){ #-- really stored music => pli is empty or == none if( $pli eq "" || $pli eq "none"){ $retl = "playing ".$trk."
by ".$art.""; }else{ #Log 1,"BOSEST: Obtaining title / track from $pli"; if ($pli =~ /scraper\.onlineradio/i){ ($trk,$art,$cov) = BOSEST_SCRAPERGET($pli); readingsBeginUpdate($hash); readingsBulkUpdate($hash, "track", $trk); readingsBulkUpdate($hash, "artist", $art); readingsBulkUpdate($hash, "art", $cov); readingsBulkUpdate($hash, "artStatus", "IMAGE_PRESENT"); readingsEndUpdate($hash, 1); $retl = "playing on ".$itm.": ".$trk."
by ".$art.""; #Log 1,"====> pli gives trk=$trk art=$art cov=$cov"; }else{ Log 1,"BOSEST: cannot interpret playinfo source"; } } return $retl; #-- will no longer work soon #-- TUNEIN: track is the radio station, artist consists of artist - title }elsif( $src =~ /TUNEIN.*/ ){ my @aaa = split(" - ",$art); if( int(@aaa) != 2){ $retl = "playing ".$trk.""; }else{ $retl = "playing on ".$trk.": ".$aaa[1]."
by ".$aaa[0]; } #-- TV, AUX, HDMI: no artist info }elsif( $src eq "PRODUCT" ){ $retl = "playing ".$acc; #-- BLUETOOTH: stationname is BT device }elsif( $src eq "BLUETOOTH"){ $retl = "playing ".$trk."
via ".$sna.""; #-- else }else{ $retl = "playing, no idea what"; } }elsif( $cur eq "online" ){ $retl = $cur; }else{ #Log 1,"====> Playinfo fallback $cur"; $retl = $cur; } return $retl; } ############################################################################# # # searchTrack # ############################################################################# sub BOSEST_searchTrack($$$) { my ($hash, $dlnaUid, $trackName) = @_; my $postXml = '11'. $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_updateBassTreble($$) { my ($hash, $deviceId) = @_; if($hash->{helper}{bassAvailable} == 1){ my $bass = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/bass"); BOSEST_processXml($hash, $bass); }elsif($hash->{helper}{bassTrebleAvailable} == 1){ my $basstreble = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/audioproducttonecontrols"); BOSEST_processXml($hash, $basstreble); } return undef; } ############################################################################# sub BOSEST_updateDialog($$) { my ($hash, $deviceId) = @_; my $dialog = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/audiodspcontrols"); BOSEST_processXml($hash, $dialog); return undef; } ############################################################################# sub BOSEST_updateBalance($$) { my ($hash, $deviceId) = @_; my $balance = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/balance"); BOSEST_processXml($hash, $balance); 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 "" or $channel eq "0"); 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; } ############################################################################# # # processXml # ############################################################################# sub BOSEST_processXml($$) { my ($hash, $wsxml) = @_; Log3 $hash, 5, "BOSEST: processXml:\n".Dumper($wsxml); #Log3 $hash, 1, "BOSEST: processXml:\n".Dumper($wsxml) # if( $hash->{DEVICEID} =~ /50338B343509/); #äWARUM WERDEN HIER NUR DATEN von der ST20 geloggt? #Log3 $hash, 1, "BOSEST: processXml: ".$hash->{DEVICEID}; 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}[0]); BOSEST_checkDoubleTap($hash, $wsxml->{updates}->{nowSelectionUpdated}->{preset}[0]->{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}->{audioproducttonecontrols} || $wsxml->{updates}->{bassUpdated}) { BOSEST_updateBassTreble($hash, $hash->{DEVICEID}); } elsif ($wsxml->{updates}->{audiodspcontrols}) { BOSEST_updateDialog($hash, $hash->{DEVICEID}); } elsif ($wsxml->{updates}->{balanceUpdated}) { BOSEST_updateBalance($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, 3, "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}) { #Log 1," ==> Calling parseAndUpdateBassTreble with bass"; BOSEST_parseAndUpdateBassTreble($hash, $wsxml->{bass}) if($hash->{helper}{bassAvailable} || $hash->{helper}{bassTrebleAvailable}); } elsif($wsxml->{audioproducttonecontrols}) { #Log 1," ==> Calling parseAndUpdateBassTreble with audioproducttonecontrols"; BOSEST_parseAndUpdateBassTreble($hash, $wsxml->{audioproducttonecontrols}) if($hash->{helper}{bassTrebleAvailable}); } elsif($wsxml->{audiodspcontrols}) { #Log 1," ==> Calling parseAndUpdateDialog with audiodspcontrols"; BOSEST_parseAndUpdateDialog($hash, $wsxml->{audiodspcontrols}) if($hash->{helper}{dialogAvailable}); } elsif($wsxml->{balance}) { BOSEST_parseAndUpdateBalance($hash, $wsxml->{balance}) if($hash->{helper}{balanceAvailable}); } elsif($wsxml->{zone}) { BOSEST_parseAndUpdateZone($hash, $wsxml->{zone}); } elsif($wsxml->{sources}) { BOSEST_parseAndUpdateSources($hash, $wsxml->{sources}->{sourceItem}); } elsif($wsxml->{msg}) { if($wsxml->{msg}->{body} && $wsxml->{msg}->{body}->{pingRequest}) { #pingpong } else { Log3 $hash, 4, "BOSEST: Unknown event, please implement:\n".Dumper($wsxml); } } 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}, "contentItemItemName", "") eq $hash->{helper}{stateCheck}{actionContentItemItemName}) { $hash->{helper}{stateCheck}{actionActive} = 1; } else { if($hash->{helper}{stateCheck}{actionActive}) { if(ReadingsVal($hash->{NAME}, "contentItemItemName", "") ne $hash->{helper}{stateCheck}{actionContentItemItemName}) { #call function with $hash as argument $hash->{helper}{stateCheck}{function}->($hash); $hash->{helper}{stateCheck}{enabled} = 0; $hash->{helper}{stateCheck}{actionActive} = 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) = @_; #-- no need to go via BOSE_XMLUpdate readingsBeginUpdate($hash); readingsBulkUpdate($hash, "volume", $volume->{actualvolume}); readingsBulkUpdate($hash, "mute", ($volume->{muteenabled} =~ /((on)|(true))/)?"on":"off"); readingsEndUpdate($hash, 1); return undef; } ############################################################################# sub BOSEST_parseAndUpdateBassTreble($$) { my ($hash, $basstreble) = @_; #-- no need to go via BOSE_XMLUpdate readingsBeginUpdate($hash); if($hash->{helper}{bassAvailable} == 1){ my $currBass = $basstreble->{actualbass} + 10; readingsBulkUpdate($hash, "bass", $currBass); }elsif($hash->{helper}{bassTrebleAvailable} == 1){ my $currBass = $basstreble->{bass}->{value}/25; my $currTreble = $basstreble->{treble}->{value}/25; readingsBulkUpdate($hash, "bass", $currBass); readingsBulkUpdate($hash, "treble", $currTreble); } readingsEndUpdate($hash, 1); return undef; } ############################################################################# sub BOSEST_parseAndUpdateDialog($$) { my ($hash, $dialog) = @_; readingsSingleUpdate($hash, "dialog", ($dialog->{audiomode} eq "AUDIO_MODE_DIALOG")?"on":"off",1); return undef; } ############################################################################# sub BOSEST_parseAndUpdateBalance($$) { my ($hash, $balance) = @_; readingsSingleUpdate($hash, "balance", $balance->{actualBalance},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, "model", $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}); BOSEST_XMLUpdate($hash, "shuffle", $nowPlaying->{shuffleSetting}); BOSEST_XMLUpdate($hash, "repeat", $nowPlaying->{repeatSetting}); 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; my $pi=BOSEST_playInfo($hash); readingsBeginUpdate($hash); readingsBulkUpdate($hash,"playinfo1",$pi); $pi =~ s/playing( on)? //; $pi =~ s/\(\d\d\d\d\)//; $pi =~ s/
/ /; $pi =~ s/\s/\ /g; readingsBulkUpdate($hash,"playinfo2",$pi); readingsEndUpdate($hash, 1); return undef; } ############################################################################# # # Discovery process # ############################################################################# 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, 1, "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}; if( $bassCap->{bassAvailable} eq "true"){ $return .= ",".$bassCap->{bassAvailable}.",".$bassCap->{bassMin}.",". $bassCap->{bassMax}.",".$bassCap->{bassDefault}; }else{ $return .= ",".$bassCap->{bassAvailable}.",,,"; #-- bassTreble only if not bass only my $bassTrebleCapabilities = BOSEST_HTTPGET($hash, $ip, "/audioproducttonecontrols"); if($bassTrebleCapabilities->{audioproducttonecontrols}) { $return .= "|bassTrebleCapabilities|$info->{deviceID},". $bassTrebleCapabilities->{audioproducttonecontrols}->{bass}->{value}.",". $bassTrebleCapabilities->{audioproducttonecontrols}->{bass}->{minValue}.",". $bassTrebleCapabilities->{audioproducttonecontrols}->{bass}->{maxValue}.",". $bassTrebleCapabilities->{audioproducttonecontrols}->{bass}->{step}.",". $bassTrebleCapabilities->{audioproducttonecontrols}->{treble}->{value}.",". $bassTrebleCapabilities->{audioproducttonecontrols}->{treble}->{minValue}.",". $bassTrebleCapabilities->{audioproducttonecontrols}->{treble}->{maxValue}.",". $bassTrebleCapabilities->{audioproducttonecontrols}->{treble}->{step}; } } } #--set supported dialog capabilities my $dialogCapabilities = BOSEST_HTTPGET($hash, $ip, "/audiodspcontrols"); if($dialogCapabilities->{audiodspcontrols}) { $return .= "|dialogCapabilities|$info->{deviceID},true"; } #--set supported balance capabilities my $balanceCapabilities = BOSEST_HTTPGET($hash, $ip, "/balance"); $return .= "|balanceCapabilities|$info->{deviceID}"; if($balanceCapabilities->{balance}) { my $balCap = $balanceCapabilities->{balance}; if( $balCap->{balanceAvailable} eq "true"){ $return .= ",".$balCap->{balanceAvailable}.",".$balCap->{balanceMin}.",". $balCap->{balanceMax}.",".$balCap->{balanceDefault}; }else{ $return .= ",".$balCap->{balanceAvailable}.",,,"; } } #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) { #Log 1,"BOSEST found device at IP ".$device->address; $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); Log3 $hash, 5, "BOSEST: finished discovery"; 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); #Log 1,"== ".$deviceHash->{NAME}." == finishedDiscovery, now bassCapabilities "; #--bassAvailable, bassMin, bassMax, bassDefault # values -9 .. 0, but slider 1..10 if($params[0] eq "true") { $deviceHash->{helper}{bassAvailable} = 1; $deviceHash->{helper}{bassMin} = $params[1]; $deviceHash->{helper}{bassMax} = $params[2]; $deviceHash->{helper}{bassDefault} = $params[3]; $deviceHash->{helper}{supportedBassTrebleCmds} = "bass:slider,1,1,10"; }else{ $deviceHash->{helper}{bassAvailable} = 0; } } elsif($command eq "bassTrebleCapabilities") { my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId); # Log 1,"== ".$deviceHash->{NAME}." == finishedDiscovery, now bassTrebleCapabilities "; #--bass value, min, max, step, treble value, min, max, step # values -100 .. 100 step 25, but slider -4 .. 4 if(defined($params[0])){ $deviceHash->{helper}{bassTrebleAvailable} = 1; $deviceHash->{helper}{bassMin} = $params[1]; $deviceHash->{helper}{bassMax} = $params[2]; $deviceHash->{helper}{trebleMin} = $params[5]; $deviceHash->{helper}{trebleMax} = $params[6]; $deviceHash->{helper}{supportedBassTrebleCmds} = "bass:slider,-4,1,4 treble:slider,-4,1,4"; }else{ $deviceHash->{helper}{bassTrebleAvailable} = 0; } } elsif($command eq "dialogCapabilities") { my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId); #--true if(defined($params[0])){ $deviceHash->{helper}{dialogAvailable} = 1; $deviceHash->{helper}{supportedDialogCmds} = "dialog:on,off"; }else{ $deviceHash->{helper}{dialogAvailable} = 0; } } elsif($command eq "balanceCapabilities") { my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId); #--balanceAvailable, balanceMin, balanceMax, balanceDefault # values -7 .. 7 if($params[0] eq "true") { $deviceHash->{helper}{balanceAvailable} = 1; $deviceHash->{helper}{balanceMin} = $params[1]; $deviceHash->{helper}{balanceMax} = $params[2]; $deviceHash->{helper}{balanceDefault} = $params[3]; $deviceHash->{helper}{supportedBalanceCmds} = "balance:slider,-7,1,7"; }else{ $deviceHash->{helper}{balanceAvailable} = 0; } } 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; #-- NEW FlatTV $deviceHash->{helper}{productTvSupport} = 0; $deviceHash->{helper}{productHdmi1Support} = 0; #-- end NEW $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,"; #-- NEW FlatTV } elsif($source eq "PRODUCT") { #quick hack, we also need sourceAccount value for PRODUCT if($deviceHash->{helper}{productTvSupport} == 0 && $deviceHash->{helper}{productHdmi1Support} == 0) { $deviceHash->{helper}{productTvSupport} = 1; $deviceHash->{helper}{productHdmi1Support} = 1; $deviceHash->{helper}{supportedSourcesCmds} .= "tv,hdmi1,"; } } #-- end NEW } $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 Log3 $hash, 5, "BOSEST: BOSEST_updateInfo"; BOSEST_updateInfo($deviceHash, $deviceID); Log3 $hash, 5, "BOSEST: BOSEST_updateNowPlaying"; #get now_playing BOSEST_updateNowPlaying($deviceHash, $deviceID); Log3 $hash, 5, "BOSEST: BOSEST_setVolume"; #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 Log3 $hash, 5, "BOSEST: BOSEST_updateVolume"; BOSEST_updateVolume($deviceHash, $deviceID); #get current presets Log3 $hash, 5, "BOSEST: BOSEST_updatePresets"; BOSEST_updatePresets($deviceHash, $deviceID); #get current bass and treble settings Log3 $hash, 5, "BOSEST: BOSEST_updateBassTreble"; BOSEST_updateBassTreble($deviceHash, $deviceID); Log3 $hash, 5, "BOSEST: BOSEST_updateDialog"; BOSEST_updateDialog($deviceHash, $deviceID); Log3 $hash, 5, "BOSEST: BOSEST_updateBalance"; BOSEST_updateBalance($deviceHash, $deviceID); #get current zone settings Log3 $hash, 5, "BOSEST: BOSEST_updateZone"; BOSEST_updateZone($deviceHash, $deviceID); #get current sources Log3 $hash, 5, "BOSEST: BOSEST_updateSources"; BOSEST_updateSources($deviceHash, $deviceID); #get current clock state Log3 $hash, 5, "BOSEST: BOSEST_updateClock"; 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 ### WARUM WAREN begin/end Auskommentiert?? 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 preset)], 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 Log3 $hash, 5, "BOSEST: run mojo loop"; my $id = Mojo::IOLoop->timer(0.0005 => sub {}); Mojo::IOLoop->one_tick; Mojo::IOLoop->remove($id); Log3 $hash, 5, "BOSEST: finished mojo loop"; } InternalTimer(gettimeofday()+0.7, "BOSEST_checkWebSocketConnection", $hash, 1); return undef; } ############################################################################# # # generic commands # ############################################################################# 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 preset)], 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 preset)], 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_SCRAPERGET($) { my ($getURI) = @_; # Sicherheit: nur scraper.onlineradio URLs erlauben #return undef if ($getURI !~ /scraper\.onlineradio/i); my $ua = LWP::UserAgent->new( timeout => 10, agent => "FHEM-BOSEST/1.0" ); my $req = HTTP::Request->new(GET => $getURI); my $response = $ua->request($req); return undef unless $response->is_success; my $json; eval { $json = decode_json($response->decoded_content); }; if ($@ || !$json) { Log3 undef, 3, "BOSEST_SCRAPERGET: JSON decode error: $@"; return undef; } # Felder extrahieren (je nach Sender leicht unterschiedlich) my $title = $json->{title} // ''; my $artist = $json->{iArtist} // ''; my $cover = $json->{iImg} // ''; # Fallback: wenn title bereits "Artist - Title" enthält if (!$artist && $title =~ /(.*?)\s*-\s*(.*)/) { $artist = $1; $title = $2; # Fallback: wenn title bereits "Artist - Title" enthält }elsif( $title =~ /(.*?)\s*-\s*(.*)/) { $title = $2; } return ($title, $artist, $cover); } ############################################################################# 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 =item device =item summary Control your BOSE SoundTouch devices =item summary_DE Steuerung deiner BOSE SoundTouch Lautsprecher =begin html

BOSEST

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

    Note: The following libraries are required for this module:
    • libwww-perl
    • libmojolicious-perl
    • libxml-simple-perl
    • libnet-bonjour-perl
    • libev-perl
    • liburi-escape-xs-perl
    • sox
    • libsox-fmt-mp3

    • Use sudo apt-get install libwww-perl libmojolicious-perl libxml-simple-perl libnet-bonjour-perl libev-perl liburi-escape-xs-perl to install this libraries.
      Please note: libmojolicious-perl must be >=5.54
      Use sudo apt-get install cpanminus and sudo cpanm Mojolicious to update to the newest version.

      TextToSpeach (TTS) can be configured as described in the following thread: Link
      Questions and/or feedback can be posted on the FHEM forum: Link


    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 (except 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 preset to play
      • saveChannel 07...20   -   save current channel to channel 07 to 20
      • play   -   start to play
      • pause   -   pause the playback
      • playPause   -   toggle play/pause
      • playNotification   -   play short notification sound
      • stop   -   stop playback
      • nextTrack   -   play next track
      • prevTrack   -   play previous track
      • playTrack name|location|source[|sourceAccount]   -   searches per DNLA for the track title/album/artist and plays it
      • origin originString   -   only if not tv, bluetooth or aux: calls an external function defined in the originHandler attribute with parameters $DEVICE,$ORIGIN replaced by the name of the BOSEST device and the value of the origin argument.
        This allows to define your own function for selecting music to play, like e.g. playlists or certain radio stations. Also, by using the widgetOverride attribute, a dropdown list may be shown for this command.
      • mute on|off|toggle   -   control volume mute
      • shuffle on|off   -   control shuffle mode
      • repeat all|one|off   -   control repeat mode
      • balance -7 .. 7   -  set the left-right balance
      • bass 0...10 resp. bass -4..4 on ST300  -   set the bass level
      • only available on ST300 treble -4 .. 4   -  set the treble level
      • only available on ST300 dialog on|off   -   setting for speech output
      • recent 0...15   -   set number of names in the recent list in readings
      • source bluetooth,bt-discover,aux mode, airplay,tv,hdmi1   -   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 and related commands:
      • saveState   -   store the current play state in memory
      • restoreState [x]  -   restore a stored play state from memory, optional after x seconds
      • restoreVolAndOff [x]  -   restore a stored volume value memory and switch off box, optionally after x seconds
      • 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[,deviceID]   -   create multiroom zone and adds device(s) to the multiroom zone
      • addToZone deviceID   -   add device to multiroom zone
      • removeFromZone deviceID   -   remove device from multiroom zone
      • playEverywhere   -   play sound of a device on all others devices
      • stopPlayEverywhere   -   stop playing sound on all devices

      Example1: set BOSE_1234567890AB playEverywhere  Starts playing the sound of the device BOSE_1234567890AB on allother devices

      Example2: set BOSE_1234567890AB createZone AB1234567890,12AB34567890  Defines BOSE_1234567890AB as multiroom master and adds BOSE_AB1234567890 and BOSE_12AB34567890 to the multiroom zone

      Note: A "double-tap" (<1s) on a preset button (device or remote control) will toggle playEverywhere/stopPlayEverywhere


        Show clock command (only for ST20/30):
      • clock enable/disable   -   show or hide clock

      Example: set BOSE_1234567890AB clock enable  Show time in the ST20/30 display.


        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.
      • speakOff "message" [0...100] [+x|-x] [en|de|xx]   -   Text to speak, optional with volume adjustment and language to use. Device is switched off after speak.

      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.


        DNLA Server command:
      • autoAddDLNAServers 0|1   -   1=automatically add all DLNA servers to BOSE library. This command is only for "main" BOSEST, not for devices/speakers!

    Get
      n/a

    Attributes
      • staticIPs IP-Address [,IP-Address]  -   Manually define the used IP address(es) (comma separated) of your BOSE devices. Should be used only, if BOSEST device detection doesn't work in your network (e.g. several subnets on server, subnet not directly connected, ...)
        Example: attr bosesystem staticIPs 192.168.1.52,192.168.1.53
      • speakChannel channel(s)  -   speaks channel/presen name bevor starting a playback, useful for SoundTouch without display (comma separated or range: e.g. 2,3,5,6 or 1-6 ). TTS must be installed.
      • auto-zone on|off   -   automatic start multiroom zone play, if speakers are playing the same, according to "contentItemLocation"; (default: off)
      • originHandler function($DEVICE,$ORIGIN,...)   -   name of an external perl function for selection of music.
      • 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
      • ttsVolume [0...100] [+x|-x]   -   set the TTS volume level in percentage or change volume by ±x from current level
      • Channel_07 to Channel_20 name|location|source|[sourceAccount]   -   define preset 07 to 20
        When you play something, you can find ContentItemLocationName, ContentItemLocation, etc. in the readings. These data can be used here to define the preset.

=end html =begin html_DE

BOSEST

    BOSEST ist ein Modul um ein BOSE SoundTouch System zu steuern, (ein oder mehrere SoundTouch 10, 20 30, 300 oder Portable Geräte)

    Hinweis: Für das BOEST Modul sind folgende Bibliotheken erforderlich:
    • libwww-perl
    • libmojolicious-perl
    • libxml-simple-perl
    • libnet-bonjour-perl
    • libev-perl
    • liburi-escape-xs-perl
    • sox
    • libsox-fmt-mp3

    • Rufe sudo apt-get install libwww-perl libmojolicious-perl libxml-simple-perl libnet-bonjour-perl libev-perl liburi-escape-xs-perl auf, um diese Bibliotheken zu installieren.
      Hinweis:libmojolicious-perl muss mindestens die Version 5.54 haben.
      Rufe sudo apt-get install cpanminus and sudo cpanm Mojolicious auf, um auf die aktuelle Version zu updaten.

      Die Konfiguration von TTS (TextToSpeech) ist in diesem FHEM Forum Beitrag beschrieben: Link
      Fragen und Feedback bitte über das FHEM Forum: Link

    Define
      define <name> BOSEST

      Beispiel:
        define bosesystem BOSEST
        Definiert das BOSE SoundTouch System. Alle Lautsprecher erscheinen innerhalb von ca. 60 s unter "Unsorted" in FHEM.

    Set
      set <name> <command> [<parameter>]

      Die folgenden SET-Kommandos gelten für Lautsprecher (Ausnahme: autoAddDLNAServers ist nur für das Hauptmodul BOSEST) :

        Allgemein
      • on   -   Lautsprecher einschalten
      • off   -   Lautsprecher ausschalten
      • power   -   Wechselt zw. on und off
      • volume [0...100] [+x|-x]   -   Lautstärke setzen (direkt oder als ±x Differenz zur aktuellen Lautstärke)
      • channel 0...20   -   Preset auswählen
      • saveChannel 07...20   -   Aktuelle Wiedergabe als Preset 07 bis 20 speichern
      • play   -   Startet die Wiedergabe
      • pause   -   Pausiert die Wiedergabe
      • playPause   -   Wechselt zw. play und pause
      • playNotification   -   Spielt kurzen Signalton
      • stop   -   Stoppt die Wiedergabe
      • nextTrack   -   Nächsten Titel spielen
      • prevTrack   -   Vorherigen Titel spielen
      • playTrack name|location|source[|sourceAccount]   -   Sucht per DNLA nach dem Titel/Album/Artist und spielt ihn ab
      • origin originString   -   nur wenn nicht tv, bluetooth oder aux: ruft eine externe Fuktion, die im Attribut originHandler definiert wird. Dabei werden die Funktionsparameter $DEVICE,$ORIGIN durch den Namen des BOSEST-Devices und das Argument des origin-Befehls ersetzt
        Dies erlaubt die Definition einer eigenen Funktion zur Musikauswahl, z.B. von Playlists oder bestimmter Radiostationen. Durch Verwendung des widgetOverride-Attributes kann ferner eine Dropdown-Liste für diesen Abspielbefehl definiert werden.
      • mute on|off|toggle   -   Stummschaltung
      • shuffle on|off   -   Zufallswiedergabe
      • repeat all|one|off   -   Wiederholung
      • balance -7 .. 7   -  Links-Rechts-balance
      • bass 0...10 bzw. bass -4..4 auf ST300  -   Basseinstellung
      • Nur verfügbar auf ST300 treble -4 .. 4   -  Höheneinstellung
      • Nur verfügbar auf ST300 dialog on|off   -   Einstellung für Sprachausgabe
      • recent 0...15   -   Anzahl der Namen, die in der recent list in readings aufgeführt werden
      • source bluetooth,bt-discover,aux mode, airplay   -   lokale Quelle auswählen

      • addDLNAServer Name1 [Name2] [Namex]   -   DLNA server Name1 (und Name2 bis Namex) zur BOSE Bibliothek hinzufügen
      • removeDLNAServer Name1 [Name2] [Namex]   -   DLNA server Name1 (und Name2 bis Namex) aus der BOSE Bibliothek entfernen

      Beispiel: set BOSE_1234567890AB volume 25  Setzt die Lautstärke des Lautsprechers BOSE_1234567890AB auf 25.


        Zeiten und verwandte Befehle:
      • saveState   -   speichere den gegenwärtigen Abspielzustand
      • restoreState [x]  -   stelle den gespeicherten Abspielzustand wieder her, optional erst nach x Sekunden
      • restoreVolAndOff [x]  -   stelle den gespeicherten Lautstärkewert wieder her und schalte die Box aus, optional erst nach x Sekunden
      • on-for-timer 1...x   -   Schaltet den Lautsprecher für x Sekunden ein
      • off-for-timer 1...x   -   Schaltet den Lautsprecher für x Sekunden aus
      • on-till hh:mm:ss   -   Schaltet den Lautsprecher bis zur angegebenen Zeit ein
      • off-till hh:mm:ss   -   Schaltet den Lautsprecher bis und zur angegebenen Zeit aus
      • on-till-overneight hh:mm:ss   -   Schaltet den Lautsprecher bis zur angegebenen Zeit am nächsten Tag ein
      • off-till-overneight hh:mm:ss   -   Schaltet den Lautsprecher bis zur angegebenen Zeit am nächsten Tag aus

      Beispiel: set BOSE_1234567890AB on-till 23:00:00  Schaltet den Lautsprecher BOSE_1234567890AB ein und um 23:00:00 Uhr aus.


        Multiroom:
      • createZone deviceID[,deviceID]   -   Legt eine Wiedergabe-Zone an und fügt einen oder mehrere Lautsprecher der Wiedergabezone hinzu
      • addToZone deviceID   -   Fügt einen Lautsprecher einer bestehenden Wiedergabe-Zone zu
      • removeFromZone deviceID   -   Entfernt einen Lautsprecher aus einer Wiedergabe-Zone
      • playEverywhere   -   Startet "Überall wiedergeben"
      • stopPlayEverywhere   -   Beendet "Überall wiedergeben"

      Beispiel 1: set BOSE_1234567890AB playEverywhere  Startet die Überall-Wiedergabe (Mit dem Lautsprecher BOSE_1234567890AB als Master-Lautsprecher).

      Beispiel 2: set BOSE_1234567890AB createZone AB1234567890,12AB34567890  Definiert BOSE_1234567890AB als Master-Lautsprecher und fügt BOSE_AB1234567890 und BOSE_12AB34567890 der Wiedergabe-Zone hinzu

      Hinweis: Drücken Sie 2x ein Preset (innerhalb einer Sekunde) am Lautsprecher oder auf der Fernbedienung, wird "Überall Wiedergeben" ein oder ausgeschaltet.


        Uhr Anzeige (nur für ST20/30):
      • clock enable/disable   -   Schaltet die Uhrenanzeige im Standby um

      Beispiel: set BOSE_1234567890AB clock enable  Zeigt die Uhr im Standby auf dem Display des ST20/30 an.


        TextToSpeach (benötigt Google Translate):
      • speak "message" [0...100] [+x|-x] [en|de|xx]   -   Text den der Lautsprecher sagen soll, ggf. mit Lautstärkeangabe, die für diese Ansage verwendet werden soll. Nach der Ansage setzt der Lautsprecher die Wiedergabe fort.
      • speakOff "message" [0...100] [+x|-x] [en|de|xx]   -   Text den der Lautsprecher sagen soll, ggf. mit Lautstärkeangabe, die für diese Ansage verwendet werden soll. Nach der Ansage schaltet der Lautsprecher ab.

      Beispiel: set BOSE_1234567890AB speakOff "Ab isn Bett." 30 en  Spricht die Meldung mit Lautstärke 30 und schaltet den Lautsprecher dann aus.


        DNLA Server:
      • autoAddDLNAServers 0|1   -   1=automatisch alle DLNA servers zur BOSE Bibliothek hinzufügen. Dieser Parameter ist nur für das Hauptmodul BOSEST, nicht für die Lautsprecher!

    Get
      n/a

    Attribute
      • staticIPs IP-Address [,IP-Address]  -   Manuelle Angabe der IP Adresse(n). Sollte nur verwendet werden, wenn die automatiche Erkennung nicht funktioniert. (z.B. bei mehreren Sub-Netzwerken oder wenn Teile des Netzwerks manuell verbunden werden, ...)
        Beispiel: attr bosesystem staticIPs 192.168.1.52,192.168.1.53
      • speakChannel channel(s)   -   Ansage des aktuellen Preset vor der Wiedergabe, sinnvoll für SoundTouch Lautsprecher ohne Display (Angabe komma-separiert oder als Bereich: z.B. 2,3,5,6 oder 1-6 ). TTS muss eingerichtet sein.
      • auto-zone on|off   -   "Überall Wiedergabe" automatisch starten, wenn Lautsprecher das gleiche wiedergeben ("contentItemLocation" ist identisch); (Standardwert: off)
      • originHandler function($DEVICE,$ORIGIN,...)   -   Name einer externen Perl-Funktion zur Musikauswahl
      • ttsDirectory "directory"   -   Angabe des DLNA TTS Verzeichnisses. Der FHEM user muss Schreibrechte in diesem Verzeichnis haben.
      • ttsLanguage en|de|xx   -   Standardsprache für TTS setzen (default: en)
      • ttsSpeakOnError 0|1   -   0= Ansage "not available" unterdrücken
      • ttsVolume [0...100] [+x|-x]   -   Lautstärke setzen (direkt oder als ±x Differnez zur aktuellen Lautstärke)
      • Channel_07 to Channel_20 name|location|source|[sourceAccount]   -   Festlegen der Preset 07 bis 20
        Bei Wiedergabe kann in den readings "ContentItemLocationName, ContentItemLocation, etc. " ausgelesen werden. Dies Daten können dann verwendet werden, um die Presets zu belegen.

=end html_DE =cut