mirror of
https://github.com/fhem/fhem-mirror.git
synced 2025-02-07 23:09:26 +00:00
9f0568e4bb
git-svn-id: https://svn.fhem.de/fhem/trunk@18548 2b470e98-0d58-463d-a4d8-8e2adae1ed80
2772 lines
111 KiB
Perl
Executable File
2772 lines
111 KiB
Perl
Executable File
#############################################################
|
|
#
|
|
# BOSEST.pm (c) by Dominik Karall, 2016-2017
|
|
# dominik karall at gmail dot com
|
|
# $Id$
|
|
#
|
|
# FHEM module to communicate with BOSE SoundTouch system
|
|
# API as defined in BOSE SoundTouchAPI_WebServices_v1.0.1.pdf
|
|
#
|
|
# Version: 2.2.1
|
|
#
|
|
#############################################################
|
|
#
|
|
# v2.2.1 - 20181127
|
|
# - CHANGE: add staticIPs documentation in commandref
|
|
#
|
|
# v2.2.0 - 20171211
|
|
# - FEATURE: support new Audio Notification API for TTS (no more minidlna)
|
|
# - BUGFIX: fix warning about uninitialized value (thx@betateilchen!)
|
|
#
|
|
# v2.1.1 - 20170812
|
|
# - CHANGE: changed reading name type to model
|
|
#
|
|
# v2.1.0 - 20170129
|
|
# - NEW REQUIREMENT: TTS: sox, libsox-fmt-mp3 (only required for TTS)
|
|
# - FEATURE: TTS: add 1 second silence before TTS message for speak to
|
|
# prevent low volume on first words
|
|
# - FEATURE: TTS: support "unlimited" characters in TTS speak.
|
|
# Text is split in sentences which are afterwards
|
|
# merged with sox. Same sentences are downloaded
|
|
# only once a month to reduce requests to Google.
|
|
# - FEATURE: TTS: remove ttsDlnaServer attribut, it will be automatically discovered
|
|
# - BUGFIX: TTS: support pause/stop after speak if previous state was paused/stopped
|
|
# - BUGFIX: TTS: fix resume after speak when spotify running
|
|
# - BUGFIX: TTS: fix speakChannel for spotify presets
|
|
# - BUGFIX: TTS: use pause on TTS instead of stop to allow proper resume
|
|
# - BUGFIX: TTS: improved check after TTS play to restore previous state
|
|
# - BUGFIX: TTS: if state was invalid before TTS it will be set to standby
|
|
# - BUGFIX: fix save spotify to channel_7-20
|
|
# - BUGFIX: fix list of arguments
|
|
# - FEATURE: add $readingFnAttributes
|
|
# - FEATURE: add playPause toggle command
|
|
#
|
|
# v2.0.1 - 20161203
|
|
# - FEATURE: support shuffle/repeat (thx@rockyou)
|
|
# - BUGFIX: support special characters for TTS (thx@hschuett)
|
|
# - BUGFIX: module didn't work if you had only 1 preset active
|
|
# - CHANGE: add link to commandref on how to setup TTS
|
|
# - BUGFIX: remove "unknown event" log entry for ping/pong messages
|
|
#
|
|
# v2.0.0 - 20160718
|
|
# - CHANGE: first official release within fhem repository
|
|
#
|
|
# v1.5.7 - 20160623
|
|
# - BUGFIX: fix off command if zone-play is active
|
|
# - BUGFIX: fix auto-zone if zone-play is already active
|
|
# - BUGFIX: do not create auto-zone if both players play nothing
|
|
# - BUGFIX: update most readings only on change (reduces number of events)
|
|
# - BUGFIX: fix autoAddDLNAServers functionality in main device
|
|
#
|
|
# v1.5.6 - 20160611
|
|
# - FEATURE: auto-zone, start zone-play if speakers are playing the same (contentItemLocation)
|
|
# attr <name> autoZone on (default: off)
|
|
# - BUGFIX: set zone only from master device as slave devices might not always report the truth (known bug at BOSE)
|
|
# - CHANGE: set zonemember_X to UDN instead of player name
|
|
# - CHANGE: delete TTS files after 30 days
|
|
# - CHANGE: reduce number of http calls after each discovery process
|
|
# - CHANGE: reduce number of http calls for key presses
|
|
#
|
|
# v1.5.5 - 20160510
|
|
# - BUGFIX: fix unitiliazed value in handleDeviceByIp
|
|
#
|
|
# v1.5.4 - 20160509
|
|
# - FEATURE: restore volume when speaker goes online
|
|
# allows to power off the box completely without loosing
|
|
# previous volume settings
|
|
# - BUGFIX: fix possible unitialized value
|
|
# - BUGFIX: fix next which should be return
|
|
# - BUGFIX: sometimes double-tap feature wasn't working due to BOSE not
|
|
# updating zones for slave speakers
|
|
#
|
|
# v1.5.3 - 20160425
|
|
# - FEATURE: support static IPs (should only be used if device can't be discovered)
|
|
# attr bose_system staticIPs 192.168.1.52,192.168.1.53
|
|
# - FEATURE: support speak channel name (useful for SoundTouch w/o display)
|
|
# attr <name> speakChannel 1-6
|
|
# attr <name> speakChannel 2,3,5,6
|
|
# - BUGFIX: retry websocket setup every 5s if it fails
|
|
# - BUGFIX: update supportClockDisplay reading only on reconnect
|
|
# - CHANGE: remove user attr from main device
|
|
#
|
|
# v1.5.2 - 20160403
|
|
# - FEATURE: support clock display (SoundTouch 20/30)
|
|
# set <name> clock enable/disable
|
|
#
|
|
# v1.5.1 - 20160330
|
|
# - CHANGE: updated documentation (again many thx to Miami!)
|
|
# - FEATURE: support triple-tap (currently no function implemented: any ideas? :))
|
|
# - CHANGE: change back channel even after speakOff
|
|
# - BUGFIX: unitialized value fixed
|
|
#
|
|
# v1.5.0 - 20160306
|
|
# - FEATURE: support SetExtensions (on-for-timer,...)
|
|
# - FEATURE: support TTS (TextToSpeach) via Google Translate
|
|
# set <name> speak "This is a test message"
|
|
# - FEATURE: support volume control for TTS
|
|
# set <name> speak "This message has different volume" 30
|
|
# - FEATURE: support different languages for TTS
|
|
# set <name> speak "Das ist ein deutscher Test" de
|
|
# set <name> speak "Das ist ein deutscher Test" 30 de
|
|
# - FEATURE: support off (instead of resume) after TTS messages (restores only volume settings)
|
|
# set <name> speakOff "Music is going to switch off now. Good night." 30 en
|
|
# - FEATURE: speak "not available" text on Google Captcha
|
|
# can be disabled by ttsSpeakOnError = 0
|
|
# - FEATURE: set default TTS language via ttsLanguage attribute
|
|
# - FEATURE: automatically add DLNA server running on the same
|
|
# server as FHEM to the BOSE library
|
|
# - FEATURE: automatically add all DLNA servers to BOSE library
|
|
# requires autoAddDLNAServers = 1 attribute for "main" (not players!)
|
|
# - FEATURE: reuse cached TTS files for 30 days
|
|
# - FEATURE: set DLNA TTS directory via ttsDirectory attribute
|
|
# - FEATURE: set DLNA TTS server via ttsDLNAServer attribute
|
|
# only needed if the DLNA server is not the FHEM server
|
|
# - FEATURE: support ttsVolume for speak
|
|
# ttsVolume = 20 (set volume 20 for speak)
|
|
# ttsVolume = +20 (increase volume by 20 from current level)
|
|
# - FEATURE: add html documentation (provided by Miami)
|
|
# - FEATURE: support relative volume settings with +/-
|
|
# set <name> volume +3
|
|
# set <name> speak "This is a louder message" +10
|
|
# - FEATURE: new reading "connectedDLNAServers" (blanks are replaced by "-")
|
|
# - FEATURE: support add/remove DLNA servers to the BOSE library
|
|
# set <name> addDLNAServer RPi
|
|
# set <name> removeDLNAServer RPi
|
|
# - FEATURE: add readings for channel_07-20
|
|
# - FEATURE: support saveChannel to save current channel to channel_07-20
|
|
# - FEATURE: support bass settings only if available (/bassCapabilities)
|
|
# - FEATURE: support bluetooth only if available (/sources)
|
|
# - FEATURE: support switch source to airplay (untested)
|
|
# - BUGFIX: update zone on Player discovery
|
|
# - BUGFIX: fixed some uninitialized variables
|
|
# - CHANGE: limit recent_X readings to 15 max
|
|
#
|
|
# v1.0.0 - 20160219
|
|
# - FEATURE: support multi-room (playEverywhere, stopPlayEverywhere)
|
|
# - FEATURE: show current zone members in readings
|
|
# - FEATURE: support createZone <deviceID1>,<deviceID2>,...
|
|
# - FEATURE: support addToZone <deviceID1>,<deviceID2>,...
|
|
# - FEATURE: support removeFromZone <deviceID1>,<deviceID2>,...
|
|
# - FEATURE: add "double-tap" multi-room feature
|
|
# double-tap (<1s) a hardware preset button to
|
|
# enable or disable the multi-room feature
|
|
# - FEATURE: support bass settings
|
|
# - FEATURE: support infoUpdated (e.g. deviceName change)
|
|
# - FEATURE: support mute on/off/toggle
|
|
# - FEATURE: support recent channel list
|
|
# set name recent X
|
|
# names for recent list entries are shown in readings
|
|
# - FEATURE: support channel_07-20 by attribute
|
|
# format:name|location|source|sourceAccount or
|
|
# name|location|source| if no sourceAccount
|
|
# - FEATURE: support bluetooth/bt-discover/aux mode
|
|
# - FEATURE: support ignoreDeviceIDs for main define
|
|
# format:B23C23FF,A2EC81EF
|
|
# - CHANGE: reading channel_X => channel_0X (e.g. channel_02)
|
|
#
|
|
# v0.9.7 - 20160214
|
|
# - FEATURE: print module version on startup of main module
|
|
# - FEATURE: support device rename (e.g. BOSE_... => wz_BOSE)
|
|
# - FEATURE: show preset itemNames in channel_X reading
|
|
# - FEATURE: automatically update preset readings on change
|
|
# - FEATURE: add description reading (could be very long)
|
|
# - CHANGE: change log level for not implemented events to 4
|
|
# - CHANGE: use only one processXml function for websocket and http
|
|
# - BUGFIX: fix set off/on more than once within 1 second
|
|
# - BUGFIX: fix warnings during setup process
|
|
# - BUGFIX: support umlauts in all readings
|
|
# - BUGFIX: handle XMLin errors with eval
|
|
# - BUGFIX: handle "set" when speaker wasn't found yet
|
|
#
|
|
# v0.9.6 - 20160210
|
|
# - FEATURE: support prev/next track
|
|
#
|
|
# v0.9.5 - 20160210
|
|
# - FEATURE: update channel based on websocket events
|
|
# - BUGFIX: specify minimum libmojolicious-perl version
|
|
# - BUGFIX: reconnect websocket if handshake fails
|
|
# - BUGFIX: presence reading fixed
|
|
# - CHANGE: websocket request timeout changed to 10s (prev. 5s)
|
|
# - CHANGE: clockDisplayUpdated message handled now
|
|
#
|
|
# v0.9.4 - 20160206
|
|
# - CHANGE: completely drop ithreads (reduces memory usage)
|
|
# - CHANGE: search for new devices every 60s (BlockingCall)
|
|
# - CHANGE: check presence status based on websocket connection
|
|
# - BUGFIX: removed arguments and readings for main BOSEST
|
|
# - FEATURE: read volume on startup
|
|
#
|
|
# v0.9.3 - 20160125
|
|
# - BUGFIX: fix "EV does not work with ithreads."
|
|
#
|
|
# v0.9.2 - 20160123
|
|
# - BUGFIX: fix memory leak
|
|
# - BUGFIX: use select instead of usleep
|
|
#
|
|
# v0.9.1 - 20160122
|
|
# - BUGFIX: bugfix for on/off support
|
|
#
|
|
# v0.9 - 20160121
|
|
# - autodiscover BOSE SoundTouch players
|
|
# - add alias for newly created devices
|
|
# - update IP if the player IP changes
|
|
# - automatically re-connect websocket
|
|
# - support UTF-8 names with umlauts
|
|
# - reconnect websocket when connection closed
|
|
# - add firmware version & IP readings
|
|
# - automatically update /info on IP update
|
|
# - state: offline,playing,stopped,paused,online (online means standby)
|
|
# - support on/off commands based on current state
|
|
# - support more readings for now_playing
|
|
#
|
|
# v0.2 - 20160110
|
|
# - support stop/play/pause/power
|
|
# - change preset to channel according to DevGuidelinesAV
|
|
# - read /info on startup
|
|
# - connect to websocket to receive speaker events
|
|
#
|
|
# v0.1 - 20160105
|
|
# - define BOSE Soundtouch based on fixed IP
|
|
# - change volume via /volume
|
|
# - change preset via /key
|
|
#
|
|
# TODO
|
|
# - 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";
|
|
|
|
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 <name> BOSEST [<deviceID>]';
|
|
} 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 ".
|
|
"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 source commands
|
|
$hash->{helper}{supportedSourcesCmds} = "";
|
|
$hash->{helper}{supportedBassCmds} = "";
|
|
|
|
if (int(@a) < 3) {
|
|
Log3 $hash, 3, "BOSEST: BOSE SoundTouch v2.2.1";
|
|
#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_Set($@) {
|
|
my ($hash, $name, @params) = @_;
|
|
my $workType = shift(@params);
|
|
|
|
#get quoted text from params
|
|
my $blankParams = join(" ", @params);
|
|
my @params2;
|
|
while($blankParams =~ /"?((?<!")\S+(?<!")|[^"]+)"?\s*/g) {
|
|
push(@params2, $1);
|
|
}
|
|
@params = @params2;
|
|
|
|
my $list = "on:noArg off:noArg power:noArg play:noArg ".
|
|
"playPause:noArg ".
|
|
"mute:on,off,toggle recent source:".$hash->{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}{supportedBassCmds}." ".
|
|
"saveChannel:07,08,09,10,11,12,13,14,15,16,17,18,19,20 ".
|
|
"addDLNAServer:".$hash->{helper}{dlnaServers}." ".
|
|
"removeDLNAServer:".ReadingsVal($hash->{NAME}, "connectedDLNAServers", "noArg");
|
|
|
|
# check parameters for set function
|
|
#DEVELOPNEWFUNCTION-1
|
|
if($workType eq "?") {
|
|
if($hash->{DEVICEID} eq "0") {
|
|
return ""; #no arguments for server
|
|
} else {
|
|
return SetExtensions($hash, $list, $name, $workType, @params);
|
|
}
|
|
}
|
|
|
|
if($hash->{helper}{IP} eq "unknown") {
|
|
return "Searching for BOSE SoundTouch, try again later...";
|
|
}
|
|
|
|
if($workType eq "volume") {
|
|
return "BOSEST: volume requires volume as additional parameter" if(int(@params) < 1);
|
|
#params[0] = volume value
|
|
BOSEST_setVolume($hash, $params[0]);
|
|
} elsif($workType eq "zoneVolume") {
|
|
BOSEST_setZoneVolume($hash, $params[0]);
|
|
} elsif($workType eq "channel") {
|
|
return "BOSEST: channel requires preset id as additional parameter" if(int(@params) < 1);
|
|
#params[0] = preset channel
|
|
BOSEST_setPreset($hash, $params[0]);
|
|
} elsif($workType eq "saveChannel") {
|
|
return "BOSEST: saveChannel requires channel number as additional parameter" if(int(@params) < 1);
|
|
#params[09 = channel number (07-20)
|
|
BOSEST_saveChannel($hash, $params[0]);
|
|
} elsif($workType eq "bass") {
|
|
return "BOSEST: bass requires bass (1-10) as additional parameter" if(int(@params) < 1);
|
|
#params[0] = bass value
|
|
BOSEST_setBass($hash, $params[0]);
|
|
} elsif($workType eq "mute") {
|
|
return "BOSEST: mute requires on/off/toggle as additional parameter" if(int(@params) < 1);
|
|
#params[0] = mute value
|
|
BOSEST_setMute($hash, $params[0]);
|
|
} elsif($workType eq "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: recebt requires number as additional parameter" if(int(@params) < 1);
|
|
#params[0] = recent value
|
|
BOSEST_setRecent($hash, $params[0]);
|
|
} elsif($workType eq "source") {
|
|
return "BOSEST: source requires bluetooth/aux as additional parameter" if(int(@params) < 1);
|
|
#params[0] = source value
|
|
BOSEST_setSource($hash, $params[0]);
|
|
} elsif($workType eq "addDLNAServer") {
|
|
return "BOSEST: addDLNAServer requires DLNA friendly name as additional parameter" if(int(@params) < 1);
|
|
#params[0] = friendly name
|
|
BOSEST_addDLNAServer($hash, $params[0]);
|
|
} elsif($workType eq "removeDLNAServer") {
|
|
return "BOSEST: removeDLNAServer requires DLNA friendly name as additional parameter" if(int(@params) < 1);
|
|
#params[0] = friendly name
|
|
BOSEST_removeDLNAServer($hash, $params[0]);
|
|
} elsif($workType eq "clock") {
|
|
return "BOSEST: clock requires enable/disable as additional parameter" if(int(@params) < 1);
|
|
#check if supported
|
|
return "BOSEST: clock not supported." if(ReadingsVal($hash->{NAME}, "supportClockDisplay", "false") eq "false");
|
|
BOSEST_clockSettings($hash, $params[0]);
|
|
} elsif($workType eq "play") {
|
|
BOSEST_play($hash);
|
|
} elsif($workType eq "stop") {
|
|
BOSEST_stop($hash);
|
|
} elsif($workType eq "pause") {
|
|
BOSEST_pause($hash);
|
|
} elsif($workType eq "playPause") {
|
|
BOSEST_playPause($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]);
|
|
} else {
|
|
return SetExtensions($hash, $list, $name, $workType, @params);
|
|
}
|
|
|
|
return undef;
|
|
}
|
|
|
|
#DEVELOPNEWFUNCTION-2 (create own function)
|
|
sub BOSEST_setZoneVolume {
|
|
my ($hash, $targetVolume) = @_;
|
|
#FIXME
|
|
# #change volume of this device
|
|
# DLNARenderer_volume($hash, $targetVolume);
|
|
|
|
# #handle volume for all devices in the current group
|
|
# #iterate through group and change volume relative to the current volume of this device
|
|
# my $mainVolumeDiff = DLNARenderer_convertVolumeToAbsolute($hash, $targetVolume) - ReadingsVal($hash->{NAME}, "volume", 0);
|
|
# my $multiRoomUnits = ReadingsVal($hash->{NAME}, "multiRoomUnits", "");
|
|
# my @multiRoomUnitsArray = split("," $multiRoomUnits);
|
|
# foreach my $unit (@multiRoomUnitsArray) {
|
|
# my $devHash = DLNARenderer_getHashByFriendlyName($hash, $unit);
|
|
# my $newVolume = ReadingsVal($devHash->{NAME}, "volume", 0) + $mainVolumeDiff);
|
|
# if($newVolume > 100) {
|
|
# $newVolume = 100;
|
|
# } elsif($newVolume < 0) {
|
|
# $newVolume = 0;
|
|
# }
|
|
# DLNARenderer_volume($devHash, $newVolume);
|
|
# }
|
|
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_clockSettings($$) {
|
|
my ($hash, $val) = @_;
|
|
|
|
if($val eq "disable") {
|
|
$val = "false";
|
|
} else {
|
|
$val = "true";
|
|
}
|
|
|
|
my $postXml = "<clockDisplay><clockConfig userEnable=\"$val\"/></clockDisplay>";
|
|
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 = "<zone master=\"$hash->{DEVICEID}\">";
|
|
my $postXmlFooter = "</zone>";
|
|
my $postXml = "";
|
|
|
|
my @players = BOSEST_getAllBosePlayers($hash);
|
|
foreach my $playerHash (@players) {
|
|
if($playerHash->{DEVICEID} ne $hash->{DEVICEID}) {
|
|
$postXml .= "<member ipaddress=\"".$playerHash->{helper}{IP}."\">".$playerHash->{DEVICEID}."</member>" if($playerHash->{helper}{IP} ne "unknown");
|
|
}
|
|
}
|
|
|
|
$postXml = $postXmlHeader.$postXml.$postXmlFooter;
|
|
|
|
if(BOSEST_HTTPPOST($hash, '/removeZoneSlave', $postXml)) {
|
|
#ok
|
|
}
|
|
}
|
|
|
|
sub BOSEST_playEverywhere($) {
|
|
my ($hash) = @_;
|
|
my $postXmlHeader = "<zone master=\"$hash->{DEVICEID}\" senderIPAddress=\"$hash->{helper}{IP}\">";
|
|
my $postXmlFooter = "</zone>";
|
|
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 .= "<member ipaddress=\"".$playerHash->{helper}{IP}."\">".$playerHash->{DEVICEID}."</member>" 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 = "<zone master=\"$hash->{DEVICEID}\" senderIPAddress=\"$hash->{helper}{IP}\">";
|
|
my $postXmlFooter = "</zone>";
|
|
my $postXml = "";
|
|
|
|
foreach my $deviceId (@devices) {
|
|
my $playerHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId);
|
|
|
|
return undef if(!defined($playerHash));
|
|
|
|
$postXml .= "<member ipaddress=\"".$playerHash->{helper}{IP}."\">".$playerHash->{DEVICEID}."</member>" 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 = "<zone master=\"$hash->{DEVICEID}\" senderIPAddress=\"$hash->{helper}{IP}\">";
|
|
my $postXmlFooter = "</zone>";
|
|
my $postXml = "";
|
|
|
|
foreach my $deviceId (@devices) {
|
|
my $playerHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId);
|
|
|
|
return undef if(!defined($playerHash));
|
|
|
|
$postXml .= "<member ipaddress=\"".$playerHash->{helper}{IP}."\">".$playerHash->{DEVICEID}."</member>" 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 = "<zone master=\"$hash->{DEVICEID}\">";
|
|
my $postXmlFooter = "</zone>";
|
|
my $postXml = "";
|
|
|
|
foreach my $deviceId (@devices) {
|
|
my $playerHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId);
|
|
|
|
return undef if(!defined($playerHash));
|
|
|
|
$postXml .= "<member ipaddress=\"".$playerHash->{helper}{IP}."\">".$playerHash->{DEVICEID}."</member>" 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 = "<ContentItem source=\"".
|
|
$source.
|
|
"\" sourceAccount=\"".
|
|
$sourceAccount.
|
|
"\" location=\"".
|
|
$location.
|
|
"\" type=\"".
|
|
$type.
|
|
"\">".
|
|
"<itemName>".
|
|
$itemName.
|
|
"</itemName>".
|
|
"</ContentItem>";
|
|
|
|
if(BOSEST_HTTPPOST($hash, "/select", $postXml)) {
|
|
#ok
|
|
}
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_setBass($$) {
|
|
my ($hash, $bass) = @_;
|
|
$bass = $bass - 10;
|
|
my $postXml = "<bass>$bass</bass>";
|
|
if(BOSEST_HTTPPOST($hash, '/bass', $postXml)) {
|
|
}
|
|
#FIXME error handling
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_setVolume($$) {
|
|
my ($hash, $volume) = @_;
|
|
|
|
if(substr($volume, 0, 1) eq "+" or
|
|
substr($volume, 0, 1) eq "-") {
|
|
$volume = ReadingsVal($hash->{NAME}, "volume", 0) + $volume;
|
|
}
|
|
|
|
my $postXml = '<volume>'.$volume.'</volume>';
|
|
if(BOSEST_HTTPPOST($hash, '/volume', $postXml)) {
|
|
}
|
|
#FIXME error handling
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_setMute($$) {
|
|
my ($hash, $mute) = @_;
|
|
|
|
if(($mute eq "on" && $hash->{READINGS}{mute}{VAL} eq "false") or
|
|
($mute eq "off" && $hash->{READINGS}{mute}{VAL} eq "true") or
|
|
($mute eq "toggle")) {
|
|
BOSEST_sendKey($hash, "MUTE");
|
|
}
|
|
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_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 "") {
|
|
return undef;
|
|
}
|
|
|
|
if($hash->{helper}{switchSource} eq "BT-DISCOVER" &&
|
|
ReadingsVal($hash->{NAME}, "connectionStatusInfo", "") eq "DISCOVERABLE") {
|
|
$hash->{helper}{switchSource} = "";
|
|
return undef;
|
|
}
|
|
|
|
if($hash->{helper}{switchSource} eq ReadingsVal($hash->{NAME}, "source", "") &&
|
|
ReadingsVal($hash->{NAME}, "connectionStatusInfo", "") ne "DISCOVERABLE") {
|
|
$hash->{helper}{switchSource} = "";
|
|
return undef;
|
|
}
|
|
|
|
#source is not switchSource yet
|
|
BOSEST_sendKey($hash, "AUX_INPUT");
|
|
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_setPreset($$) {
|
|
my ($hash, $preset) = @_;
|
|
if($preset > 0 && $preset < 7) {
|
|
BOSEST_sendKey($hash, "PRESET_".$preset);
|
|
} else {
|
|
#set channel based on AttrVal
|
|
my $channelVal = AttrVal($hash->{NAME}, sprintf("channel_%02d", $preset), "0");
|
|
return undef if($channelVal eq "0");
|
|
my @channel = split("\\|", $channelVal);
|
|
$channel[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_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;
|
|
}
|
|
|
|
sub BOSEST_Undef($) {
|
|
my ($hash) = @_;
|
|
|
|
#remove internal timer
|
|
RemoveInternalTimer($hash);
|
|
|
|
#kill blocking
|
|
BlockingKill($hash->{helper}{DISCOVERY_PID}) if(defined($hash->{helper}{DISCOVERY_PID}));
|
|
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_Get($$) {
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_speakChannel {
|
|
my ($hash) = @_;
|
|
|
|
my $speakChannel = AttrVal($hash->{NAME}, "speakChannel", "");
|
|
if($speakChannel ne "") {
|
|
my $channelNr = ReadingsVal($hash->{NAME}, "channel", "");
|
|
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 = '<play_info><app_key>Ml7YGAI9JWjFhU7D348e86JPXtisddBa</app_key><url>'.$translateUrl.'</url><service>'.$text.'</service><volume>'.$volume.'</volume></play_info>';
|
|
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;
|
|
}
|
|
|
|
sub BOSEST_saveCurrentState($) {
|
|
my ($hash) = @_;
|
|
|
|
$hash->{helper}{savedState}{volume} = ReadingsVal($hash->{NAME}, "volume", 20);
|
|
$hash->{helper}{savedState}{source} = ReadingsVal($hash->{NAME}, "source", "");
|
|
$hash->{helper}{savedState}{bass} = ReadingsVal($hash->{NAME}, "bass", "");
|
|
$hash->{helper}{savedState}{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_setBass($hash, $hash->{helper}{savedState}{bass});
|
|
|
|
#bose 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_setBass($hash, $hash->{helper}{savedState}{bass});
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
sub BOSEST_setMusicServiceAccount($$$) {
|
|
my ($hash, $friendlyName, $uuid) = @_;
|
|
my $postXml = '<credentials source="STORED_MUSIC" displayName="'.
|
|
$friendlyName.
|
|
'"><user>'.
|
|
$uuid.'/0'.
|
|
'</user><pass/></credentials>';
|
|
if(BOSEST_HTTPPOST($hash, '/setMusicServiceAccount', $postXml)) {
|
|
#ok
|
|
}
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_removeMusicServiceAccount($$$) {
|
|
my ($hash, $friendlyName, $uuid) = @_;
|
|
my $postXml = '<credentials source="STORED_MUSIC" displayName="'.
|
|
$friendlyName.
|
|
'"><user>'.
|
|
$uuid.
|
|
'</user><pass/></credentials>';
|
|
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;
|
|
}
|
|
|
|
sub BOSEST_searchTrack($$$) {
|
|
my ($hash, $dlnaUid, $trackName) = @_;
|
|
|
|
my $postXml = '<search source="STORED_MUSIC" sourceAccount="'.
|
|
$dlnaUid.
|
|
'"><startItem>1</startItem><numItems>1</numItems><searchTerm filter="track">'.
|
|
$trackName.
|
|
'</searchTerm></search>';
|
|
|
|
if(my $xmlSearchResult = BOSEST_HTTPPOST($hash, '/search', $postXml)) {
|
|
#return first item from search results
|
|
if($xmlSearchResult->{searchResponse}->{items}) {
|
|
return $xmlSearchResult->{searchResponse}->{items}->{item}[0]->{ContentItem};
|
|
}
|
|
}
|
|
return undef;
|
|
}
|
|
|
|
###### UPDATE VIA HTTP ######
|
|
sub BOSEST_updateClock($$) {
|
|
my ($hash, $deviceId) = @_;
|
|
my $clockDisplay = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/clockDisplay");
|
|
BOSEST_processXml($hash, $clockDisplay);
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_updateInfo($$) {
|
|
my ($hash, $deviceId) = @_;
|
|
my $info = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/info");
|
|
BOSEST_processXml($hash, $info);
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_updateSources($$) {
|
|
my ($hash, $deviceId) = @_;
|
|
my $sources = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/sources");
|
|
BOSEST_processXml($hash, $sources);
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_updatePresets($$) {
|
|
my ($hash, $deviceId) = @_;
|
|
my $presets = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/presets");
|
|
BOSEST_processXml($hash, $presets);
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_updateZone($$) {
|
|
my ($hash, $deviceId) = @_;
|
|
my $zone = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/getZone");
|
|
BOSEST_processXml($hash, $zone);
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_updateVolume($$) {
|
|
my ($hash, $deviceId) = @_;
|
|
my $volume = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/volume");
|
|
BOSEST_processXml($hash, $volume);
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_updateBass($$) {
|
|
my ($hash, $deviceId) = @_;
|
|
my $bass = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/bass");
|
|
BOSEST_processXml($hash, $bass);
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_updateNowPlaying($$) {
|
|
my ($hash, $deviceId) = @_;
|
|
my $nowPlaying = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/now_playing");
|
|
BOSEST_processXml($hash, $nowPlaying);
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_updateAutoZone {
|
|
my ($hash, $location) = @_;
|
|
|
|
return undef if($location eq "");
|
|
return undef if(AttrVal($hash->{NAME}, "autoZone", "off") eq "off");
|
|
|
|
my @allPlayers = BOSEST_getAllBosePlayers($hash);
|
|
my $newZoneMaster;
|
|
my $createZone = 0;
|
|
foreach my $playerHash (@allPlayers) {
|
|
next if($playerHash->{DEVICEID} eq $hash->{DEVICEID});
|
|
|
|
my $playerLocation = ReadingsVal($playerHash->{NAME}, "contentItemLocation", "");
|
|
my $playerZoneMaster = ReadingsVal($playerHash->{NAME}, "zoneMaster", "");
|
|
Log3 $hash, 5, "BOSEST: auto-zone $hash->{NAME}: $location = $playerHash->{NAME}: $playerLocation?";
|
|
#make sure that $playerHash is master device
|
|
if($playerLocation eq $location && ($playerZoneMaster eq "" or $playerZoneMaster eq $playerHash->{DEVICEID})) {
|
|
#TODO: check if createZone is needed
|
|
$newZoneMaster = $playerHash;
|
|
$createZone = 1 if($playerZoneMaster eq "");
|
|
}
|
|
}
|
|
|
|
if($newZoneMaster) {
|
|
if($createZone) {
|
|
BOSEST_createZone($newZoneMaster, $hash->{DEVICEID});
|
|
} else {
|
|
BOSEST_addToZone($newZoneMaster, $hash->{DEVICEID});
|
|
}
|
|
}
|
|
}
|
|
|
|
sub BOSEST_checkDoubleTap($$) {
|
|
my ($hash, $channel) = @_;
|
|
|
|
return undef if($channel eq "" 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;
|
|
}
|
|
|
|
###### XML PROCESSING ######
|
|
sub BOSEST_processXml($$) {
|
|
my ($hash, $wsxml) = @_;
|
|
|
|
Log3 $hash, 5, "BOSEST: processXml:\n".Dumper($wsxml);
|
|
|
|
if($wsxml->{updates}) {
|
|
if($wsxml->{updates}->{nowPlayingUpdated}) {
|
|
if($wsxml->{updates}->{nowPlayingUpdated}->{nowPlaying}) {
|
|
BOSEST_parseAndUpdateNowPlaying($hash, $wsxml->{updates}->{nowPlayingUpdated}->{nowPlaying});
|
|
if($hash->{helper}{switchSource} ne "") {
|
|
BOSEST_setSource($hash, $hash->{helper}{switchSource});
|
|
} else {
|
|
BOSEST_speakChannel($hash);
|
|
}
|
|
}
|
|
} elsif ($wsxml->{updates}->{volumeUpdated}) {
|
|
BOSEST_parseAndUpdateVolume($hash, $wsxml->{updates}->{volumeUpdated}->{volume});
|
|
} elsif ($wsxml->{updates}->{nowSelectionUpdated}) {
|
|
BOSEST_parseAndUpdateChannel($hash, $wsxml->{updates}->{nowSelectionUpdated}->{preset}[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}->{bassUpdated}) {
|
|
#bassUpdated is just a notification with no data
|
|
BOSEST_updateBass($hash, $hash->{DEVICEID});
|
|
} elsif ($wsxml->{updates}->{infoUpdated}) {
|
|
#infoUpdated is just a notification with no data
|
|
BOSEST_updateInfo($hash, $hash->{DEVICEID});
|
|
} elsif ($wsxml->{updates}->{sourcesUpdated}) {
|
|
#sourcesUpdated is just a notification with no data
|
|
BOSEST_updateSources($hash, $hash->{DEVICEID});
|
|
} elsif ($wsxml->{updates}->{clockTimeUpdated}) {
|
|
BOSEST_parseAndUpdateClock($hash, $wsxml->{updates}->{clockTimeUpdated});
|
|
} else {
|
|
Log3 $hash, 4, "BOSEST: Unknown event, please implement:\n".Dumper($wsxml);
|
|
}
|
|
} elsif($wsxml->{info}) {
|
|
BOSEST_parseAndUpdateInfo($hash, $wsxml->{info});
|
|
} elsif($wsxml->{nowPlaying}) {
|
|
BOSEST_parseAndUpdateNowPlaying($hash, $wsxml->{nowPlaying});
|
|
} elsif($wsxml->{volume}) {
|
|
BOSEST_parseAndUpdateVolume($hash, $wsxml->{volume});
|
|
} elsif($wsxml->{presets}) {
|
|
BOSEST_parseAndUpdatePresets($hash, $wsxml->{presets});
|
|
} elsif($wsxml->{bass}) {
|
|
BOSEST_parseAndUpdateBass($hash, $wsxml->{bass});
|
|
} elsif($wsxml->{zone}) {
|
|
BOSEST_parseAndUpdateZone($hash, $wsxml->{zone});
|
|
} elsif($wsxml->{sources}) {
|
|
BOSEST_parseAndUpdateSources($hash, $wsxml->{sources}->{sourceItem});
|
|
} 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) = @_;
|
|
readingsBeginUpdate($hash);
|
|
BOSEST_XMLUpdate($hash, "volume", $volume->{actualvolume});
|
|
BOSEST_XMLUpdate($hash, "mute", $volume->{muteenabled});
|
|
readingsEndUpdate($hash, 1);
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_parseAndUpdateBass($$) {
|
|
my ($hash, $bass) = @_;
|
|
my $currBass = $bass->{actualbass} + 10;
|
|
readingsBeginUpdate($hash);
|
|
BOSEST_XMLUpdate($hash, "bass", $currBass);
|
|
readingsEndUpdate($hash, 1);
|
|
return undef;
|
|
}
|
|
|
|
sub BOSEST_parseAndUpdateInfo($$) {
|
|
my ($hash, $info) = @_;
|
|
$info->{name} = Encode::encode('UTF-8', $info->{name});
|
|
readingsBeginUpdate($hash);
|
|
BOSEST_XMLUpdate($hash, "deviceName", $info->{name});
|
|
BOSEST_XMLUpdate($hash, "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;
|
|
|
|
readingsEndUpdate($hash, 1);
|
|
|
|
return undef;
|
|
}
|
|
|
|
###### DISCOVERY #######
|
|
sub BOSEST_startDiscoveryProcess($) {
|
|
my ($hash) = @_;
|
|
|
|
if(!$init_done) {
|
|
#init not done yet, wait 3 more seconds
|
|
InternalTimer(gettimeofday()+3, "BOSEST_startDiscoveryProcess", $hash, 0);
|
|
}
|
|
|
|
if (!defined($hash->{helper}{DISCOVERY_PID})) {
|
|
$hash->{helper}{DISCOVERY_PID} = BlockingCall("BOSEST_Discovery", $hash->{NAME}."|".$hash, "BOSEST_finishedDiscovery");
|
|
}
|
|
}
|
|
|
|
sub BOSEST_handleDeviceByIp {
|
|
my ($hash, $ip) = @_;
|
|
my $return = "";
|
|
|
|
my $info = BOSEST_HTTPGET($hash, $ip, "/info");
|
|
#remove info tag to reduce line length
|
|
$info = $info->{info} if (defined($info->{info}));
|
|
#skip entry if no deviceid was found
|
|
return "" if (!defined($info->{deviceID}));
|
|
|
|
#TODO return if the device is already defined and IP is the same
|
|
# make sure that this can be done and no further code below is needed
|
|
|
|
#create new device if it doesn't exist
|
|
if(!defined(BOSEST_getBosePlayerByDeviceId($hash, $info->{deviceID}))) {
|
|
$info->{name} = Encode::encode('UTF-8',$info->{name});
|
|
Log3 $hash, 3, "BOSEST: Device $info->{name} ($info->{deviceID}) found.";
|
|
$return = $return."|commandDefineBOSE|$info->{deviceID},$info->{name}";
|
|
|
|
#set supported capabilities
|
|
my $capabilities = BOSEST_HTTPGET($hash, $ip, "/capabilities");
|
|
$return .= "|capabilities|$info->{deviceID}";
|
|
if($capabilities->{capabilities}->{clockDisplay}) {
|
|
$return .= ",".$capabilities->{capabilities}->{clockDisplay};
|
|
} else {
|
|
$return .= ",false";
|
|
}
|
|
|
|
#set supported bass capabilities
|
|
my $bassCapabilities = BOSEST_HTTPGET($hash, $ip, "/bassCapabilities");
|
|
$return .= "|bassCapabilities|$info->{deviceID}";
|
|
if($bassCapabilities->{bassCapabilities}) {
|
|
my $bassCap = $bassCapabilities->{bassCapabilities};
|
|
$return .= ",".$bassCap->{bassAvailable}.",".$bassCap->{bassMin}.",".
|
|
$bassCap->{bassMax}.",".$bassCap->{bassDefault};
|
|
}
|
|
}
|
|
|
|
#TODO create own function (add own DLNA server)
|
|
my $myIp = BOSEST_getMyIp($hash);
|
|
my $listMediaServers = BOSEST_HTTPGET($hash, $ip, "/listMediaServers");
|
|
|
|
#set supported sources
|
|
my $sources = BOSEST_HTTPGET($hash, $ip, "/sources");
|
|
$return .= "|supportedSources|$info->{deviceID}";
|
|
foreach my $source (@{ $sources->{sources}->{sourceItem} }) {
|
|
$return .= ",".$source->{source};
|
|
}
|
|
|
|
my $returnListMediaServers = "|listMediaServers|".$info->{deviceID};
|
|
foreach my $mediaServer (@{ $listMediaServers->{ListMediaServersResponse}->{media_server} }) {
|
|
$returnListMediaServers .= ",".$mediaServer->{friendly_name};
|
|
|
|
#check if it is already connected
|
|
my $isConnected = 0;
|
|
foreach my $source (@{ $sources->{sources}->{sourceItem} }) {
|
|
next if($source->{source} ne "STORED_MUSIC");
|
|
|
|
if(substr($source->{sourceAccount}, 0, length($mediaServer->{id})) eq $mediaServer->{id}) {
|
|
#already connected
|
|
$isConnected = 1;
|
|
next;
|
|
}
|
|
}
|
|
|
|
next if($isConnected);
|
|
|
|
if(($myIp eq $mediaServer->{ip}) ||
|
|
(AttrVal($hash->{NAME}, "autoAddDLNAServers", "0") eq "1" )) {
|
|
$return = $return."|setMusicServiceAccount|".$info->{deviceID}.",".$mediaServer->{friendly_name}.",".$mediaServer->{id};
|
|
Log3 $hash, 3, "BOSEST: DLNA Server ".$mediaServer->{friendly_name}." added.";
|
|
}
|
|
}
|
|
|
|
#append listMediaServers
|
|
$return .= $returnListMediaServers;
|
|
|
|
#update IP address of the device
|
|
$return = $return."|updateIP|".$info->{deviceID}.",".$ip;
|
|
|
|
return $return;
|
|
}
|
|
|
|
sub BOSEST_Discovery($) {
|
|
my ($string) = @_;
|
|
my ($name, $hash) = split("\\|", $string);
|
|
my $return = "$name";
|
|
|
|
$hash = $main::defs{$name};
|
|
|
|
eval {
|
|
my $res = Net::Bonjour->new('soundtouch');
|
|
$res->discover;
|
|
foreach my $device ($res->entries) {
|
|
$return .= BOSEST_handleDeviceByIp($hash, $device->address);
|
|
}
|
|
};
|
|
|
|
#update static players
|
|
my @staticIPs = split(",", AttrVal($hash->{NAME}, "staticIPs", ""));
|
|
foreach my $ip (@staticIPs) {
|
|
$return .= BOSEST_handleDeviceByIp($hash, $ip);
|
|
}
|
|
|
|
if($@) {
|
|
Log3 $hash, 3, "BOSEST: Discovery failed with: $@";
|
|
}
|
|
|
|
return $return;
|
|
}
|
|
|
|
sub BOSEST_finishedDiscovery($) {
|
|
my ($string) = @_;
|
|
my @commands = split("\\|", $string);
|
|
my $name = $commands[0];
|
|
my $hash = $defs{$name};
|
|
my $i = 0;
|
|
my $ignoreDeviceIDs = AttrVal($hash->{NAME}, "ignoreDeviceIDs", "");
|
|
|
|
delete($hash->{helper}{DISCOVERY_PID});
|
|
|
|
#start discovery again after 67s
|
|
InternalTimer(gettimeofday()+67, "BOSEST_startDiscoveryProcess", $hash, 1);
|
|
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);
|
|
#bassAvailable, bassMin, bassMax, bassDefault
|
|
$deviceHash->{helper}{bassAvailable} = 1 if($params[0] eq "true");
|
|
$deviceHash->{helper}{bassMin} = $params[1];
|
|
$deviceHash->{helper}{bassMax} = $params[2];
|
|
$deviceHash->{helper}{bassDefault} = $params[3];
|
|
if($params[0] eq "true") {
|
|
$deviceHash->{helper}{supportedBassCmds} = "bass:slider,1,1,10";
|
|
} else {
|
|
$deviceHash->{helper}{supportedBassCmds} = "";
|
|
}
|
|
} elsif($command eq "supportedSources") {
|
|
my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId);
|
|
#list of supported sources
|
|
$deviceHash->{helper}{bluetoothSupport} = 0;
|
|
$deviceHash->{helper}{auxSupport} = 0;
|
|
$deviceHash->{helper}{airplaySupport} = 0;
|
|
$deviceHash->{helper}{supportedSourcesCmds} = "";
|
|
foreach my $source (@params) {
|
|
if($source eq "BLUETOOTH") {
|
|
$deviceHash->{helper}{bluetoothSupport} = 1;
|
|
$deviceHash->{helper}{supportedSourcesCmds} .= "bluetooth,bt-discover,";
|
|
} elsif($source eq "AUX") {
|
|
$deviceHash->{helper}{auxSupport} = 1;
|
|
$deviceHash->{helper}{supportedSourcesCmds} .= "aux,";
|
|
} elsif($source eq "AIRPLAY") {
|
|
$deviceHash->{helper}{airplaySupport} = 1;
|
|
$deviceHash->{helper}{supportedSourcesCmds} .= "airplay,";
|
|
}
|
|
}
|
|
$deviceHash->{helper}{supportedSourcesCmds} = substr($deviceHash->{helper}{supportedSourcesCmds}, 0, length($deviceHash->{helper}{supportedSourcesCmds})-1);
|
|
} elsif($command eq "capabilities") {
|
|
my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId);
|
|
if(ReadingsVal($deviceHash->{NAME}, "supportClockDisplay", "") ne $params[0]) {
|
|
readingsSingleUpdate($deviceHash, "supportClockDisplay", $params[0], 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
sub BOSEST_updateIP($$$) {
|
|
my ($hash, $deviceID, $ip) = @_;
|
|
my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceID);
|
|
#check current IP of the device
|
|
my $currentIP = $deviceHash->{helper}{IP};
|
|
$currentIP = "unknown" if(!defined($currentIP));
|
|
|
|
#if update is needed, get info/now_playing
|
|
if($currentIP ne $ip) {
|
|
$deviceHash->{helper}{IP} = $ip;
|
|
readingsSingleUpdate($deviceHash, "IP", $ip, 1);
|
|
readingsSingleUpdate($deviceHash, "presence", "online", 1);
|
|
Log3 $hash, 3, "BOSEST: $deviceHash->{NAME}, new IP ($ip)";
|
|
#get info
|
|
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 settings
|
|
Log3 $hash, 5, "BOSEST: BOSEST_updateBass";
|
|
BOSEST_updateBass($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
|
|
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('<msg><header deviceID="'.
|
|
$hash->{DEVICEID}.
|
|
'" url="webserver/pingRequest" method="GET"><request requestID="'.
|
|
$hash->{helper}{requestId}.
|
|
'"><info type="new"/></request></header></msg>');
|
|
#$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 ######
|
|
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 state="press" sender="Gabbo">'.$key.'</key>';
|
|
if(BOSEST_HTTPPOST($hash, '/key', $postXml)) {
|
|
$postXml = '<key state="release" sender="Gabbo">'.$key.'</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_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
|
|
|
|
<a name="BOSEST"></a>
|
|
<h3>BOSEST</h3>
|
|
|
|
<ul>BOSEST is used to control a BOSE SoundTouch system (one or more SoundTouch 10, 20 30, 300 or Portable devices)<br><br>
|
|
<b>Note:</b> The following libraries are required for this module:
|
|
<ul>
|
|
<li>libwww-perl</li> <li>libmojolicious-perl</li> <li>libxml-simple-perl</li> <li>libnet-bonjour-perl</li> <li>libev-perl</li><li>liburi-escape-xs-perl</li>
|
|
<li>sox</li>
|
|
<li>libsox-fmt-mp3</li><br>
|
|
Use <b>sudo apt-get install libwww-perl libmojolicious-perl libxml-simple-perl libnet-bonjour-perl libev-perl liburi-escape-xs-perl</b> to install this libraries.<br>
|
|
Please note: libmojolicious-perl must be >=5.54<br>
|
|
Use <b>sudo apt-get install cpanminus</b> and <b>sudo cpanm Mojolicious</b> to update to the newest version.<br><br>
|
|
TextToSpeach (TTS) can be configured as described in the following thread: <a target= "_blank" href="https://forum.fhem.de/index.php/topic,46838.0.html">Link</a><br>
|
|
Questions and/or feedback can be posted on the FHEM forum: <a target= "_blank" href="https://forum.fhem.de/index.php/topic,46838.msg533050.html#new">Link</a>
|
|
</ul><br><br>
|
|
|
|
<a name="BOSESTdefine" id="BOSESTdefine"></a>
|
|
<b>Define</b>
|
|
<ul>
|
|
<code>define <name> BOSEST</code><br><br>
|
|
Example:
|
|
<ul>
|
|
<code>define bosesystem BOSEST</code><br>
|
|
Defines BOSE SoundTouch system. All devices/speakers will show up after 60s under "Unsorted" in FHEM.<br>
|
|
</ul>
|
|
</ul><br>
|
|
|
|
<a name="BOSESTset" id="BOSESTset"></a>
|
|
<b>Set</b>
|
|
<ul><code>set <name> <command> [<parameter>]</code><br><br>
|
|
The following commands are defined for the devices/speakers (except <b>autoAddDLNAServers</b> is for the "main" BOSEST) :<br><br>
|
|
<ul><u>General commands</u>
|
|
<li><code><b>on</b></code> - power on the device</li>
|
|
<li><code><b>off</b></code> - turn the device off</li>
|
|
<li><code><b>power</b></code> - toggle on/off</li>
|
|
<li><code><b>volume</b> [0...100] [+x|-x]</code> - set the volume level in percentage or change volume by ±x from current level</li>
|
|
<li><code><b>channel</b> 0...20</code> - select present to play</li>
|
|
<li><code><b>saveChannel</b> 07...20</code> - save current channel to channel 07 to 20</li>
|
|
<li><code><b>play</b></code> - start to play </li>
|
|
<li><code><b>pause</b></code> - pause the playback</li>
|
|
|
|
<li><code><b>playpause</b></code> - toggle play/pause</li>
|
|
<li><code><b>stop</b></code> - stop playback</li><li><code><b>nextTrack</b></code> - play next track</li>
|
|
<li><code><b>prevTrack</b></code> - play previous track</li>
|
|
<li><code><b>playTrack </b> name|location|source[|sourceAccount]</code> - searches per DNLA for the track title and plays it <br>
|
|
<font size=-1>additional information:
|
|
<ol>
|
|
<li>You can search for trackTitle, trackAlbum, trackArtist (<a target= "_blank" href="https://forum.fhem.de/index.php?topic=46838.msg594029#msg594029">german FHEM forum post</a>) </li>
|
|
<li>You can test it in the SoundTouch APP: Use the search in the APP to see if a playlist is found and played. It yes it will work with playTrack too. (<a target= "_blank" href="https://forum.fhem.de/index.php/topic,46838.msg594693.html#msg594693">german FHEM forum post</a>) </li>
|
|
</ol>
|
|
</font></li>
|
|
<li><code><b>mute</b> on|off|toggle</code> - control volume mute</li>
|
|
<li><code><b>shuffle</b> on|off</code> - control shuffle mode</li>
|
|
<li><code><b>repeat</b> all|one|off</code> - control repeat mode</li>
|
|
<li><code><b>bass</b> 0...10</code> - set the bass level</li>
|
|
<li><code><b>recent</b> 0...15</code> - set number of names in the recent list in readings</li>
|
|
<li><code><b>source</b> bluetooth,bt-discover,aux mode, airplay</code> - select a local source</li><br>
|
|
<li><code><b>addDLNAServer</b> Name1 [Name2] [Namex]</code> - add DLNA servers Name1 (and Name2 to Namex) to the BOSE library</li>
|
|
<li><code><b>removeDLNAServer</b> Name1 [Name2] [Namex]</code> - remove DLNA servers Name1 (and Name2 to Namex) to the BOSE library</li>
|
|
</ul><br>
|
|
Example: <code>set BOSE_1234567890AB volume 25</code> Set volume on device with the name BOSE_1234567890AB <br><br><br>
|
|
|
|
<ul><u>Timer commands:</u>
|
|
<li><code><b>on-for-timer</b> 1...x</code> - power on the device for x seconds</li>
|
|
<li><code><b>off-for-timer</b> 1...x</code> - turn the device off and power on again after x seconds</li>
|
|
<li><code><b>on-till</b> hh:mm:ss</code> - power on the device until defined time</li>
|
|
<li><code><b>off-till</b> hh:mm:ss</code> - turn the device off and power on again at defined time</li>
|
|
<li><code><b>on-till-overneight</b> hh:mm:ss</code> - power on the device until defined time on the next day</li>
|
|
<li><code><b>off-till-overneight</b> hh:mm:ss</code> - turn the device off at defined time on the next day</li>
|
|
</ul><br>
|
|
Example: <code>set BOSE_1234567890AB on-till 23:00:00</code> Switches device with the name BOSE_1234567890AB now on and at 23:00:00 off<br><br><br>
|
|
|
|
<ul><u>Multiroom commands:</u>
|
|
<li><code><b>createZone</b> deviceID[,deviceID]</code> - create multiroom zone and adds device(s) to the multiroom zone </li>
|
|
<li><code><b>addToZone</b> deviceID</code> - add device to multiroom zone </li>
|
|
<li><code><b>removeFromZone</b> deviceID</code> - remove device from multiroom zone</li>
|
|
<li><code><b>playEverywhere</b></code> - play sound of a device on all others devices</li>
|
|
<li><code><b>stopPlayEverywhere</b></code> - stop playing sound on all devices</li>
|
|
</ul><br>
|
|
Example1: <code>set BOSE_1234567890AB playEverywhere</code> Starts playing the sound of the device BOSE_1234567890AB on allother devices<br><br>
|
|
Example2: <code>set BOSE_1234567890AB createZone AB1234567890,12AB34567890</code> Defines BOSE_1234567890AB as multiroom master
|
|
and adds BOSE_AB1234567890 and BOSE_12AB34567890 to the multiroom zone <br><br>
|
|
|
|
<b>Note:</b> A "double-tap" (<1s) on a preset button (device or remote control) will toggle playEverywhere/stopPlayEverywhere<br><br><br>
|
|
|
|
<ul><u>Show clock command (only for ST20/30):</u>
|
|
<li><code><b>clock</b> enable/disable</code> - show or hide clock </li>
|
|
</ul><br>
|
|
Example: <code>set BOSE_1234567890AB clock enable</code> Show time in the ST20/30 display.<br><br><br>
|
|
|
|
<ul><u>TextToSpeach commands (needs Google Translate):</u>
|
|
<li><code><b>speak</b> "message" [0...100] [+x|-x] [en|de|xx]</code> - Text to speak, optional with volume adjustment and language to use.</li>
|
|
<li><code><b>speakOff</b> "message" [0...100] [+x|-x] [en|de|xx]</code> - Text to speak, optional with volume adjustment and language to use. Device is switched off after speak.</li>
|
|
</ul><br>
|
|
Example: <code>set BOSE_1234567890AB speakOff "Music is going to switch off now. Good night." 30 en</code> Speaks message at volume 30 and then switches off device.<br><br><br>
|
|
|
|
<ul><u>DNLA Server command:</u>
|
|
<li><code><b>autoAddDLNAServers</b> 0|1</code> - 1=automatically add all DLNA servers to BOSE library. This command is only for "main" BOSEST, not for devices/speakers!</li>
|
|
</ul><br>
|
|
</ul>
|
|
|
|
<a name="BOSESTget" id="BOSESTget"></a>
|
|
<b>Get</b>
|
|
<ul>
|
|
<code>n/a</code>
|
|
</ul><br>
|
|
|
|
<a name="BOSESTattr" id="BOSESTattr"></a>
|
|
<b>Attributes</b>
|
|
<ul>
|
|
<ul>
|
|
<li><code><b>staticIPs</b> IP-Address [,IP-Address]</code> - 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, ...) <br>Example: <code>attr bosesystem staticIPs 192.168.1.52,192.168.1.53</code></li>
|
|
<li><code><b>speakChannel</b> channel(s)</code> - speaks channel/present name bevor starting a playback, useful for SoundTouch without display (comma separated or range: e.g. <code>2,3,5,6 </code> or <code>1-6</code> ). TTS must be installed.</li>
|
|
<li><code><b>auto-zone</b> on|off</code> - automatic start multiroom zone play, if speakers are playing the same, according to "contentItemLocation"; (default: off)</li>
|
|
<li><code><b>ttsDirectory</b> "directory"</code> - set DLNA TTS directory. FHEM user needs permissions to write to that directory. </li>
|
|
<li><code><b>ttsLanguage </b> en|de|xx</code> - set default TTS language (default: en)</li>
|
|
<li><code><b>ttsSpeakOnError</b> 0|1</code> - 0=disable to speak "not available" text</li>
|
|
<li><code><b>ttsVolume</b> [0...100] [+x|-x]</code> - set the TTS volume level in percentage or change volume by ±x from current level</li>
|
|
<li><code><b>Channel_07</b> to <b>Channel_20</b> name|location|source|[sourceAccount]</code> - define present 07 to 20 <br>When you play something, you can find ContentItemLocationName, ContentItemLocation, etc. in the readings. These data can be used here to define the present. </li>
|
|
</ul>
|
|
</ul><br>
|
|
</ul>
|
|
=end html
|
|
|
|
=begin html_DE
|
|
|
|
<a name="BOSEST"></a>
|
|
<h3>BOSEST</h3>
|
|
|
|
<ul>BOSEST ist ein Modul um ein BOSE SoundTouch System zu steuern, (ein oder mehrere SoundTouch 10, 20 30, 300 oder Portable Geräte)<br><br>
|
|
|
|
<b>Hinweis:</b> Für das BOEST Modul sind folgende Bibliotheken erforderlich:
|
|
<ul>
|
|
<li>libwww-perl</li> <li>libmojolicious-perl</li> <li>libxml-simple-perl</li> <li>libnet-bonjour-perl</li> <li>libev-perl</li><li>liburi-escape-xs-perl</li><li>sox</li><li>libsox-fmt-mp3</li><br>
|
|
Rufe <b>sudo apt-get install libwww-perl libmojolicious-perl libxml-simple-perl libnet-bonjour-perl libev-perl liburi-escape-xs-perl</b> auf, um diese Bibliotheken zu installieren.<br>
|
|
Hinweis:libmojolicious-perl muss mindestens die Version 5.54 haben.<br>
|
|
Rufe <b>sudo apt-get install cpanminus</b> and <b>sudo cpanm Mojolicious</b> auf, um auf die aktuelle Version zu updaten.<br><br>
|
|
Die Konfiguration von TTS (TextToSpeech) ist in diesem FHEM Forum Beitrag beschrieben: <a target= "_blank" href="https://forum.fhem.de/index.php/topic,46838.0.html">Link</a><br>
|
|
Fragen und Feedback bitte über das FHEM Forum: <a target= "_blank" href="https://forum.fhem.de/index.php/topic,46838.msg533050.html#new">Link</a><br>
|
|
</ul><br>
|
|
|
|
<a name="BOSESTdefine" id="BOSESTdefine"></a>
|
|
<b>Define</b>
|
|
<ul>
|
|
<code>define <name> BOSEST</code><br><br>
|
|
Beispiel:
|
|
<ul>
|
|
<code>define bosesystem BOSEST</code><br>
|
|
Definiert das BOSE SoundTouch System. Alle Lautsprecher erscheinen innerhalb von ca. 60 s unter "Unsorted" in FHEM.<br/>
|
|
</ul>
|
|
</ul>
|
|
|
|
<br>
|
|
|
|
<a name="BOSESTset" id="BOSESTset"></a>
|
|
<b>Set</b>
|
|
<ul><code>set <name> <command> [<parameter>]</code><br><br>
|
|
Die folgenden SET-Kommandos gelten für Lautsprecher (Ausnahme: <b>autoAddDLNAServers</b> ist nur für das Hauptmodul BOSEST) :<br><br>
|
|
<ul><u>Allgemein</u>
|
|
<li><code><b>on</b></code> - Lautsprecher einschalten</li>
|
|
<li><code><b>off</b></code> - Lautsprecher ausschalten</li>
|
|
<li><code><b>power</b></code> - Wechselt zw. on und off</li>
|
|
<li><code><b>volume</b> [0...100] [+x|-x]</code> - Lautstärke setzen (direkt oder als ±x Differenz zur aktuellen Lautstärke) </li>
|
|
<li><code><b>channel</b> 0...20</code> - Present auswählen</li>
|
|
<li><code><b>saveChannel</b> 07...20</code> - Aktuelle Wiedergabe als Present 07 bis 20 speichern</li>
|
|
<li><code><b>play</b></code> - Startet die Wiedergabe </li>
|
|
<li><code><b>pause</b></code> - Pausiert die Wiedergabe</li>
|
|
<li><code><b>playpause</b></code> - Wechselt zw. play und pause</li>
|
|
<li><code><b>stop</b></code> - Stoppt die Wiedergabe</li><li><code><b>nextTrack</b></code> - Nächsten Titel spielen</li>
|
|
<li><code><b>prevTrack</b></code> - Vorherigen Titel spielen</li>
|
|
<li><code><b>playTrack </b> name|location|source[|sourceAccount]</code> - Sucht per DNLA nach dem Titel und spielt ihn ab <br>
|
|
<font size=-1>Weitere Informationen:
|
|
<ol>
|
|
<li>"Es ist immer nur eine Suche und die ist nach trackTitle, trackAlbum, trackArtist möglich, so wie in der SoundTouch App" (<a target= "_blank" href="https://forum.fhem.de/index.php?topic=46838.msg594029#msg594029">FHEM Forum Beitrag</a>) </li>
|
|
<li>"Einfach in der SoundTouch APP auf die Suche klicken und schauen, ob Playlists gefunden und abgespielt werden können." (<a target= "_blank" href="https://forum.fhem.de/index.php/topic,46838.msg594693.html#msg594693">FHEM Forum Beitrag</a>) </li>
|
|
</ol>
|
|
</font></li>
|
|
<li><code><b>mute</b> on|off|toggle</code> - Stummschaltung</li>
|
|
<li><code><b>shuffle</b> on|off</code> - Zufallswiedergabe</li>
|
|
<li><code><b>repeat</b> all|one|off</code> - Wiederholung</li>
|
|
<li><code><b>bass</b> 0...10</code> - Basseinstellung</li>
|
|
<li><code><b>recent</b> 0...15</code> - Anzahl der Namen, die in der recent list in readings aufgeführt werden</li>
|
|
<li><code><b>source</b> bluetooth,bt-discover,aux mode, airplay</code> - lokale Quelle auswählen</li><br>
|
|
<li><code><b>addDLNAServer</b> Name1 [Name2] [Namex]</code> - DLNA server Name1 (und Name2 bis Namex) zur BOSE Bibliothek hinzufügen</li>
|
|
<li><code><b>removeDLNAServer</b> Name1 [Name2] [Namex]</code> - DLNA server Name1 (und Name2 bis Namex) aus der BOSE Bibliothek entfernen</li>
|
|
</ul><br>
|
|
Beispiel: <code>set BOSE_1234567890AB volume 25</code> Setzt die Lautstärke des Lautsprechers BOSE_1234567890AB auf 25.<br><br><br>
|
|
|
|
<ul><u>Zeiten:</u>
|
|
<li><code><b>on-for-timer</b> 1...x</code> - Schaltet den Lautsprecher für x Sekunden ein</li>
|
|
<li><code><b>off-for-timer</b> 1...x</code> - Schaltet den Lautsprecher für x Sekunden aus</li>
|
|
<li><code><b>on-till</b> hh:mm:ss</code> - Schaltet den Lautsprecher bis zur angegebenen Zeit ein</li>
|
|
<li><code><b>off-till</b> hh:mm:ss</code> - Schaltet den Lautsprecher bis und zur angegebenen Zeit aus</li>
|
|
<li><code><b>on-till-overneight</b> hh:mm:ss</code> - Schaltet den Lautsprecher bis zur angegebenen Zeit am nächsten Tag ein</li>
|
|
<li><code><b>off-till-overneight</b> hh:mm:ss</code> - Schaltet den Lautsprecher bis zur angegebenen Zeit am nächsten Tag aus</li>
|
|
</ul><br>
|
|
Beispiel: <code>set BOSE_1234567890AB on-till 23:00:00</code> Schaltet den Lautsprecher BOSE_1234567890AB ein und um 23:00:00 Uhr aus.<br><br><br>
|
|
|
|
<ul><u>Multiroom:</u>
|
|
<li><code><b>createZone</b> deviceID[,deviceID]</code> - Legt eine Wiedergabe-Zone an und fügt einen oder mehrere Lautsprecher der Wiedergabezone hinzu </li>
|
|
<li><code><b>addToZone</b> deviceID</code> - Fügt einen Lautsprecher einer bestehenden Wiedergabe-Zone zu </li>
|
|
<li><code><b>removeFromZone</b> deviceID</code> - Entfernt einen Lautsprecher aus einer Wiedergabe-Zone</li>
|
|
<li><code><b>playEverywhere</b></code> - Startet "Überall wiedergeben"</li>
|
|
<li><code><b>stopPlayEverywhere</b></code> - Beendet "Überall wiedergeben"</li>
|
|
</ul><br>
|
|
Beispiel 1: <code>set BOSE_1234567890AB playEverywhere</code> Startet die Überall-Wiedergabe (Mit dem Lautsprecher BOSE_1234567890AB als Master-Lautsprecher). <br><br>
|
|
Beispiel 2: <code>set BOSE_1234567890AB createZone AB1234567890,12AB34567890</code> Definiert BOSE_1234567890AB als Master-Lautsprecher und fügt BOSE_AB1234567890 und BOSE_12AB34567890 der Wiedergabe-Zone hinzu<br><br>
|
|
<b>Hinweis:</b> Drücken Sie 2x ein Present (innerhalb einer Sekunde) am Lautsprecher oder auf der Fernbedienung, wird "Überall Wiedergeben" ein oder ausgeschaltet.<br><br><br>
|
|
|
|
<ul><u>Uhr Anzeige (nur für ST20/30):</u>
|
|
<li><code><b>clock</b> enable/disable</code> - Schaltet die Uhrenanzeige im Standby um</li>
|
|
</ul><br>
|
|
Beispiel: <code>set BOSE_1234567890AB clock enable</code> Zeigt die Uhr im Standby auf dem Display des ST20/30 an.<br><br><br>
|
|
|
|
<ul><u>TextToSpeach (benötigt Google Translate):</u>
|
|
<li><code><b>speak</b> "message" [0...100] [+x|-x] [en|de|xx]</code> - 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.</li>
|
|
<li><code><b>speakOff</b> "message" [0...100] [+x|-x] [en|de|xx]</code> - 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.</li>
|
|
</ul><br>
|
|
Beispiel: <code>set BOSE_1234567890AB speakOff "Ab isn Bett." 30 en</code> Spricht die Meldung mit Lautstärke 30 und schaltet den Lautsprecher dann aus.<br><br><br>
|
|
|
|
<ul><u>DNLA Server:</u>
|
|
<li><code><b>autoAddDLNAServers</b> 0|1</code> - 1=automatisch alle DLNA servers zur BOSE Bibliothek hinzufügen. Dieser Parameter ist nur für das Hauptmodul BOSEST, nicht für die Lautsprecher!</li></ul><br>
|
|
</ul>
|
|
|
|
<a name="BOSESTget" id="BOSESTget"></a>
|
|
<b>Get</b>
|
|
<ul>
|
|
<code>n/a</code>
|
|
</ul><br>
|
|
|
|
<a name="BOSESTattr" id="BOSESTattr"></a>
|
|
<b>Attribute</b>
|
|
<ul>
|
|
<ul>
|
|
<li><code><b>staticIPs</b> IP-Address [,IP-Address]</code> - 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, ...) <br>Beispiel: <code>attr bosesystem staticIPs 192.168.1.52,192.168.1.53</code></li>
|
|
<li><code><b>speakChannel</b> channel(s) </code> - Ansage des aktuellen Present vor der Wiedergabe, sinnvoll für SoundTouch Lautsprecher ohne Display (Angabe komma-separiert oder als Bereich: z.B. <code>2,3,5,6 </code> oder <code>1-6</code> ). TTS muss eingerichtet sein.</li>
|
|
<li><code><b>auto-zone</b> on|off</code> - "Überall Wiedergabe" automatisch starten, wenn Lautsprecher das gleiche wiedergeben ("contentItemLocation" ist identisch); (Standardwert: off)</li>
|
|
<li><code><b>ttsDirectory</b> "directory"</code> - Angabe des DLNA TTS Verzeichnisses. Der FHEM user muss Schreibrechte in diesem Verzeichnis haben.</li>
|
|
<li><code><b>ttsLanguage </b> en|de|xx</code> - Standardsprache für TTS setzen (default: en)</li>
|
|
<li><code><b>ttsSpeakOnError</b> 0|1</code> - 0= Ansage "not available" unterdrücken</li>
|
|
<li><code><b>ttsVolume</b> [0...100] [+x|-x]</code> - Lautstärke setzen (direkt oder als ±x Differnez zur aktuellen Lautstärke)</li>
|
|
<li><code><b>Channel_07</b> to <b>Channel_20</b> name|location|source|[sourceAccount]</code> - Festlegen der Present 07 bis 20 <br>Bei Wiedergabe kann in den readings "ContentItemLocationName, ContentItemLocation, etc. " ausgelesen werden. Dies Daten können dann verwendet werden, um die Presents zu belegen.</li>
|
|
</ul>
|
|
</ul><br>
|
|
</ul>
|
|
=end html_DE
|
|
=cut
|
|
|