From 9cd1da33f3e69e39a88cbc802d78d5b198bef852 Mon Sep 17 00:00:00 2001 From: dominik Date: Sun, 29 Jan 2017 17:45:23 +0000 Subject: [PATCH] 98_BOSEST: new TTS features and bugfixes 10_EQ3BT: use all BT interfaces git-svn-id: https://svn.fhem.de/fhem/trunk@13274 2b470e98-0d58-463d-a4d8-8e2adae1ed80 --- fhem/CHANGED | 8 ++ fhem/FHEM/10_EQ3BT.pm | 183 +++++++++++++++++------ fhem/FHEM/98_BOSEST.pm | 320 +++++++++++++++++++++++++++++++---------- 3 files changed, 394 insertions(+), 117 deletions(-) diff --git a/fhem/CHANGED b/fhem/CHANGED index d8b1ff928..c1a49515c 100644 --- a/fhem/CHANGED +++ b/fhem/CHANGED @@ -1,5 +1,13 @@ # Add changes at the top of the list. Keep it in ASCII, and 80-char wide. # Do not insert empty lines here, update check depends on it. + - feature: 98_BOSEST: NEW REQUIREMENT sox, libsox-fmt-mp3 for TTS + - feature: 98_BOSEST: support more than 100 chars for TTS + - bugfix: 98_BOSEST: several TTS and Spotify bugfixes + - feature: 98_BOSEST: support playPause toggle + - feature: 10_EQ3BT: use all available BT interfaces + - feature: 10_EQ3BT: new reading lastChangeBy FHEM/Thermostat + - feature: 10_EQ3BT: support $readingFnAttribute + - bugfix: 10_EQ3BT: do not run parallel gatttool commands for same dev - feature: FB_CALLMONITOR: new set command "reopen" - feature: 66_ECMD: new attribute autoReopen - update: 74_AMAD: Version 2.6.8 new feature sendSMS diff --git a/fhem/FHEM/10_EQ3BT.pm b/fhem/FHEM/10_EQ3BT.pm index 41da532cf..8e00c839a 100644 --- a/fhem/FHEM/10_EQ3BT.pm +++ b/fhem/FHEM/10_EQ3BT.pm @@ -1,15 +1,30 @@ ############################################################# # -# EQ3BT.pm (c) by Dominik Karall, 2016 +# EQ3BT.pm (c) by Dominik Karall, 2016-2017 # dominik karall at gmail dot com # $Id$ # # FHEM module to communicate with EQ-3 Bluetooth thermostats # -# Version: 1.1.3 +# Version: 2.0.0 # ############################################################# # +# v2.0.0 - 20170129 +# - FEATURE: use all available bluetooth interfaces to communicate +# with the bluetooth thermostat +# - FEATURE: new reading bluetoothDevice (shows used hci device) +# - CHANGE: change maximum retries to 20 +# - FEATURE: new set function resetErrorCounters +# - FEATURE: new set function resetConsumption (not today/yesterday) +# - FEATURE: new reading lastChangeBy FHEM or thermostat +# indicates who was responsible for the last change +# - FEATURE: support $readingFnAttributes +# - FEATURE: add VERSION internal and log output +# - CHANGE: updateStatus is now 3min intervall starting from +# last working updateStatus +# - BUGFIX: do not run parallel gatttool commands for the same device +# # v1.1.3 - 20161211 # - BUGFIX: better error handling if no notification was received # - BUGFIX: update system information fixed @@ -102,6 +117,7 @@ # set tempconf 17 comfort*2 eco*2 # # TODOs +# - create virtual device (wohnzimmer) # - read/set eco/comfort temperature # - read/set tempOffset # - read/set windowOpen time settings @@ -126,6 +142,7 @@ sub EQ3BT_Initialize($) { $hash->{GetFn} = 'EQ3BT_Get'; $hash->{SetFn} = 'EQ3BT_Set'; $hash->{AttrFn} = 'EQ3BT_Attribute'; + $hash->{AttrList} = $readingFnAttributes; return undef; } @@ -138,6 +155,8 @@ sub EQ3BT_Define($$) { my $mac; $hash->{STATE} = "initialized"; + $hash->{VERSION} = "2.0.0"; + Log3 $hash, 3, "EQ3BT: EQ-3 Bluetooth Thermostat ".$hash->{VERSION}; if (int(@a) > 3) { return 'EQ3BT: Wrong syntax, must be define EQ3BT '; @@ -146,15 +165,32 @@ sub EQ3BT_Define($$) { $hash->{MAC} = $a[2]; } + EQ3BT_updateHciDevicelist($hash); + BlockingCall("EQ3BT_pairDevice", $name."|".$hash->{MAC}); RemoveInternalTimer($hash); - InternalTimer(gettimeofday()+60, "EQ3BT_updateStatusWithTimer", $hash, 0); - InternalTimer(gettimeofday()+20, "EQ3BT_updateSystemInformationWithTimer", $hash, 0); + InternalTimer(gettimeofday()+60, "EQ3BT_updateStatus", $hash, 0); + InternalTimer(gettimeofday()+20, "EQ3BT_updateSystemInformation", $hash, 0); return undef; } +sub EQ3BT_updateHciDevicelist { + my ($hash) = @_; + #check for hciX devices + $hash->{helper}{hcidevices} = (); + my @btDevices = split("\n", qx(hcitool dev)); + foreach my $btDevLine (@btDevices) { + if($btDevLine =~ /hci(.)/) { + push(@{$hash->{helper}{hcidevices}}, $1); + } + } + $hash->{helper}{currenthcidevice} = 0; + readingsSingleUpdate($hash, "bluetoothDevice", "hci".$hash->{helper}{hcidevices}[$hash->{helper}{currenthcidevice}], 1); + return undef; +} + sub EQ3BT_pairDevice { my ($string) = @_; my ($name, $mac) = split("\\|", $string); @@ -184,9 +220,9 @@ sub EQ3BT_Set($@) { # my ($hash, $name, @params) = @_; my $workType = shift(@params); - my $list = "desiredTemperature:slider,4.5,0.5,29.5,1 updateStatus:noArg boost:on,off mode:manual,automatic eco:noArg comfort:noArg"; - #my $list = "desiredTemperature:slider,5,0.5,30,1 boost daymode nightmode childlock holidaymode datetime window program"; - + my $list = "desiredTemperature:slider,4.5,0.5,29.5,1 updateStatus:noArg boost:on,off mode:manual,automatic eco:noArg comfort:noArg ". + "resetErrorCounters:noArg resetConsumption:noArg"; + # check parameters for set function if($workType eq "?") { return SetExtensions($hash, $list, $name, $workType, @params); @@ -209,6 +245,10 @@ sub EQ3BT_Set($@) { EQ3BT_setEco($hash); } elsif($workType eq "comfort") { EQ3BT_setComfort($hash); + } elsif($workType eq "resetErrorCounters") { + EQ3BT_setResetErrorCounters($hash); + } elsif($workType eq "resetConsumption") { + EQ3BT_setResetConsumption($hash); } elsif($workType eq "childlock") { return "EQ3BT: childlock requires on/off as additional parameter" if(int(@params) < 1); EQ3BT_setChildlock($hash, $params[0]); @@ -230,6 +270,26 @@ sub EQ3BT_Set($@) { return undef; } +### resetErrorCounters ### +sub EQ3BT_setResetErrorCounters { + my ($hash) = @_; + + foreach my $reading (keys %{ $hash->{READINGS} }) { + if($reading =~ /errorCount-.*/) { + readingsSingleUpdate($hash, $reading, 0, 1); + } + } + + return undef; +} + +### resetConsumption ### +sub EQ3BT_setResetConsumption { + my ($hash) = @_; + readingsSingleUpdate($hash, "consumption", 0, 1); + return undef; +} + ### updateSystemInformation ### sub EQ3BT_updateSystemInformation { my ($hash) = @_; @@ -237,16 +297,9 @@ sub EQ3BT_updateSystemInformation { $hash->{helper}{RUNNING_PID} = BlockingCall("EQ3BT_execGatttool", $name."|".$hash->{MAC}."|updateSystemInformation|0x0411|00|listen", "EQ3BT_processGatttoolResult", 300, "EQ3BT_killGatttool", $hash); } -sub EQ3BT_updateSystemInformationWithTimer { - my ($hash) = @_; - EQ3BT_updateSystemInformation($hash); - InternalTimer(gettimeofday()+7200+int(rand(180)), "EQ3BT_updateSystemInformation", $hash, 0); - return undef; -} - sub EQ3BT_updateSystemInformationSuccessful { my ($hash, $handle, $value) = @_; - + InternalTimer(gettimeofday()+7200+int(rand(180)), "EQ3BT_updateSystemInformation", $hash, 0); return undef; } @@ -256,15 +309,13 @@ sub EQ3BT_updateSystemInformationRetry { return undef; } -### updateStatus ### -sub EQ3BT_updateStatusWithTimer { +sub EQ3BT_updateSystemInformationFailed { my ($hash) = @_; - - EQ3BT_updateStatus($hash); - - InternalTimer(gettimeofday()+160+int(rand(20)), "EQ3BT_updateStatusWithTimer", $hash, 0); + InternalTimer(gettimeofday()+7000+int(rand(180)), "EQ3BT_updateSystemInformation", $hash, 0); + return undef; } +### updateStatus ### sub EQ3BT_updateStatus { my ($hash) = @_; my $name = $hash->{NAME}; @@ -273,7 +324,7 @@ sub EQ3BT_updateStatus { sub EQ3BT_updateStatusSuccessful { my ($hash, $handle, $value) = @_; - + InternalTimer(gettimeofday()+140+int(rand(60)), "EQ3BT_updateStatus", $hash, 0); return undef; } @@ -283,6 +334,12 @@ sub EQ3BT_updateStatusRetry { return undef; } +sub EQ3BT_updateStatusFailed { + my ($hash, $handle, $value) = @_; + InternalTimer(gettimeofday()+170+int(rand(60)), "EQ3BT_updateStatus", $hash, 0); + return undef; +} + ### setDesiredTemperature ### sub EQ3BT_setDesiredTemperature($$) { my ($hash, $desiredTemp) = @_; @@ -297,7 +354,7 @@ sub EQ3BT_setDesiredTemperature($$) { sub EQ3BT_setDesiredTemperatureSuccessful { my ($hash, $handle, $tempVal) = @_; my $temp = (hex($tempVal) - 0x4100) / 2; - readingsSingleUpdate($hash, "desiredTemperature", $temp, 1); + readingsSingleUpdate($hash, "desiredTemperature", sprintf("%.1f", $temp), 1); return undef; } @@ -407,6 +464,7 @@ sub EQ3BT_execGatttool($) { my ($string) = @_; my ($name, $mac, $workType, $handle, $value, $listen) = split("\\|", $string); my $wait = 1; + my $hash = $main::defs{$name}; my $gatttool = qx(which gatttool); chomp $gatttool; @@ -415,25 +473,26 @@ sub EQ3BT_execGatttool($) { my $gtResult; while($wait) { - my $grepGatttool = qx(ps ax| grep \'hcitool\' | grep -v grep); + my $grepGatttool = qx(ps ax| grep -E \'gatttool -b $mac\' | grep -v grep); if(not $grepGatttool =~ /^\s*$/) { #another gattool is running - Log3 $name, 5, "EQ3BT ($name): another hcitool process is running. waiting..."; + Log3 $name, 5, "EQ3BT ($name): another gatttool process is running. waiting..."; sleep(1); } else { $wait = 0; } } - + if($value eq "03") { my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(time); my $currentDate = sprintf("%02X%02X%02X%02X%02X", $year+1900-2000, $mon+1, $mday, $hour, $min); $value .= $currentDate; } - - my $cmd = "gatttool -b $mac --char-write-req --handle=$handle --value=$value"; + + my $hciDevice = "hci".$hash->{helper}{hcidevices}[$hash->{helper}{currenthcidevice}]; + my $cmd = "gatttool -b $mac -i $hciDevice --char-write-req --handle=$handle --value=$value"; if(defined($listen) && $listen eq "listen") { - $cmd = "timeout 5 ".$cmd." --listen"; + $cmd = "timeout 15 ".$cmd." --listen"; } #redirect stderr to stdout @@ -478,6 +537,8 @@ sub EQ3BT_processGatttoolResult($) { my $value = $a[5]; my $notification = $a[6]; + delete($hash->{helper}{RUNNING_PID}); + Log3 $hash, 5, "EQ3BT ($name): gatttool return string: $string"; $hash->{helper}{"handle$workType"} = $handle; @@ -489,11 +550,15 @@ sub EQ3BT_processGatttoolResult($) { if(defined($notification)) { EQ3BT_processNotification($hash, $notification); } + if($workType =~ /set.*/) { + readingsSingleUpdate($hash, "lastChangeBy", "FHEM", 1); + } #call WorkTypeSuccessful function my $call = "EQ3BT_".$workType."Successful"; - #FIXME otherwise temperature is not set after successfull write no strict "refs"; - &{$call}($hash, $handle, $value); + eval { + &{$call}($hash, $handle, $value); + }; use strict "refs"; RemoveInternalTimer($hash, "EQ3BT_".$workType."Retry"); $hash->{helper}{"retryCounter$workType"} = 0; @@ -501,13 +566,38 @@ sub EQ3BT_processGatttoolResult($) { $hash->{helper}{"retryCounter$workType"} = 0 if(!defined($hash->{helper}{"retryCounter$workType"})); $hash->{helper}{"retryCounter$workType"}++; Log3 $hash, 4, "EQ3BT ($name): $workType failed ($handle, $value, $notification)"; - if ($hash->{helper}{"retryCounter$workType"} > 30) { + if ($hash->{helper}{"retryCounter$workType"} > 20) { my $errorCount = ReadingsVal($hash->{NAME}, "errorCount-$workType", 0); readingsSingleUpdate($hash, "errorCount-$workType", $errorCount+1, 1); - Log3 $hash, 3, "EQ3BT ($name): $workType, $handle, $value failed 30 times."; + Log3 $hash, 3, "EQ3BT ($name): $workType, $handle, $value failed 20 times."; $hash->{helper}{"retryCounter$workType"} = 0; + $hash->{helper}{"retryCounterHci".$hash->{helper}{currenthcidevice}} = 0; + #call WorkTypeFailed function + my $call = "EQ3BT_".$workType."Failed"; + no strict "refs"; + eval { + &{$call}($hash, $handle, $value); + }; + use strict "refs"; + + #update hci devicelist + EQ3BT_updateHciDevicelist($hash); } else { - InternalTimer(gettimeofday()+5, "EQ3BT_".$workType."Retry", $hash, 0); + $hash->{helper}{"retryCounterHci".$hash->{helper}{currenthcidevice}} = 0 if(!defined($hash->{helper}{"retryCounterHci".$hash->{helper}{currenthcidevice}})); + $hash->{helper}{"retryCounterHci".$hash->{helper}{currenthcidevice}}++; + if ($hash->{helper}{"retryCounterHci".$hash->{helper}{currenthcidevice}} > 7) { + #reset error counter + $hash->{helper}{"retryCounterHci".$hash->{helper}{currenthcidevice}} = 0; + #use next hci device next time + $hash->{helper}{currenthcidevice} += 1; + my $maxHciDevices = @{ $hash->{helper}{hcidevices} } - 1; + if($hash->{helper}{currenthcidevice} > $maxHciDevices) { + $hash->{helper}{currenthcidevice} = 0; + } + #update reading + readingsSingleUpdate($hash, "bluetoothDevice", "hci".$hash->{helper}{hcidevices}[$hash->{helper}{currenthcidevice}], 1); + } + InternalTimer(gettimeofday()+3+int(rand(5)), "EQ3BT_".$workType."Retry", $hash, 0); } } @@ -558,31 +648,44 @@ sub EQ3BT_processNotification { my $consumptionTodaySecSinceLastChange = ReadingsAge($hash->{NAME}, "consumptionToday", 0); my $oldVal = ReadingsVal($hash->{NAME}, "valvePosition", 0); my $consumptionDiff = 0; - if($timeSinceLastChange < 300) { + if($timeSinceLastChange < 600) { $consumptionDiff += ($oldVal + $pct) / 2 * $timeSinceLastChange / 3600; } - + EQ3BT_readingsSingleUpdateIfChanged($hash, "consumption", sprintf("%.3f", $consumption+$consumptionDiff)); + my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(time); if($consumptionTodaySecSinceLastChange > ($hour*3600+$min*60+$sec)) { readingsSingleUpdate($hash, "consumptionYesterday", $consumptionToday + $consumptionDiff/2, 1); readingsSingleUpdate($hash, "consumptionToday", 0 + $consumptionDiff/2, 1); } else { - readingsSingleUpdate($hash, "consumptionToday", sprintf("%.3f", $consumptionToday+$consumptionDiff), 1); + EQ3BT_readingsSingleUpdateIfChanged($hash, "consumptionToday", sprintf("%.3f", $consumptionToday+$consumptionDiff)); } + readingsSingleUpdate($hash, "valvePosition", $pct, 1); + #changes below this line will set lastchangeby readingsSingleUpdate($hash, "windowOpen", $wndOpen, 1); readingsSingleUpdate($hash, "ecoMode", $eco, 1); readingsSingleUpdate($hash, "battery", $batteryStr, 1); readingsSingleUpdate($hash, "boost", $isBoost, 1); - readingsSingleUpdate($hash, "consumption", sprintf("%.3f", $consumption+$consumptionDiff), 1); readingsSingleUpdate($hash, "mode", $modeStr, 1); - readingsSingleUpdate($hash, "valvePosition", $pct, 1); - readingsSingleUpdate($hash, "desiredTemperature", $temp, 1); + readingsSingleUpdate($hash, "desiredTemperature", sprintf("%.1f", $temp), 1); } return undef; } +sub EQ3BT_readingsSingleUpdateIfChanged { + my ($hash, $reading, $value, $setLastChange) = @_; + my $curVal = ReadingsVal($hash->{NAME}, $reading, ""); + + if($curVal ne $value) { + readingsSingleUpdate($hash, $reading, $value, 1); + if(defined($setLastChange)) { + readingsSingleUpdate($hash, "lastChangeBy", "Thermostat", 1); + } + } +} + sub EQ3BT_killGatttool($) { } diff --git a/fhem/FHEM/98_BOSEST.pm b/fhem/FHEM/98_BOSEST.pm index c9667a6c0..2548991dd 100755 --- a/fhem/FHEM/98_BOSEST.pm +++ b/fhem/FHEM/98_BOSEST.pm @@ -1,16 +1,36 @@ ############################################################# # -# BOSEST.pm (c) by Dominik Karall, 2016 +# 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.0.1 +# Version: 2.1.0 # ############################################################# # +# 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) @@ -201,12 +221,7 @@ # - change preset via /key # # TODO -# - redesign multiroom functionality (virtual devices: represent the readings of master device -# and send the commands only to the master device (except volume?) -# automatically create group before playing -# - support multiroom volume (check with SoundTouch app to see commands) -# - use websocket frame ping (WS_PING) instead of websocket XML ping -# - TTS code cleanup (group functions logically) +# - 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 # @@ -238,6 +253,8 @@ 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) = @_; @@ -247,6 +264,7 @@ sub BOSEST_Initialize($) { $hash->{GetFn} = 'BOSEST_Get'; $hash->{SetFn} = 'BOSEST_Set'; $hash->{AttrFn} = 'BOSEST_Attribute'; + $hash->{AttrList} = $readingFnAttributes; return undef; } @@ -281,6 +299,7 @@ sub BOSEST_Define($$) { #init statecheck $hash->{helper}{stateCheck}{enabled} = 0; + $hash->{helper}{stateCheck}{actionActive} = 0; #init switchSource $hash->{helper}{switchSource} = ""; @@ -288,11 +307,14 @@ sub BOSEST_Define($$) { #init speak channel functionality $hash->{helper}{lastSpokenChannel} = ""; - foreach my $attrname (qw(channel_07 channel_08 channel_09 channel_10 channel_11 - channel_12 channel_13 channel_14 channel_15 channel_16 - channel_17 channel_18 channel_19 channel_20 ignoreDeviceIDs - ttsDirectory ttsLanguage ttsSpeakOnError ttsDLNAServer ttsVolume - speakChannel autoZone)) { + 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); } @@ -309,7 +331,7 @@ sub BOSEST_Define($$) { $hash->{helper}{supportedBassCmds} = ""; if (int(@a) < 3) { - Log3 $hash, 3, "BOSEST: BOSE SoundTouch v2.0.1"; + Log3 $hash, 3, "BOSEST: BOSE SoundTouch v2.1.0"; #start discovery process 30s delayed InternalTimer(gettimeofday()+30, "BOSEST_startDiscoveryProcess", $hash, 0); @@ -331,8 +353,6 @@ sub BOSEST_Attribute($$$$) { return "BOSEST: wrong format" if(!defined($value[2])); #update reading for channel_X readingsSingleUpdate($main::defs{$devName}, $attrName, $value[0], 1); - } elsif($attrName eq "ttsDLNAServer") { - BOSEST_addDLNAServer($main::defs{$devName}, $attrValue); } } elsif($mode eq "del") { if(substr($attrName, 0, 8) eq "channel_") { @@ -356,18 +376,18 @@ sub BOSEST_Set($@) { } @params = @params2; - my $list = "on:noArg off:noArg power:noArg play: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"); + 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 @@ -441,6 +461,8 @@ sub BOSEST_Set($@) { 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") { @@ -459,7 +481,6 @@ sub BOSEST_Set($@) { } elsif($workType eq "speak" or $workType eq "speakOff") { return "BOSEST: speak requires quoted text as additional parameters" if(int(@params) < 1); return "BOSEST: speak requires quoted text" if(substr($blankParams, 0, 1) ne "\""); - return "BOSEST: speak maximum text length is 100 characters" if(length($params[0])>100); if(AttrVal($hash->{NAME}, "ttsDirectory", "") eq "") { return "BOSEST: Please set ttsDirectory attribute first. FHEM user needs permissions to write to that directory. @@ -586,17 +607,18 @@ sub BOSEST_removeDLNAServer($$) { sub BOSEST_saveChannel($$) { my ($hash, $channel) = @_; - if(ReadingsVal($hash->{NAME}, "state", "stopped") ne "playing") { - return "BOSEST: No playing channel. Start a channel and save afterwards."; + if(ReadingsVal($hash->{NAME}, "contentItemLocation", "") eq "") { + return "BOSEST: No active channel."; } - #itemname, location, source, sourceaccount + #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|$location|$source|$sourceAccount"); + fhem("attr $hash->{NAME} channel_$channel $itemName|$type|$location|$source|$sourceAccount"); return undef; } @@ -749,6 +771,7 @@ sub BOSEST_setRecent($$) { 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}); @@ -756,8 +779,10 @@ sub BOSEST_setRecent($$) { return undef; } -sub BOSEST_setContentItem($$$$$) { - my ($hash, $itemName, $location, $source, $sourceAccount) = @_; +sub BOSEST_setContentItem { + my ($hash, $itemName, $type, $location, $source, $sourceAccount) = @_; + + $type = "" if(!defined($type)); my $postXml = "". "". $itemName. @@ -877,10 +904,13 @@ sub BOSEST_setPreset($$) { 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])); - Log3 $hash, 5, "BOSEST: AttrVal: $channel[0], $channel[1], $channel[2], $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]); + BOSEST_setContentItem($hash, $channel[0], $channel[1], $channel[2], $channel[3], $channel[4]); } return undef; } @@ -891,6 +921,12 @@ sub BOSEST_play($) { return undef; } +sub BOSEST_playPause($) { + my ($hash) = @_; + BOSEST_sendKey($hash, "PLAY_PAUSE"); + return undef; +} + sub BOSEST_stop($) { my ($hash) = @_; BOSEST_sendKey($hash, "STOP"); @@ -968,6 +1004,13 @@ sub BOSEST_speak($$$$$) { $lang = AttrVal($hash->{NAME}, "ttsLanguage", "en") if($lang eq ""); $volume = AttrVal($hash->{NAME}, "ttsVolume", ReadingsVal($hash->{NAME}, "volume", 20)) if($volume eq ""); + 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); @@ -980,7 +1023,9 @@ sub BOSEST_saveCurrentState($) { $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", ""); @@ -995,13 +1040,20 @@ sub BOSEST_restoreSavedState($) { BOSEST_setBass($hash, $hash->{helper}{savedState}{bass}); #bose off when source was off - if($hash->{helper}{savedState}{source} eq "STANDBY") { + 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; @@ -1014,6 +1066,7 @@ sub BOSEST_restoreVolumeAndOff($) { 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}); @@ -1029,17 +1082,14 @@ sub BOSEST_downloadGoogleNotAvailable($) { my $md5 = md5_hex($lang.$text); my $filename = $ttsDir."/".$md5.".mp3"; - if (-f $filename) { - #file exists already - return undef; + if (! -f $filename) { + BOSEST_retrieveGooglTTSFile($hash, $filename, $md5, $text, $lang); } - BOSEST_downloadGoogleTTS($hash, $filename, $md5, $text, $lang); - return undef; } -sub BOSEST_downloadGoogleTTS($$$$$;$) { +sub BOSEST_retrieveGooglTTSFile($$$$$;$) { my ($hash, $filename, $md5, $text, $lang, $callback) = @_; my $uri_text = uri_escape($text); @@ -1058,22 +1108,67 @@ sub BOSEST_downloadGoogleTTS($$$$$;$) { 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_stop($hash); + BOSEST_pause($hash); BOSEST_setVolume($hash, $volume); } BOSEST_playTrack($hash, $trackname); $hash->{helper}{stateCheck}{enabled} = 1; - $hash->{helper}{stateCheck}{always} = 0; - #after play the speaker sets INVALID_SOURCE - $hash->{helper}{stateCheck}{actionSource} = "INVALID_SOURCE"; + #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; @@ -1110,8 +1205,71 @@ sub BOSEST_deleteOldTTSFiles { $err = setKeyValue("BOSEST_tts_files", join(",", @ttsFiles)); } -sub BOSEST_playGoogleTTS($$$$$$) { +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); @@ -1123,13 +1281,12 @@ sub BOSEST_playGoogleTTS($$$$$$) { my $now = time(); if($now-$timestamp < 2592000) { #file is not older than 30 days - #file exists, call play sub - BOSEST_playMessage($hash, $md5, $volume, $stopAfterSpeak); + $hash->{helper}{tts}{downloads}{$md5} = 1; return undef; } } - BOSEST_downloadGoogleTTS($hash, $filename, $md5, $text, $lang, sub { + BOSEST_retrieveGooglTTSFile($hash, $filename, $md5, $text, $lang, sub { my ($hash, $filename, $md5, $downloadOk) = @_; if($downloadOk) { @@ -1141,15 +1298,10 @@ sub BOSEST_playGoogleTTS($$$$$$) { } $err = setKeyValue("BOSEST_tts_files", $val.$md5); $err = setKeyValue($md5, gettimeofday()); - BOSEST_playMessage($hash, $md5, $volume, $stopAfterSpeak); + $hash->{helper}{tts}{downloads}{$md5} = 1; + #add silence and play message afterwards } else { - if(AttrVal($hash->{NAME}, "ttsSpeakOnError", "1") eq "1") { - $md5 = md5_hex($BOSEST_GOOGLE_NOT_AVAILABLE_LANG.$BOSEST_GOOGLE_NOT_AVAILABLE_TEXT); - BOSEST_playMessage($hash, $md5, $volume, $stopAfterSpeak); - } else { - Log3 $hash, 3, "BOSEST: Download Google Translate failed ($text)."; - return undef; - } + $hash->{helper}{tts}{downloads}{$md5} = 10; #download error } }); @@ -1185,23 +1337,35 @@ sub BOSEST_removeMusicServiceAccount($$$) { sub BOSEST_playTrack($$) { my ($hash, $trackName) = @_; - my $ttsDlnaServer = AttrVal($hash->{NAME}, "ttsDLNAServer", ""); + 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") { - #skip servers which don't equal to ttsDLNAServer attribute if set - if($ttsDlnaServer ne "") { - next if($ttsDlnaServer ne $source->{content}); - } - Log3 $hash, 4, "BOSEST: Search for $trackName on $source->{source}"; + 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); } } @@ -1213,7 +1377,7 @@ sub BOSEST_searchTrack($$$) { my $postXml = '1100'. + '">11'. $trackName. ''; @@ -1318,7 +1482,7 @@ sub BOSEST_updateAutoZone { sub BOSEST_checkDoubleTap($$) { my ($hash, $channel) = @_; - return undef if($channel eq ""); + 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(); @@ -1428,13 +1592,16 @@ sub BOSEST_processXml($$) { if($hash->{helper}{stateCheck}{enabled}) { #check if state is action state - if(ReadingsVal($hash->{NAME}, "source", "") eq $hash->{helper}{stateCheck}{actionSource}) { - #call function with $hash as argument - $hash->{helper}{stateCheck}{function}->($hash); - - #reset if always is not enabled - if(!$hash->{helper}{stateCheck}{always}) { - $hash->{helper}{stateCheck}{enabled} = 0; + 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; + } } } } @@ -2318,7 +2485,7 @@ sub BOSEST_readingsSingleUpdateIfChanged {
    BOSEST is used to control a BOSE SoundTouch system (one or more SoundTouch 10, 20 or 30 devices)

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

    • +
      • libwww-perl
      • libmojolicious-perl
      • libxml-simple-perl
      • libnet-bonjour-perl
      • libev-perl
      • liburi-escape-xs-perl
      • sox
      • libsox-fmt-mp3

      • Use sudo apt-get install libwww-perl libmojolicious-perl libxml-simple-perl libnet-bonjour-perl libev-perl to install this libraries.
        Please note: libmojolicious-perl must be >=5.54, but under wheezy is only 2.x avaible.
        Use sudo apt-get install cpanminus and sudo cpanm Mojolicious to update to the newest version
        @@ -2389,7 +2556,6 @@ sub BOSEST_readingsSingleUpdateIfChanged {
      • speak "message" [0...100] [+x|-x] [en|de|xx]   -   Text to speak, optional with volume adjustment and language to use. The message to speak may have up to 100 letters
      • speakOff "message" [0...100] [+x|-x] [en|de|xx]   -   Text to speak, optional with volume adjustment and language to use. The message to speak may have up to 100 letters. Device is switched off after speak
      • ttsVolume [0...100] [+x|-x]   -   set the TTS volume level in percentage or change volume by ±x from current level
      • -
      • ttsDLNAServer "DLNA Server"   -   set DLNA TTS server, only needed if the DLNA server is not the FHEM server, a DLNA server running on the same server as FHEM is automatically added to the BOSE library
      • ttsDirectory "directory"   -   set DLNA TTS directory. FHEM user needs permissions to write to that directory.
      • ttsLanguage en|de|xx   -   set default TTS language (default: en)
      • ttsSpeakOnError 0|1   -   0=disable to speak "not available" text