98_BOSEST.pm 96 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578
  1. #############################################################
  2. #
  3. # BOSEST.pm (c) by Dominik Karall, 2016-2017
  4. # dominik karall at gmail dot com
  5. # $Id: 98_BOSEST.pm 13274 2017-01-29 17:45:23Z dominik $
  6. #
  7. # FHEM module to communicate with BOSE SoundTouch system
  8. # API as defined in BOSE SoundTouchAPI_WebServices_v1.0.1.pdf
  9. #
  10. # Version: 2.1.0
  11. #
  12. #############################################################
  13. #
  14. # v2.1.0 - 20170129
  15. # - NEW REQUIREMENT: TTS: sox, libsox-fmt-mp3 (only required for TTS)
  16. # - FEATURE: TTS: add 1 second silence before TTS message for speak to
  17. # prevent low volume on first words
  18. # - FEATURE: TTS: support "unlimited" characters in TTS speak.
  19. # Text is split in sentences which are afterwards
  20. # merged with sox. Same sentences are downloaded
  21. # only once a month to reduce requests to Google.
  22. # - FEATURE: TTS: remove ttsDlnaServer attribut, it will be automatically discovered
  23. # - BUGFIX: TTS: support pause/stop after speak if previous state was paused/stopped
  24. # - BUGFIX: TTS: fix resume after speak when spotify running
  25. # - BUGFIX: TTS: fix speakChannel for spotify presets
  26. # - BUGFIX: TTS: use pause on TTS instead of stop to allow proper resume
  27. # - BUGFIX: TTS: improved check after TTS play to restore previous state
  28. # - BUGFIX: TTS: if state was invalid before TTS it will be set to standby
  29. # - BUGFIX: fix save spotify to channel_7-20
  30. # - BUGFIX: fix list of arguments
  31. # - FEATURE: add $readingFnAttributes
  32. # - FEATURE: add playPause toggle command
  33. #
  34. # v2.0.1 - 20161203
  35. # - FEATURE: support shuffle/repeat (thx@rockyou)
  36. # - BUGFIX: support special characters for TTS (thx@hschuett)
  37. # - BUGFIX: module didn't work if you had only 1 preset active
  38. # - CHANGE: add link to commandref on how to setup TTS
  39. # - BUGFIX: remove "unknown event" log entry for ping/pong messages
  40. #
  41. # v2.0.0 - 20160718
  42. # - CHANGE: first official release within fhem repository
  43. #
  44. # v1.5.7 - 20160623
  45. # - BUGFIX: fix off command if zone-play is active
  46. # - BUGFIX: fix auto-zone if zone-play is already active
  47. # - BUGFIX: do not create auto-zone if both players play nothing
  48. # - BUGFIX: update most readings only on change (reduces number of events)
  49. # - BUGFIX: fix autoAddDLNAServers functionality in main device
  50. #
  51. # v1.5.6 - 20160611
  52. # - FEATURE: auto-zone, start zone-play if speakers are playing the same (contentItemLocation)
  53. # attr <name> autoZone on (default: off)
  54. # - BUGFIX: set zone only from master device as slave devices might not always report the truth (known bug at BOSE)
  55. # - CHANGE: set zonemember_X to UDN instead of player name
  56. # - CHANGE: delete TTS files after 30 days
  57. # - CHANGE: reduce number of http calls after each discovery process
  58. # - CHANGE: reduce number of http calls for key presses
  59. #
  60. # v1.5.5 - 20160510
  61. # - BUGFIX: fix unitiliazed value in handleDeviceByIp
  62. #
  63. # v1.5.4 - 20160509
  64. # - FEATURE: restore volume when speaker goes online
  65. # allows to power off the box completely without loosing
  66. # previous volume settings
  67. # - BUGFIX: fix possible unitialized value
  68. # - BUGFIX: fix next which should be return
  69. # - BUGFIX: sometimes double-tap feature wasn't working due to BOSE not
  70. # updating zones for slave speakers
  71. #
  72. # v1.5.3 - 20160425
  73. # - FEATURE: support static IPs (should only be used if device can't be discovered)
  74. # attr bose_system staticIPs 192.168.1.52,192.168.1.53
  75. # - FEATURE: support speak channel name (useful for SoundTouch w/o display)
  76. # attr <name> speakChannel 1-6
  77. # attr <name> speakChannel 2,3,5,6
  78. # - BUGFIX: retry websocket setup every 5s if it fails
  79. # - BUGFIX: update supportClockDisplay reading only on reconnect
  80. # - CHANGE: remove user attr from main device
  81. #
  82. # v1.5.2 - 20160403
  83. # - FEATURE: support clock display (SoundTouch 20/30)
  84. # set <name> clock enable/disable
  85. #
  86. # v1.5.1 - 20160330
  87. # - CHANGE: updated documentation (again many thx to Miami!)
  88. # - FEATURE: support triple-tap (currently no function implemented: any ideas? :))
  89. # - CHANGE: change back channel even after speakOff
  90. # - BUGFIX: unitialized value fixed
  91. #
  92. # v1.5.0 - 20160306
  93. # - FEATURE: support SetExtensions (on-for-timer,...)
  94. # - FEATURE: support TTS (TextToSpeach) via Google Translate
  95. # set <name> speak "This is a test message"
  96. # - FEATURE: support volume control for TTS
  97. # set <name> speak "This message has different volume" 30
  98. # - FEATURE: support different languages for TTS
  99. # set <name> speak "Das ist ein deutscher Test" de
  100. # set <name> speak "Das ist ein deutscher Test" 30 de
  101. # - FEATURE: support off (instead of resume) after TTS messages (restores only volume settings)
  102. # set <name> speakOff "Music is going to switch off now. Good night." 30 en
  103. # - FEATURE: speak "not available" text on Google Captcha
  104. # can be disabled by ttsSpeakOnError = 0
  105. # - FEATURE: set default TTS language via ttsLanguage attribute
  106. # - FEATURE: automatically add DLNA server running on the same
  107. # server as FHEM to the BOSE library
  108. # - FEATURE: automatically add all DLNA servers to BOSE library
  109. # requires autoAddDLNAServers = 1 attribute for "main" (not players!)
  110. # - FEATURE: reuse cached TTS files for 30 days
  111. # - FEATURE: set DLNA TTS directory via ttsDirectory attribute
  112. # - FEATURE: set DLNA TTS server via ttsDLNAServer attribute
  113. # only needed if the DLNA server is not the FHEM server
  114. # - FEATURE: support ttsVolume for speak
  115. # ttsVolume = 20 (set volume 20 for speak)
  116. # ttsVolume = +20 (increase volume by 20 from current level)
  117. # - FEATURE: add html documentation (provided by Miami)
  118. # - FEATURE: support relative volume settings with +/-
  119. # set <name> volume +3
  120. # set <name> speak "This is a louder message" +10
  121. # - FEATURE: new reading "connectedDLNAServers" (blanks are replaced by "-")
  122. # - FEATURE: support add/remove DLNA servers to the BOSE library
  123. # set <name> addDLNAServer RPi
  124. # set <name> removeDLNAServer RPi
  125. # - FEATURE: add readings for channel_07-20
  126. # - FEATURE: support saveChannel to save current channel to channel_07-20
  127. # - FEATURE: support bass settings only if available (/bassCapabilities)
  128. # - FEATURE: support bluetooth only if available (/sources)
  129. # - FEATURE: support switch source to airplay (untested)
  130. # - BUGFIX: update zone on Player discovery
  131. # - BUGFIX: fixed some uninitialized variables
  132. # - CHANGE: limit recent_X readings to 15 max
  133. #
  134. # v1.0.0 - 20160219
  135. # - FEATURE: support multi-room (playEverywhere, stopPlayEverywhere)
  136. # - FEATURE: show current zone members in readings
  137. # - FEATURE: support createZone <deviceID1>,<deviceID2>,...
  138. # - FEATURE: support addToZone <deviceID1>,<deviceID2>,...
  139. # - FEATURE: support removeFromZone <deviceID1>,<deviceID2>,...
  140. # - FEATURE: add "double-tap" multi-room feature
  141. # double-tap (<1s) a hardware preset button to
  142. # enable or disable the multi-room feature
  143. # - FEATURE: support bass settings
  144. # - FEATURE: support infoUpdated (e.g. deviceName change)
  145. # - FEATURE: support mute on/off/toggle
  146. # - FEATURE: support recent channel list
  147. # set name recent X
  148. # names for recent list entries are shown in readings
  149. # - FEATURE: support channel_07-20 by attribute
  150. # format:name|location|source|sourceAccount or
  151. # name|location|source| if no sourceAccount
  152. # - FEATURE: support bluetooth/bt-discover/aux mode
  153. # - FEATURE: support ignoreDeviceIDs for main define
  154. # format:B23C23FF,A2EC81EF
  155. # - CHANGE: reading channel_X => channel_0X (e.g. channel_02)
  156. #
  157. # v0.9.7 - 20160214
  158. # - FEATURE: print module version on startup of main module
  159. # - FEATURE: support device rename (e.g. BOSE_... => wz_BOSE)
  160. # - FEATURE: show preset itemNames in channel_X reading
  161. # - FEATURE: automatically update preset readings on change
  162. # - FEATURE: add description reading (could be very long)
  163. # - CHANGE: change log level for not implemented events to 4
  164. # - CHANGE: use only one processXml function for websocket and http
  165. # - BUGFIX: fix set off/on more than once within 1 second
  166. # - BUGFIX: fix warnings during setup process
  167. # - BUGFIX: support umlauts in all readings
  168. # - BUGFIX: handle XMLin errors with eval
  169. # - BUGFIX: handle "set" when speaker wasn't found yet
  170. #
  171. # v0.9.6 - 20160210
  172. # - FEATURE: support prev/next track
  173. #
  174. # v0.9.5 - 20160210
  175. # - FEATURE: update channel based on websocket events
  176. # - BUGFIX: specify minimum libmojolicious-perl version
  177. # - BUGFIX: reconnect websocket if handshake fails
  178. # - BUGFIX: presence reading fixed
  179. # - CHANGE: websocket request timeout changed to 10s (prev. 5s)
  180. # - CHANGE: clockDisplayUpdated message handled now
  181. #
  182. # v0.9.4 - 20160206
  183. # - CHANGE: completely drop ithreads (reduces memory usage)
  184. # - CHANGE: search for new devices every 60s (BlockingCall)
  185. # - CHANGE: check presence status based on websocket connection
  186. # - BUGFIX: removed arguments and readings for main BOSEST
  187. # - FEATURE: read volume on startup
  188. #
  189. # v0.9.3 - 20160125
  190. # - BUGFIX: fix "EV does not work with ithreads."
  191. #
  192. # v0.9.2 - 20160123
  193. # - BUGFIX: fix memory leak
  194. # - BUGFIX: use select instead of usleep
  195. #
  196. # v0.9.1 - 20160122
  197. # - BUGFIX: bugfix for on/off support
  198. #
  199. # v0.9 - 20160121
  200. # - autodiscover BOSE SoundTouch players
  201. # - add alias for newly created devices
  202. # - update IP if the player IP changes
  203. # - automatically re-connect websocket
  204. # - support UTF-8 names with umlauts
  205. # - reconnect websocket when connection closed
  206. # - add firmware version & IP readings
  207. # - automatically update /info on IP update
  208. # - state: offline,playing,stopped,paused,online (online means standby)
  209. # - support on/off commands based on current state
  210. # - support more readings for now_playing
  211. #
  212. # v0.2 - 20160110
  213. # - support stop/play/pause/power
  214. # - change preset to channel according to DevGuidelinesAV
  215. # - read /info on startup
  216. # - connect to websocket to receive speaker events
  217. #
  218. # v0.1 - 20160105
  219. # - define BOSE Soundtouch based on fixed IP
  220. # - change volume via /volume
  221. # - change preset via /key
  222. #
  223. # TODO
  224. # - set title/album/artist for TTS files (--comment "Title=Title..")
  225. # - check if Mojolicious should be used for HTTPGET/HTTPPOST
  226. # - ramp up/down volume support in SetExtensions
  227. #
  228. #############################################################
  229. BEGIN {
  230. $ENV{MOJO_REACTOR} = "Mojo::Reactor::Poll";
  231. }
  232. package main;
  233. use strict;
  234. use warnings;
  235. use Blocking;
  236. use Encode;
  237. use SetExtensions;
  238. use Data::Dumper;
  239. use Digest::MD5 qw(md5_hex);
  240. use File::stat;
  241. use IO::Socket::INET;
  242. use LWP::UserAgent;
  243. use Mojolicious 5.54;
  244. use Net::Bonjour;
  245. use Scalar::Util qw(looks_like_number);
  246. use XML::Simple;
  247. use URI::Escape;
  248. my $BOSEST_GOOGLE_NOT_AVAILABLE_TEXT = "Hello, I'm sorry, but Google Translate is currently not available.";
  249. my $BOSEST_GOOGLE_NOT_AVAILABLE_LANG = "en";
  250. my $BOSEST_READ_CMDREF_TEXT = "Hello, I'm sorry, but you need to install new libraries, please read command reference.";
  251. my $BOSEST_READ_CMDREF_LANG = "en";
  252. sub BOSEST_Initialize($) {
  253. my ($hash) = @_;
  254. $hash->{DefFn} = 'BOSEST_Define';
  255. $hash->{UndefFn} = 'BOSEST_Undef';
  256. $hash->{GetFn} = 'BOSEST_Get';
  257. $hash->{SetFn} = 'BOSEST_Set';
  258. $hash->{AttrFn} = 'BOSEST_Attribute';
  259. $hash->{AttrList} = $readingFnAttributes;
  260. return undef;
  261. }
  262. sub BOSEST_Define($$) {
  263. my ($hash, $def) = @_;
  264. my @a = split("[ \t]+", $def);
  265. my $name = $a[0];
  266. $hash->{DEVICEID} = "0";
  267. $hash->{STATE} = "initialized";
  268. if (int(@a) > 3) {
  269. return 'BOSEST: Wrong syntax, must be define <name> BOSEST [<deviceID>]';
  270. } elsif(int(@a) == 3) {
  271. my $param = $a[2];
  272. #set device id from parameter
  273. $hash->{DEVICEID} = $param;
  274. #set IP to unknown
  275. $hash->{helper}{IP} = "unknown";
  276. readingsSingleUpdate($hash, "IP", "unknown", 1);
  277. #allow on/off commands (updateIP?)
  278. $hash->{helper}{sent_on} = 0;
  279. $hash->{helper}{sent_off} = 0;
  280. #no websockets connected
  281. $hash->{helper}{wsconnected} = 0;
  282. #create mojo useragent
  283. $hash->{helper}{useragent} = Mojo::UserAgent->new() if(!defined($hash->{helper}{useragent}));
  284. #init statecheck
  285. $hash->{helper}{stateCheck}{enabled} = 0;
  286. $hash->{helper}{stateCheck}{actionActive} = 0;
  287. #init switchSource
  288. $hash->{helper}{switchSource} = "";
  289. #init speak channel functionality
  290. $hash->{helper}{lastSpokenChannel} = "";
  291. my $attrList = "channel_07 channel_08 channel_09 channel_10 channel_11 ".
  292. "channel_12 channel_13 channel_14 channel_15 channel_16 ".
  293. "channel_17 channel_18 channel_19 channel_20 ignoreDeviceIDs ".
  294. "ttsDirectory ttsLanguage ttsSpeakOnError ttsVolume ".
  295. "speakChannel autoZone";
  296. my @attrListArr = split(" ", $attrList);
  297. foreach my $attrname (@attrListArr) {
  298. addToDevAttrList($name, $attrname);
  299. }
  300. BOSEST_deleteOldTTSFiles($hash);
  301. #FIXME reset all recent_$i entries on startup (must be done here, otherwise readings are displayed when player wasn't found)
  302. }
  303. #init dlnaservers
  304. $hash->{helper}{dlnaServers} = "";
  305. #init supported source commands
  306. $hash->{helper}{supportedSourcesCmds} = "";
  307. $hash->{helper}{supportedBassCmds} = "";
  308. if (int(@a) < 3) {
  309. Log3 $hash, 3, "BOSEST: BOSE SoundTouch v2.1.0";
  310. #start discovery process 30s delayed
  311. InternalTimer(gettimeofday()+30, "BOSEST_startDiscoveryProcess", $hash, 0);
  312. foreach my $attrname (qw(staticIPs autoAddDLNAServers)) {
  313. addToDevAttrList($name, $attrname);
  314. }
  315. }
  316. return undef;
  317. }
  318. sub BOSEST_Attribute($$$$) {
  319. my ($mode, $devName, $attrName, $attrValue) = @_;
  320. if($mode eq "set") {
  321. if(substr($attrName, 0, 8) eq "channel_") {
  322. #check if there are 3 | in the attrValue
  323. my @value = split("\\|", $attrValue);
  324. return "BOSEST: wrong format" if(!defined($value[2]));
  325. #update reading for channel_X
  326. readingsSingleUpdate($main::defs{$devName}, $attrName, $value[0], 1);
  327. }
  328. } elsif($mode eq "del") {
  329. if(substr($attrName, 0, 8) eq "channel_") {
  330. #update reading for channel_X
  331. readingsSingleUpdate($main::defs{$devName}, $attrName, "-", 1);
  332. }
  333. }
  334. return undef;
  335. }
  336. sub BOSEST_Set($@) {
  337. my ($hash, $name, @params) = @_;
  338. my $workType = shift(@params);
  339. #get quoted text from params
  340. my $blankParams = join(" ", @params);
  341. my @params2;
  342. while($blankParams =~ /"?((?<!")\S+(?<!")|[^"]+)"?\s*/g) {
  343. push(@params2, $1);
  344. }
  345. @params = @params2;
  346. my $list = "on:noArg off:noArg power:noArg play:noArg ".
  347. "playPause:noArg ".
  348. "mute:on,off,toggle recent source:".$hash->{helper}{supportedSourcesCmds}.
  349. "shuffle:on,off repeat:all,one,off ".
  350. "nextTrack:noArg prevTrack:noArg playTrack speak speakOff ".
  351. "playEverywhere:noArg stopPlayEverywhere:noArg createZone addToZone removeFromZone ".
  352. "clock:enable,disable ".
  353. "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 ".
  354. "volume:slider,0,1,100 ".$hash->{helper}{supportedBassCmds}." ".
  355. "saveChannel:07,08,09,10,11,12,13,14,15,16,17,18,19,20 ".
  356. "addDLNAServer:".$hash->{helper}{dlnaServers}." ".
  357. "removeDLNAServer:".ReadingsVal($hash->{NAME}, "connectedDLNAServers", "noArg");
  358. # check parameters for set function
  359. #DEVELOPNEWFUNCTION-1
  360. if($workType eq "?") {
  361. if($hash->{DEVICEID} eq "0") {
  362. return ""; #no arguments for server
  363. } else {
  364. return SetExtensions($hash, $list, $name, $workType, @params);
  365. }
  366. }
  367. if($hash->{helper}{IP} eq "unknown") {
  368. return "Searching for BOSE SoundTouch, try again later...";
  369. }
  370. if($workType eq "volume") {
  371. return "BOSEST: volume requires volume as additional parameter" if(int(@params) < 1);
  372. #params[0] = volume value
  373. BOSEST_setVolume($hash, $params[0]);
  374. } elsif($workType eq "zoneVolume") {
  375. BOSEST_setZoneVolume($hash, $params[0]);
  376. } elsif($workType eq "channel") {
  377. return "BOSEST: channel requires preset id as additional parameter" if(int(@params) < 1);
  378. #params[0] = preset channel
  379. BOSEST_setPreset($hash, $params[0]);
  380. } elsif($workType eq "saveChannel") {
  381. return "BOSEST: saveChannel requires channel number as additional parameter" if(int(@params) < 1);
  382. #params[09 = channel number (07-20)
  383. BOSEST_saveChannel($hash, $params[0]);
  384. } elsif($workType eq "bass") {
  385. return "BOSEST: bass requires bass (1-10) as additional parameter" if(int(@params) < 1);
  386. #params[0] = bass value
  387. BOSEST_setBass($hash, $params[0]);
  388. } elsif($workType eq "mute") {
  389. return "BOSEST: mute requires on/off/toggle as additional parameter" if(int(@params) < 1);
  390. #params[0] = mute value
  391. BOSEST_setMute($hash, $params[0]);
  392. } elsif($workType eq "shuffle") {
  393. return "BOSEST: shuffle requires on/off as additional parameter" if(int(@params) < 1);
  394. #params[0] = shuffle value
  395. BOSEST_setShuffle($hash, $params[0]);
  396. } elsif($workType eq "repeat") {
  397. return "BOSEST: repeat requires all/one/off as additional parameter" if(int(@params) < 1);
  398. return "BOSEST: repeat one not supported by spotify" if ($params[0] eq "one" && ReadingsVal($hash->{NAME}, "source", "") eq "SPOTIFY");
  399. #params[0] = repeat value
  400. BOSEST_setRepeat($hash, $params[0]);
  401. } elsif($workType eq "recent") {
  402. return "BOSEST: recebt requires number as additional parameter" if(int(@params) < 1);
  403. #params[0] = recent value
  404. BOSEST_setRecent($hash, $params[0]);
  405. } elsif($workType eq "source") {
  406. return "BOSEST: source requires bluetooth/aux as additional parameter" if(int(@params) < 1);
  407. #params[0] = source value
  408. BOSEST_setSource($hash, $params[0]);
  409. } elsif($workType eq "addDLNAServer") {
  410. return "BOSEST: addDLNAServer requires DLNA friendly name as additional parameter" if(int(@params) < 1);
  411. #params[0] = friendly name
  412. BOSEST_addDLNAServer($hash, $params[0]);
  413. } elsif($workType eq "removeDLNAServer") {
  414. return "BOSEST: removeDLNAServer requires DLNA friendly name as additional parameter" if(int(@params) < 1);
  415. #params[0] = friendly name
  416. BOSEST_removeDLNAServer($hash, $params[0]);
  417. } elsif($workType eq "clock") {
  418. return "BOSEST: clock requires enable/disable as additional parameter" if(int(@params) < 1);
  419. #check if supported
  420. return "BOSEST: clock not supported." if(ReadingsVal($hash->{NAME}, "supportClockDisplay", "false") eq "false");
  421. BOSEST_clockSettings($hash, $params[0]);
  422. } elsif($workType eq "play") {
  423. BOSEST_play($hash);
  424. } elsif($workType eq "stop") {
  425. BOSEST_stop($hash);
  426. } elsif($workType eq "pause") {
  427. BOSEST_pause($hash);
  428. } elsif($workType eq "playPause") {
  429. BOSEST_playPause($hash);
  430. } elsif($workType eq "power") {
  431. BOSEST_power($hash);
  432. } elsif($workType eq "on") {
  433. BOSEST_on($hash);
  434. } elsif($workType eq "off") {
  435. BOSEST_off($hash);
  436. InternalTimer(gettimeofday()+2, "BOSEST_off", $hash, 0);
  437. } elsif($workType eq "nextTrack") {
  438. BOSEST_next($hash);
  439. } elsif($workType eq "prevTrack") {
  440. BOSEST_prev($hash);
  441. } elsif($workType eq "playTrack") {
  442. return "BOSEST: playTrack requires track name as additional parameters" if(int(@params) < 1);
  443. #params[0] = track name for search
  444. BOSEST_playTrack($hash, $params[0]);
  445. } elsif($workType eq "speak" or $workType eq "speakOff") {
  446. return "BOSEST: speak requires quoted text as additional parameters" if(int(@params) < 1);
  447. return "BOSEST: speak requires quoted text" if(substr($blankParams, 0, 1) ne "\"");
  448. if(AttrVal($hash->{NAME}, "ttsDirectory", "") eq "") {
  449. return "BOSEST: Please set ttsDirectory attribute first.
  450. FHEM user needs permissions to write to that directory.
  451. It is also recommended to set ttsLanguage (default: en).";
  452. }
  453. #set text (must be within quotes)
  454. my $text = $params[0];
  455. my $volume = "";
  456. if(looks_like_number($params[1])) {
  457. #set volume (default current volume)
  458. $volume = $params[1] if(defined($params[1]));
  459. } else {
  460. #parameter is language
  461. $params[2] = $params[1];
  462. }
  463. #set language (default English)
  464. my $lang = "";
  465. $lang = $params[2] if(defined($params[2]));
  466. #stop after speak?
  467. my $stopAfterSpeak = 0;
  468. if($workType eq "speakOff") {
  469. $stopAfterSpeak = 1;
  470. }
  471. BOSEST_speak($hash, $text, $volume, $lang, $stopAfterSpeak);
  472. } elsif($workType eq "playEverywhere") {
  473. BOSEST_playEverywhere($hash);
  474. } elsif($workType eq "stopPlayEverywhere") {
  475. BOSEST_stopPlayEverywhere($hash);
  476. } elsif($workType eq "createZone") {
  477. return "BOSEST: createZone requires deviceIDs as additional parameter" if(int(@params) < 1);
  478. #params[0] = deviceID channel
  479. BOSEST_createZone($hash, $params[0]);
  480. } elsif($workType eq "addToZone") {
  481. return "BOSEST: addToZone requires deviceID as additional parameter" if(int(@params) < 1);
  482. #params[0] = deviceID channel
  483. BOSEST_addToZone($hash, $params[0]);
  484. } elsif($workType eq "removeFromZone") {
  485. return "BOSEST: removeFromZone requires deviceID as additional parameter" if(int(@params) < 1);
  486. #params[0] = deviceID channel
  487. BOSEST_removeFromZone($hash, $params[0]);
  488. } else {
  489. return SetExtensions($hash, $list, $name, $workType, @params);
  490. }
  491. return undef;
  492. }
  493. #DEVELOPNEWFUNCTION-2 (create own function)
  494. sub BOSEST_setZoneVolume {
  495. my ($hash, $targetVolume) = @_;
  496. #FIXME
  497. # #change volume of this device
  498. # DLNARenderer_volume($hash, $targetVolume);
  499. # #handle volume for all devices in the current group
  500. # #iterate through group and change volume relative to the current volume of this device
  501. # my $mainVolumeDiff = DLNARenderer_convertVolumeToAbsolute($hash, $targetVolume) - ReadingsVal($hash->{NAME}, "volume", 0);
  502. # my $multiRoomUnits = ReadingsVal($hash->{NAME}, "multiRoomUnits", "");
  503. # my @multiRoomUnitsArray = split("," $multiRoomUnits);
  504. # foreach my $unit (@multiRoomUnitsArray) {
  505. # my $devHash = DLNARenderer_getHashByFriendlyName($hash, $unit);
  506. # my $newVolume = ReadingsVal($devHash->{NAME}, "volume", 0) + $mainVolumeDiff);
  507. # if($newVolume > 100) {
  508. # $newVolume = 100;
  509. # } elsif($newVolume < 0) {
  510. # $newVolume = 0;
  511. # }
  512. # DLNARenderer_volume($devHash, $newVolume);
  513. # }
  514. return undef;
  515. }
  516. sub BOSEST_clockSettings($$) {
  517. my ($hash, $val) = @_;
  518. if($val eq "disable") {
  519. $val = "false";
  520. } else {
  521. $val = "true";
  522. }
  523. my $postXml = "<clockDisplay><clockConfig userEnable=\"$val\"/></clockDisplay>";
  524. if(BOSEST_HTTPPOST($hash, '/clockDisplay', $postXml)) {
  525. }
  526. #FIXME error handling
  527. return undef;
  528. }
  529. sub BOSEST_addDLNAServer($$) {
  530. my ($hash, $friendlyName) = @_;
  531. #retrieve uuid for friendlyname
  532. my $listMediaServers = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/listMediaServers");
  533. foreach my $mediaServer (@{ $listMediaServers->{ListMediaServersResponse}->{media_server} }) {
  534. $mediaServer->{friendly_name} =~ s/\ /_/g;
  535. if($mediaServer->{friendly_name} eq $friendlyName) {
  536. BOSEST_setMusicServiceAccount($hash, $friendlyName, $mediaServer->{id});
  537. }
  538. }
  539. return undef;
  540. }
  541. sub BOSEST_removeDLNAServer($$) {
  542. my ($hash, $friendlyName) = @_;
  543. #retrieve uuid for friendlyname
  544. my $sources = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/sources");
  545. foreach my $source (@{ $sources->{sources}->{sourceItem} }) {
  546. next if($source->{source} ne "STORED_MUSIC");
  547. $source->{content} =~ s/\ /_/g;
  548. if($source->{content} eq $friendlyName) {
  549. BOSEST_removeMusicServiceAccount($hash, $friendlyName, $source->{sourceAccount});
  550. }
  551. }
  552. return undef;
  553. }
  554. sub BOSEST_saveChannel($$) {
  555. my ($hash, $channel) = @_;
  556. if(ReadingsVal($hash->{NAME}, "contentItemLocation", "") eq "") {
  557. return "BOSEST: No active channel.";
  558. }
  559. #itemname, type, location, source, sourceaccount
  560. my $itemName = ReadingsVal($hash->{NAME}, "contentItemItemName", "");
  561. my $location = ReadingsVal($hash->{NAME}, "contentItemLocation", "");
  562. my $type = ReadingsVal($hash->{NAME}, "contentItemType", "");
  563. my $source = ReadingsVal($hash->{NAME}, "contentItemSource", "");
  564. my $sourceAccount = ReadingsVal($hash->{NAME}, "contentItemSourceAccount", "");
  565. fhem("attr $hash->{NAME} channel_$channel $itemName|$type|$location|$source|$sourceAccount");
  566. return undef;
  567. }
  568. sub BOSEST_stopPlayEverywhere($) {
  569. my ($hash) = @_;
  570. my $postXmlHeader = "<zone master=\"$hash->{DEVICEID}\">";
  571. my $postXmlFooter = "</zone>";
  572. my $postXml = "";
  573. my @players = BOSEST_getAllBosePlayers($hash);
  574. foreach my $playerHash (@players) {
  575. if($playerHash->{DEVICEID} ne $hash->{DEVICEID}) {
  576. $postXml .= "<member ipaddress=\"".$playerHash->{helper}{IP}."\">".$playerHash->{DEVICEID}."</member>" if($playerHash->{helper}{IP} ne "unknown");
  577. }
  578. }
  579. $postXml = $postXmlHeader.$postXml.$postXmlFooter;
  580. if(BOSEST_HTTPPOST($hash, '/removeZoneSlave', $postXml)) {
  581. #ok
  582. }
  583. }
  584. sub BOSEST_playEverywhere($) {
  585. my ($hash) = @_;
  586. my $postXmlHeader = "<zone master=\"$hash->{DEVICEID}\" senderIPAddress=\"$hash->{helper}{IP}\">";
  587. my $postXmlFooter = "</zone>";
  588. my $postXml = "";
  589. my @players = BOSEST_getAllBosePlayers($hash);
  590. foreach my $playerHash (@players) {
  591. #don't add myself as member, I'm the master
  592. if($playerHash->{DEVICEID} ne $hash->{DEVICEID}) {
  593. $postXml .= "<member ipaddress=\"".$playerHash->{helper}{IP}."\">".$playerHash->{DEVICEID}."</member>" if($playerHash->{helper}{IP} ne "unknown");
  594. }
  595. }
  596. $postXml = $postXmlHeader.$postXml.$postXmlFooter;
  597. if(BOSEST_HTTPPOST($hash, '/setZone', $postXml)) {
  598. #ok
  599. }
  600. return undef;
  601. }
  602. sub BOSEST_createZone($$) {
  603. my ($hash, $deviceIds) = @_;
  604. my @devices = split(",", $deviceIds);
  605. my $postXmlHeader = "<zone master=\"$hash->{DEVICEID}\" senderIPAddress=\"$hash->{helper}{IP}\">";
  606. my $postXmlFooter = "</zone>";
  607. my $postXml = "";
  608. foreach my $deviceId (@devices) {
  609. my $playerHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId);
  610. return undef if(!defined($playerHash));
  611. $postXml .= "<member ipaddress=\"".$playerHash->{helper}{IP}."\">".$playerHash->{DEVICEID}."</member>" if($playerHash->{helper}{IP} ne "unknown");
  612. }
  613. $postXml = $postXmlHeader.$postXml.$postXmlFooter;
  614. if(BOSEST_HTTPPOST($hash, '/setZone', $postXml)) {
  615. #ok
  616. }
  617. return undef;
  618. }
  619. sub BOSEST_addToZone($$) {
  620. my ($hash, $deviceIds) = @_;
  621. my @devices = split(",", $deviceIds);
  622. my $postXmlHeader = "<zone master=\"$hash->{DEVICEID}\" senderIPAddress=\"$hash->{helper}{IP}\">";
  623. my $postXmlFooter = "</zone>";
  624. my $postXml = "";
  625. foreach my $deviceId (@devices) {
  626. my $playerHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId);
  627. return undef if(!defined($playerHash));
  628. $postXml .= "<member ipaddress=\"".$playerHash->{helper}{IP}."\">".$playerHash->{DEVICEID}."</member>" if($playerHash->{helper}{IP} ne "unknown");
  629. }
  630. $postXml = $postXmlHeader.$postXml.$postXmlFooter;
  631. if(BOSEST_HTTPPOST($hash, '/addZoneSlave', $postXml)) {
  632. #ok
  633. }
  634. return undef;
  635. }
  636. sub BOSEST_removeFromZone($$) {
  637. my ($hash, $deviceIds) = @_;
  638. my @devices = split(",", $deviceIds);
  639. my $postXmlHeader = "<zone master=\"$hash->{DEVICEID}\">";
  640. my $postXmlFooter = "</zone>";
  641. my $postXml = "";
  642. foreach my $deviceId (@devices) {
  643. my $playerHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId);
  644. return undef if(!defined($playerHash));
  645. $postXml .= "<member ipaddress=\"".$playerHash->{helper}{IP}."\">".$playerHash->{DEVICEID}."</member>" if($playerHash->{helper}{IP} ne "unknown");
  646. }
  647. $postXml = $postXmlHeader.$postXml.$postXmlFooter;
  648. if(BOSEST_HTTPPOST($hash, '/removeZoneSlave', $postXml)) {
  649. #ok
  650. }
  651. return undef;
  652. }
  653. sub BOSEST_on($) {
  654. my ($hash) = @_;
  655. if(!$hash->{helper}{sent_on}) {
  656. my $sourceState = ReadingsVal($hash->{NAME}, "source", "STANDBY");
  657. if($sourceState eq "STANDBY") {
  658. BOSEST_power($hash);
  659. }
  660. $hash->{helper}{sent_on} = 1;
  661. }
  662. }
  663. sub BOSEST_off($) {
  664. my ($hash) = @_;
  665. if(!$hash->{helper}{sent_off}) {
  666. my $sourceState = ReadingsVal($hash->{NAME}, "source", "STANDBY");
  667. if($sourceState ne "STANDBY") {
  668. BOSEST_power($hash);
  669. }
  670. $hash->{helper}{sent_off} = 1;
  671. }
  672. }
  673. sub BOSEST_setRecent($$) {
  674. my ($hash, $nr) = @_;
  675. if(!defined($hash->{helper}{recents}{$nr}{itemName})) {
  676. #recent entry not found
  677. return undef;
  678. }
  679. BOSEST_setContentItem($hash,
  680. $hash->{helper}{recents}{$nr}{itemName},
  681. $hash->{helper}{recents}{$nr}{type},
  682. $hash->{helper}{recents}{$nr}{location},
  683. $hash->{helper}{recents}{$nr}{source},
  684. $hash->{helper}{recents}{$nr}{sourceAccount});
  685. return undef;
  686. }
  687. sub BOSEST_setContentItem {
  688. my ($hash, $itemName, $type, $location, $source, $sourceAccount) = @_;
  689. $type = "" if(!defined($type));
  690. my $postXml = "<ContentItem source=\"".
  691. $source.
  692. "\" sourceAccount=\"".
  693. $sourceAccount.
  694. "\" location=\"".
  695. $location.
  696. "\" type=\"".
  697. $type.
  698. "\">".
  699. "<itemName>".
  700. $itemName.
  701. "</itemName>".
  702. "</ContentItem>";
  703. if(BOSEST_HTTPPOST($hash, "/select", $postXml)) {
  704. #ok
  705. }
  706. return undef;
  707. }
  708. sub BOSEST_setBass($$) {
  709. my ($hash, $bass) = @_;
  710. $bass = $bass - 10;
  711. my $postXml = "<bass>$bass</bass>";
  712. if(BOSEST_HTTPPOST($hash, '/bass', $postXml)) {
  713. }
  714. #FIXME error handling
  715. return undef;
  716. }
  717. sub BOSEST_setVolume($$) {
  718. my ($hash, $volume) = @_;
  719. if(substr($volume, 0, 1) eq "+" or
  720. substr($volume, 0, 1) eq "-") {
  721. $volume = ReadingsVal($hash->{NAME}, "volume", 0) + $volume;
  722. }
  723. my $postXml = '<volume>'.$volume.'</volume>';
  724. if(BOSEST_HTTPPOST($hash, '/volume', $postXml)) {
  725. }
  726. #FIXME error handling
  727. return undef;
  728. }
  729. sub BOSEST_setMute($$) {
  730. my ($hash, $mute) = @_;
  731. if(($mute eq "on" && $hash->{READINGS}{mute}{VAL} eq "false") or
  732. ($mute eq "off" && $hash->{READINGS}{mute}{VAL} eq "true") or
  733. ($mute eq "toggle")) {
  734. BOSEST_sendKey($hash, "MUTE");
  735. }
  736. return undef;
  737. }
  738. sub BOSEST_setShuffle($$) {
  739. my ($hash, $shuffle) = @_;
  740. if($shuffle eq "on") {
  741. BOSEST_sendKey($hash, "SHUFFLE_ON");
  742. }
  743. if ($shuffle eq "off") {
  744. BOSEST_sendKey($hash, "SHUFFLE_OFF");
  745. }
  746. return undef;
  747. }
  748. sub BOSEST_setRepeat($$) {
  749. my ($hash, $repeat) = @_;
  750. if($repeat eq "one") {
  751. BOSEST_sendKey($hash, "REPEAT_ONE");
  752. }
  753. if ($repeat eq "all") {
  754. BOSEST_sendKey($hash, "REPEAT_ALL");
  755. }
  756. if ($repeat eq "off") {
  757. BOSEST_sendKey($hash, "REPEAT_OFF");
  758. }
  759. return undef;
  760. }
  761. sub BOSEST_setSource($$) {
  762. my ($hash, $source) = @_;
  763. $hash->{helper}{switchSource} = uc $source;
  764. if($hash->{helper}{switchSource} eq "") {
  765. return undef;
  766. }
  767. if($hash->{helper}{switchSource} eq "BT-DISCOVER" &&
  768. ReadingsVal($hash->{NAME}, "connectionStatusInfo", "") eq "DISCOVERABLE") {
  769. $hash->{helper}{switchSource} = "";
  770. return undef;
  771. }
  772. if($hash->{helper}{switchSource} eq ReadingsVal($hash->{NAME}, "source", "") &&
  773. ReadingsVal($hash->{NAME}, "connectionStatusInfo", "") ne "DISCOVERABLE") {
  774. $hash->{helper}{switchSource} = "";
  775. return undef;
  776. }
  777. #source is not switchSource yet
  778. BOSEST_sendKey($hash, "AUX_INPUT");
  779. return undef;
  780. }
  781. sub BOSEST_setPreset($$) {
  782. my ($hash, $preset) = @_;
  783. if($preset > 0 && $preset < 7) {
  784. BOSEST_sendKey($hash, "PRESET_".$preset);
  785. } else {
  786. #set channel based on AttrVal
  787. my $channelVal = AttrVal($hash->{NAME}, sprintf("channel_%02d", $preset), "0");
  788. return undef if($channelVal eq "0");
  789. my @channel = split("\\|", $channelVal);
  790. $channel[1] = "" if(!defined($channel[1]));
  791. $channel[2] = "" if(!defined($channel[2]));
  792. $channel[3] = "" if(!defined($channel[3]));
  793. $channel[4] = "" if(!defined($channel[4]));
  794. Log3 $hash, 5, "BOSEST: AttrVal: $channel[0], $channel[1], $channel[2], $channel[3], $channel[4]";
  795. #format: itemName|location|source|sourceAccount
  796. BOSEST_setContentItem($hash, $channel[0], $channel[1], $channel[2], $channel[3], $channel[4]);
  797. }
  798. return undef;
  799. }
  800. sub BOSEST_play($) {
  801. my ($hash) = @_;
  802. BOSEST_sendKey($hash, "PLAY");
  803. return undef;
  804. }
  805. sub BOSEST_playPause($) {
  806. my ($hash) = @_;
  807. BOSEST_sendKey($hash, "PLAY_PAUSE");
  808. return undef;
  809. }
  810. sub BOSEST_stop($) {
  811. my ($hash) = @_;
  812. BOSEST_sendKey($hash, "STOP");
  813. return undef;
  814. }
  815. sub BOSEST_pause($) {
  816. my ($hash) = @_;
  817. BOSEST_sendKey($hash, "PAUSE");
  818. return undef;
  819. }
  820. sub BOSEST_power($) {
  821. my ($hash) = @_;
  822. BOSEST_sendKey($hash, "POWER");
  823. return undef;
  824. }
  825. sub BOSEST_next($) {
  826. my ($hash) = @_;
  827. BOSEST_sendKey($hash, "NEXT_TRACK");
  828. return undef;
  829. }
  830. sub BOSEST_prev($) {
  831. my ($hash) = @_;
  832. BOSEST_sendKey($hash, "PREV_TRACK");
  833. return undef;
  834. }
  835. sub BOSEST_Undef($) {
  836. my ($hash) = @_;
  837. #remove internal timer
  838. RemoveInternalTimer($hash);
  839. #kill blocking
  840. BlockingKill($hash->{helper}{DISCOVERY_PID}) if(defined($hash->{helper}{DISCOVERY_PID}));
  841. return undef;
  842. }
  843. sub BOSEST_Get($$) {
  844. return undef;
  845. }
  846. sub BOSEST_speakChannel {
  847. my ($hash) = @_;
  848. my $speakChannel = AttrVal($hash->{NAME}, "speakChannel", "");
  849. if($speakChannel ne "") {
  850. my $channelNr = ReadingsVal($hash->{NAME}, "channel", "");
  851. Log3 $hash, 5, "BOSEST: speakChannel, $channelNr is in $speakChannel range?";
  852. if($channelNr =~ /[$speakChannel]/g) {
  853. my $channelName = ReadingsVal($hash->{NAME}, "contentItemItemName", "");
  854. Log3 $hash, 5, "BOSEST: speakChannel, start speak for channl $channelName";
  855. if($channelNr ne "" && $channelName ne "" && $hash->{helper}{lastSpokenChannel} ne $channelName) {
  856. #speak channel name
  857. $hash->{helper}{lastSpokenChannel} = $channelName;
  858. BOSEST_speak($hash, $channelName, "", "", 0);
  859. }
  860. } else {
  861. if($channelNr ne "") {
  862. #delete lastSpokenChannel
  863. $hash->{helper}{lastSpokenChannel} = "";
  864. }
  865. }
  866. }
  867. }
  868. sub BOSEST_speak($$$$$) {
  869. my ($hash, $text, $volume, $lang, $stopAfterSpeak) = @_;
  870. my $ttsDir = AttrVal($hash->{NAME}, "ttsDirectory", "");
  871. $lang = AttrVal($hash->{NAME}, "ttsLanguage", "en") if($lang eq "");
  872. $volume = AttrVal($hash->{NAME}, "ttsVolume", ReadingsVal($hash->{NAME}, "volume", 20)) if($volume eq "");
  873. my $sox = qx(which sox);
  874. chomp $sox;
  875. if(!-x $sox) {
  876. BOSEST_playGoogleTTS($hash, $ttsDir, $BOSEST_READ_CMDREF_TEXT, $volume, $BOSEST_READ_CMDREF_LANG, $stopAfterSpeak);
  877. return undef;
  878. }
  879. #download file and play
  880. BOSEST_playGoogleTTS($hash, $ttsDir, $text, $volume, $lang, $stopAfterSpeak);
  881. return undef;
  882. }
  883. sub BOSEST_saveCurrentState($) {
  884. my ($hash) = @_;
  885. $hash->{helper}{savedState}{volume} = ReadingsVal($hash->{NAME}, "volume", 20);
  886. $hash->{helper}{savedState}{source} = ReadingsVal($hash->{NAME}, "source", "");
  887. $hash->{helper}{savedState}{bass} = ReadingsVal($hash->{NAME}, "bass", "");
  888. $hash->{helper}{savedState}{playStatus} = ReadingsVal($hash->{NAME}, "playStatus", "STOP_STATE");
  889. $hash->{helper}{savedState}{contentItemItemName} = ReadingsVal($hash->{NAME}, "contentItemItemName", "");
  890. $hash->{helper}{savedState}{contentItemType} = ReadingsVal($hash->{NAME}, "contentItemType", "");
  891. $hash->{helper}{savedState}{contentItemLocation} = ReadingsVal($hash->{NAME}, "contentItemLocation", "");
  892. $hash->{helper}{savedState}{contentItemSource} = ReadingsVal($hash->{NAME}, "contentItemSource", "");
  893. $hash->{helper}{savedState}{contentItemSourceAccount} = ReadingsVal($hash->{NAME}, "contentItemSourceAccount", "");
  894. return undef;
  895. }
  896. sub BOSEST_restoreSavedState($) {
  897. my ($hash) = @_;
  898. BOSEST_setVolume($hash, $hash->{helper}{savedState}{volume});
  899. BOSEST_setBass($hash, $hash->{helper}{savedState}{bass});
  900. #bose off when source was off
  901. if($hash->{helper}{savedState}{source} eq "STANDBY" or $hash->{helper}{savedState}{source} eq "INVALID_SOURCE") {
  902. BOSEST_off($hash);
  903. } else {
  904. BOSEST_setContentItem($hash, $hash->{helper}{savedState}{contentItemItemName},
  905. $hash->{helper}{savedState}{contentItemType},
  906. $hash->{helper}{savedState}{contentItemLocation},
  907. $hash->{helper}{savedState}{contentItemSource},
  908. $hash->{helper}{savedState}{contentItemSourceAccount});
  909. if($hash->{helper}{savedState}{playStatus} eq "STOP_STATE") {
  910. InternalTimer(gettimeofday()+0.8, "BOSEST_stop", $hash, 0);
  911. } elsif($hash->{helper}{savedState}{playStatus} eq "PAUSE_STATE") {
  912. InternalTimer(gettimeofday()+0.8, "BOSEST_pause", $hash, 0);
  913. }
  914. }
  915. return undef;
  916. }
  917. sub BOSEST_restoreVolumeAndOff($) {
  918. my ($hash) = @_;
  919. BOSEST_setVolume($hash, $hash->{helper}{savedState}{volume});
  920. BOSEST_setBass($hash, $hash->{helper}{savedState}{bass});
  921. BOSEST_setContentItem($hash, $hash->{helper}{savedState}{contentItemItemName},
  922. $hash->{helper}{savedState}{contentItemType},
  923. $hash->{helper}{savedState}{contentItemLocation},
  924. $hash->{helper}{savedState}{contentItemSource},
  925. $hash->{helper}{savedState}{contentItemSourceAccount});
  926. BOSEST_off($hash);
  927. }
  928. sub BOSEST_downloadGoogleNotAvailable($) {
  929. my ($hash) = @_;
  930. my $text = $BOSEST_GOOGLE_NOT_AVAILABLE_TEXT;
  931. my $lang = $BOSEST_GOOGLE_NOT_AVAILABLE_LANG;
  932. my $ttsDir = AttrVal($hash->{NAME}, "ttsDirectory", "");
  933. my $md5 = md5_hex($lang.$text);
  934. my $filename = $ttsDir."/".$md5.".mp3";
  935. if (! -f $filename) {
  936. BOSEST_retrieveGooglTTSFile($hash, $filename, $md5, $text, $lang);
  937. }
  938. return undef;
  939. }
  940. sub BOSEST_retrieveGooglTTSFile($$$$$;$) {
  941. my ($hash, $filename, $md5, $text, $lang, $callback) = @_;
  942. my $uri_text = uri_escape($text);
  943. $hash->{helper}{useragent}->get("http://translate.google.com/translate_tts?ie=UTF-8&tl=$lang&client=tw-ob&q=$uri_text" => sub {
  944. my ($ua, $tx) = @_;
  945. my $downloadOk = 0;
  946. if($tx->res->headers->content_type eq "audio/mpeg") {
  947. $tx->res->content->asset->move_to($filename);
  948. $downloadOk = 1;
  949. }
  950. if(defined($callback)) {
  951. $callback->($hash, $filename, $md5, $downloadOk);
  952. }
  953. });
  954. return undef;
  955. }
  956. sub BOSEST_generateSilence {
  957. my ($hash) = @_;
  958. my $ttsDir = AttrVal($hash->{NAME}, "ttsDirectory", "");
  959. my $silenceFile = $ttsDir."/BOSEST_silence.mp3";
  960. my $soxCmd;
  961. if(!-f $silenceFile) {
  962. #generate silence file
  963. $soxCmd = "sox -n -r 24000 -c 1 $silenceFile trim 0.0 1";
  964. qx($soxCmd);
  965. }
  966. return undef;
  967. }
  968. sub BOSEST_joinAudioFilesBlocking {
  969. my ($string) = @_;
  970. my ($name, $outputFile, @inputFiles) = split("\\|", $string);
  971. my $ttsDir = AttrVal($name, "ttsDirectory", "");
  972. my $hash = $main::defs{$name};
  973. my $inputF = join(" ", map { $ttsDir."/".$_ } @inputFiles);
  974. my $outputF = $ttsDir."/".$outputFile;
  975. my $outputFileTmp = $ttsDir."/tmp_".$outputFile;
  976. BOSEST_generateSilence($hash);
  977. my $soxCmd = "sox $inputF $outputFileTmp";
  978. Log3 $hash, 5, "SOX: $soxCmd";
  979. my $soxRes = qx($soxCmd);
  980. qx(mv $outputFileTmp $outputF);
  981. return $name;
  982. }
  983. sub BOSEST_playMessageStringArg {
  984. my ($name) = @_;
  985. my $hash = $main::defs{$name};
  986. BOSEST_playMessage($hash, "v1_".$hash->{helper}{tts}{fulltextmd5}, $hash->{helper}{tts}{volume}, $hash->{helper}{tts}{stopAfterSpeak});
  987. return undef;
  988. }
  989. sub BOSEST_playMessage($$$$) {
  990. my ($hash, $trackname, $volume, $stopAfterSpeak) = @_;
  991. Log3 $hash, 4, "BOSEST: playMessage $trackname, $volume, $stopAfterSpeak";
  992. BOSEST_saveCurrentState($hash);
  993. if($volume ne ReadingsVal($hash->{NAME}, "volume", 0)) {
  994. BOSEST_pause($hash);
  995. BOSEST_setVolume($hash, $volume);
  996. }
  997. BOSEST_playTrack($hash, $trackname);
  998. $hash->{helper}{stateCheck}{enabled} = 1;
  999. #after play the speaker changes contentItemItemName
  1000. $hash->{helper}{stateCheck}{actionContentItemItemName} = $trackname;
  1001. #check if we need to stop after speak
  1002. if(defined($stopAfterSpeak) && $stopAfterSpeak eq "1") {
  1003. $hash->{helper}{stateCheck}{function} = \&BOSEST_restoreVolumeAndOff;
  1004. } else {
  1005. $hash->{helper}{stateCheck}{function} = \&BOSEST_restoreSavedState;
  1006. }
  1007. return undef;
  1008. }
  1009. sub BOSEST_deleteOldTTSFiles {
  1010. my ($hash) = @_;
  1011. my ($err, $val) = getKeyValue("BOSEST_tts_files");
  1012. my @ttsFiles = split(",", $val);
  1013. my $ttsDir = AttrVal($hash->{NAME}, "ttsDirectory", "");
  1014. return undef if($ttsDir eq "");
  1015. InternalTimer(gettimeofday()+86500, "BOSEST_deleteOldTTSFiles", $hash, 0);
  1016. foreach my $ttsFile (@ttsFiles) {
  1017. ($err, $val) = getKeyValue($ttsFile);
  1018. my $now = gettimeofday();
  1019. if($now - $val > 2592000) {
  1020. #delete file
  1021. unlink $ttsDir."/".$ttsFile.".mp3";;
  1022. #remove $ttsFile from BOSEST_tts_files array
  1023. @ttsFiles = grep { $_ != $ttsFile } @ttsFiles;
  1024. #remove key
  1025. $err = setKeyValue($ttsFile, undef);
  1026. }
  1027. }
  1028. $err = setKeyValue("BOSEST_tts_files", join(",", @ttsFiles));
  1029. }
  1030. sub BOSEST_playGoogleTTS {
  1031. my ($hash, $ttsDir, $text, $volume, $lang, $stopAfterSpeak) = @_;
  1032. $hash->{helper}{tts}{volume} = $volume;
  1033. $hash->{helper}{tts}{stopAfterSpeak} = $stopAfterSpeak;
  1034. $hash->{helper}{tts}{fulltextmd5} = md5_hex($lang.$text);
  1035. my $filename = $ttsDir."/v1_".$hash->{helper}{tts}{fulltextmd5}.".mp3";
  1036. if(-f $filename) {
  1037. my $timestamp = (stat($filename))->mtime(); #last modification timestamp
  1038. my $now = time();
  1039. if($now-$timestamp < 2592000) {
  1040. #file is not older than 30 days
  1041. Log3 $hash, 5, "BOSEST: File $filename found. No new download required.";
  1042. BOSEST_playMessageStringArg($hash->{NAME});
  1043. return undef;
  1044. }
  1045. }
  1046. my @sentences = split (/(?<=[.?!])/, $text);
  1047. $hash->{helper}{tts}{downloads}{all} = "";
  1048. foreach my $sentence (@sentences) {
  1049. my $md5 = md5_hex($lang.$sentence);
  1050. $hash->{helper}{tts}{downloads}{$md5} = 0;
  1051. $hash->{helper}{tts}{downloads}{all} .= $md5.",";
  1052. BOSEST_downloadGoogleTTS($hash, $ttsDir, $sentence, $lang);
  1053. }
  1054. InternalTimer(gettimeofday()+1, "BOSEST_checkTTSDownloadFinished", $hash, 0);
  1055. return undef;
  1056. }
  1057. sub BOSEST_checkTTSDownloadFinished {
  1058. my ($hash) = @_;
  1059. my @allMd5 = split(",", $hash->{helper}{tts}{downloads}{all});
  1060. my $msgStatus = 1;
  1061. foreach my $md5 (@allMd5) {
  1062. if($hash->{helper}{tts}{downloads}{$md5} == 10) {
  1063. $msgStatus = 10;
  1064. } elsif($hash->{helper}{tts}{downloads}{$md5} == 0) {
  1065. $msgStatus = 0;
  1066. }
  1067. }
  1068. if($msgStatus == 10) {
  1069. if(AttrVal($hash->{NAME}, "ttsSpeakOnError", "1") eq "1") {
  1070. my $md5 = md5_hex($BOSEST_GOOGLE_NOT_AVAILABLE_LANG.$BOSEST_GOOGLE_NOT_AVAILABLE_TEXT);
  1071. BOSEST_playMessage($hash, $md5, $hash->{helper}{tts}{volume}, $hash->{helper}{tts}{stopAfterSpeak});
  1072. } else {
  1073. Log3 $hash, 3, "BOSEST: Google translate download failed.";
  1074. }
  1075. } elsif($msgStatus == 0) {
  1076. #check again in 1s
  1077. InternalTimer(gettimeofday()+1, "BOSEST_checkTTSDownloadFinished", $hash, 0);
  1078. } else {
  1079. BlockingCall("BOSEST_joinAudioFilesBlocking", $hash->{NAME}."|v1_".$hash->{helper}{tts}{fulltextmd5}.".mp3|BOSEST_silence.mp3|".join(".mp3|", @allMd5).".mp3", "BOSEST_playMessageStringArg");
  1080. }
  1081. return undef;
  1082. }
  1083. sub BOSEST_downloadGoogleTTS {
  1084. my ($hash, $ttsDir, $text, $lang) = @_;
  1085. BOSEST_downloadGoogleNotAvailable($hash);
  1086. my $md5 = md5_hex($lang.$text);
  1087. my $filename = $ttsDir."/".$md5.".mp3";
  1088. if(-f $filename) {
  1089. my $timestamp = (stat($filename))->mtime(); #last modification timestamp
  1090. my $now = time();
  1091. if($now-$timestamp < 2592000) {
  1092. #file is not older than 30 days
  1093. $hash->{helper}{tts}{downloads}{$md5} = 1;
  1094. return undef;
  1095. }
  1096. }
  1097. BOSEST_retrieveGooglTTSFile($hash, $filename, $md5, $text, $lang, sub {
  1098. my ($hash, $filename, $md5, $downloadOk) = @_;
  1099. if($downloadOk) {
  1100. my ($err, $val) = getKeyValue("BOSEST_tts_files");
  1101. if(!defined($val)) {
  1102. $val = "";
  1103. } else {
  1104. $val .= ",";
  1105. }
  1106. $err = setKeyValue("BOSEST_tts_files", $val.$md5);
  1107. $err = setKeyValue($md5, gettimeofday());
  1108. $hash->{helper}{tts}{downloads}{$md5} = 1;
  1109. #add silence and play message afterwards
  1110. } else {
  1111. $hash->{helper}{tts}{downloads}{$md5} = 10; #download error
  1112. }
  1113. });
  1114. return undef;
  1115. }
  1116. sub BOSEST_setMusicServiceAccount($$$) {
  1117. my ($hash, $friendlyName, $uuid) = @_;
  1118. my $postXml = '<credentials source="STORED_MUSIC" displayName="'.
  1119. $friendlyName.
  1120. '"><user>'.
  1121. $uuid.'/0'.
  1122. '</user><pass/></credentials>';
  1123. if(BOSEST_HTTPPOST($hash, '/setMusicServiceAccount', $postXml)) {
  1124. #ok
  1125. }
  1126. return undef;
  1127. }
  1128. sub BOSEST_removeMusicServiceAccount($$$) {
  1129. my ($hash, $friendlyName, $uuid) = @_;
  1130. my $postXml = '<credentials source="STORED_MUSIC" displayName="'.
  1131. $friendlyName.
  1132. '"><user>'.
  1133. $uuid.
  1134. '</user><pass/></credentials>';
  1135. if(BOSEST_HTTPPOST($hash, '/removeMusicServiceAccount', $postXml)) {
  1136. #ok
  1137. }
  1138. return undef;
  1139. }
  1140. sub BOSEST_playTrack($$) {
  1141. my ($hash, $trackName) = @_;
  1142. my $ttsDlnaServer = $hash->{helper}{ttsdlnaserver};
  1143. if(defined($ttsDlnaServer) && $ttsDlnaServer ne "") {
  1144. Log3 $hash, 4, "BOSEST: Search for $trackName on $ttsDlnaServer";
  1145. if(my $xmlTrack = BOSEST_searchTrack($hash, $ttsDlnaServer, $trackName)) {
  1146. BOSEST_setContentItem($hash,
  1147. $xmlTrack->{itemName},
  1148. $xmlTrack->{type},
  1149. $xmlTrack->{location},
  1150. $xmlTrack->{source},
  1151. $xmlTrack->{sourceAccount});
  1152. return undef;
  1153. }
  1154. }
  1155. foreach my $source (@{$hash->{helper}{sources}}) {
  1156. if($source->{source} eq "STORED_MUSIC" && $source->{status} eq "READY") {
  1157. Log3 $hash, 4, "BOSEST: Search for $trackName on $source->{sourceAccount}";
  1158. if(my $xmlTrack = BOSEST_searchTrack($hash, $source->{sourceAccount}, $trackName)) {
  1159. BOSEST_setContentItem($hash,
  1160. $xmlTrack->{itemName},
  1161. $xmlTrack->{type},
  1162. $xmlTrack->{location},
  1163. $xmlTrack->{source},
  1164. $xmlTrack->{sourceAccount});
  1165. $hash->{helper}{ttsdlnaserver} = $source->{sourceAccount};
  1166. last;
  1167. }
  1168. #sleep 100ms, otherwise internal server error from BOSE speaker
  1169. select(undef, undef, undef, 0.1);
  1170. }
  1171. }
  1172. return undef;
  1173. }
  1174. sub BOSEST_searchTrack($$$) {
  1175. my ($hash, $dlnaUid, $trackName) = @_;
  1176. my $postXml = '<search source="STORED_MUSIC" sourceAccount="'.
  1177. $dlnaUid.
  1178. '"><startItem>1</startItem><numItems>1</numItems><searchTerm filter="track">'.
  1179. $trackName.
  1180. '</searchTerm></search>';
  1181. if(my $xmlSearchResult = BOSEST_HTTPPOST($hash, '/search', $postXml)) {
  1182. #return first item from search results
  1183. if($xmlSearchResult->{searchResponse}->{items}) {
  1184. return $xmlSearchResult->{searchResponse}->{items}->{item}[0]->{ContentItem};
  1185. }
  1186. }
  1187. return undef;
  1188. }
  1189. ###### UPDATE VIA HTTP ######
  1190. sub BOSEST_updateClock($$) {
  1191. my ($hash, $deviceId) = @_;
  1192. my $clockDisplay = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/clockDisplay");
  1193. BOSEST_processXml($hash, $clockDisplay);
  1194. return undef;
  1195. }
  1196. sub BOSEST_updateInfo($$) {
  1197. my ($hash, $deviceId) = @_;
  1198. my $info = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/info");
  1199. BOSEST_processXml($hash, $info);
  1200. return undef;
  1201. }
  1202. sub BOSEST_updateSources($$) {
  1203. my ($hash, $deviceId) = @_;
  1204. my $sources = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/sources");
  1205. BOSEST_processXml($hash, $sources);
  1206. return undef;
  1207. }
  1208. sub BOSEST_updatePresets($$) {
  1209. my ($hash, $deviceId) = @_;
  1210. my $presets = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/presets");
  1211. BOSEST_processXml($hash, $presets);
  1212. return undef;
  1213. }
  1214. sub BOSEST_updateZone($$) {
  1215. my ($hash, $deviceId) = @_;
  1216. my $zone = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/getZone");
  1217. BOSEST_processXml($hash, $zone);
  1218. return undef;
  1219. }
  1220. sub BOSEST_updateVolume($$) {
  1221. my ($hash, $deviceId) = @_;
  1222. my $volume = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/volume");
  1223. BOSEST_processXml($hash, $volume);
  1224. return undef;
  1225. }
  1226. sub BOSEST_updateBass($$) {
  1227. my ($hash, $deviceId) = @_;
  1228. my $bass = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/bass");
  1229. BOSEST_processXml($hash, $bass);
  1230. return undef;
  1231. }
  1232. sub BOSEST_updateNowPlaying($$) {
  1233. my ($hash, $deviceId) = @_;
  1234. my $nowPlaying = BOSEST_HTTPGET($hash, $hash->{helper}{IP}, "/now_playing");
  1235. BOSEST_processXml($hash, $nowPlaying);
  1236. return undef;
  1237. }
  1238. sub BOSEST_updateAutoZone {
  1239. my ($hash, $location) = @_;
  1240. return undef if($location eq "");
  1241. return undef if(AttrVal($hash->{NAME}, "autoZone", "off") eq "off");
  1242. my @allPlayers = BOSEST_getAllBosePlayers($hash);
  1243. my $newZoneMaster;
  1244. my $createZone = 0;
  1245. foreach my $playerHash (@allPlayers) {
  1246. next if($playerHash->{DEVICEID} eq $hash->{DEVICEID});
  1247. my $playerLocation = ReadingsVal($playerHash->{NAME}, "contentItemLocation", "");
  1248. my $playerZoneMaster = ReadingsVal($playerHash->{NAME}, "zoneMaster", "");
  1249. Log3 $hash, 5, "BOSEST: auto-zone $hash->{NAME}: $location = $playerHash->{NAME}: $playerLocation?";
  1250. #make sure that $playerHash is master device
  1251. if($playerLocation eq $location && ($playerZoneMaster eq "" or $playerZoneMaster eq $playerHash->{DEVICEID})) {
  1252. #TODO: check if createZone is needed
  1253. $newZoneMaster = $playerHash;
  1254. $createZone = 1 if($playerZoneMaster eq "");
  1255. }
  1256. }
  1257. if($newZoneMaster) {
  1258. if($createZone) {
  1259. BOSEST_createZone($newZoneMaster, $hash->{DEVICEID});
  1260. } else {
  1261. BOSEST_addToZone($newZoneMaster, $hash->{DEVICEID});
  1262. }
  1263. }
  1264. }
  1265. sub BOSEST_checkDoubleTap($$) {
  1266. my ($hash, $channel) = @_;
  1267. return undef if($channel eq "" or $channel eq "0");
  1268. if(!defined($hash->{helper}{dt_nowSelectionUpdatedTS}) or $channel ne $hash->{helper}{dt_nowSelectionUpdatedCH}) {
  1269. $hash->{helper}{dt_nowSelectionUpdatedTS} = gettimeofday();
  1270. $hash->{helper}{dt_nowSelectionUpdatedCH} = $channel;
  1271. $hash->{helper}{dt_lastChange} = 0;
  1272. $hash->{helper}{dt_counter} = 1;
  1273. return undef;
  1274. }
  1275. my $timeDiff = gettimeofday() - $hash->{helper}{dt_nowSelectionUpdatedTS};
  1276. if($timeDiff < 1) {
  1277. $hash->{helper}{dt_counter}++;
  1278. if($hash->{helper}{dt_counter} == 2) {
  1279. if(ReadingsVal($hash->{NAME}, "zoneMaster", "") eq $hash->{DEVICEID}) {
  1280. BOSEST_stopPlayEverywhere($hash);
  1281. $hash->{helper}{dt_lastChange} = gettimeofday();
  1282. } elsif(ReadingsVal($hash->{NAME}, "zoneMaster", "") eq "") {
  1283. #make sure that play isn't started just after stop, that might confuse the player
  1284. my $timeDiffMasterChange = gettimeofday() - $hash->{helper}{dt_lastChange};
  1285. if($timeDiffMasterChange > 2) {
  1286. BOSEST_playEverywhere($hash);
  1287. $hash->{helper}{dt_lastChange} = gettimeofday();
  1288. }
  1289. }
  1290. } elsif($hash->{helper}{dt_counter} == 3) {
  1291. #handle three-tap function - ideas?
  1292. }
  1293. } else {
  1294. $hash->{helper}{dt_counter} = 1;
  1295. }
  1296. $hash->{helper}{dt_nowSelectionUpdatedTS} = gettimeofday();
  1297. return undef;
  1298. }
  1299. ###### XML PROCESSING ######
  1300. sub BOSEST_processXml($$) {
  1301. my ($hash, $wsxml) = @_;
  1302. Log3 $hash, 5, "BOSEST: processXml:\n".Dumper($wsxml);
  1303. if($wsxml->{updates}) {
  1304. if($wsxml->{updates}->{nowPlayingUpdated}) {
  1305. if($wsxml->{updates}->{nowPlayingUpdated}->{nowPlaying}) {
  1306. BOSEST_parseAndUpdateNowPlaying($hash, $wsxml->{updates}->{nowPlayingUpdated}->{nowPlaying});
  1307. if($hash->{helper}{switchSource} ne "") {
  1308. BOSEST_setSource($hash, $hash->{helper}{switchSource});
  1309. } else {
  1310. BOSEST_speakChannel($hash);
  1311. }
  1312. }
  1313. } elsif ($wsxml->{updates}->{volumeUpdated}) {
  1314. BOSEST_parseAndUpdateVolume($hash, $wsxml->{updates}->{volumeUpdated}->{volume});
  1315. } elsif ($wsxml->{updates}->{nowSelectionUpdated}) {
  1316. BOSEST_parseAndUpdateChannel($hash, $wsxml->{updates}->{nowSelectionUpdated}->{preset}[0]);
  1317. BOSEST_checkDoubleTap($hash, $wsxml->{updates}->{nowSelectionUpdated}->{preset}[0]->{id});
  1318. } elsif ($wsxml->{updates}->{recentsUpdated}) {
  1319. BOSEST_parseAndUpdateRecents($hash, $wsxml->{updates}->{recentsUpdated}->{recents});
  1320. } elsif ($wsxml->{updates}->{connectionStateUpdated}) {
  1321. #BOSE SoundTouch team says that it's not necessary to handle this one
  1322. } elsif ($wsxml->{updates}->{clockDisplayUpdated}) {
  1323. #TODO handle clockDisplayUpdated (feature currently unknown)
  1324. } elsif ($wsxml->{updates}->{presetsUpdated}) {
  1325. BOSEST_parseAndUpdatePresets($hash, $wsxml->{updates}->{presetsUpdated}->{presets});
  1326. } elsif ($wsxml->{updates}->{zoneUpdated}) {
  1327. #zoneUpdated is just a notification with no data
  1328. BOSEST_updateZone($hash, $hash->{DEVICEID});
  1329. } elsif ($wsxml->{updates}->{bassUpdated}) {
  1330. #bassUpdated is just a notification with no data
  1331. BOSEST_updateBass($hash, $hash->{DEVICEID});
  1332. } elsif ($wsxml->{updates}->{infoUpdated}) {
  1333. #infoUpdated is just a notification with no data
  1334. BOSEST_updateInfo($hash, $hash->{DEVICEID});
  1335. } elsif ($wsxml->{updates}->{sourcesUpdated}) {
  1336. #sourcesUpdated is just a notification with no data
  1337. BOSEST_updateSources($hash, $hash->{DEVICEID});
  1338. } elsif ($wsxml->{updates}->{clockTimeUpdated}) {
  1339. BOSEST_parseAndUpdateClock($hash, $wsxml->{updates}->{clockTimeUpdated});
  1340. } else {
  1341. Log3 $hash, 4, "BOSEST: Unknown event, please implement:\n".Dumper($wsxml);
  1342. }
  1343. } elsif($wsxml->{info}) {
  1344. BOSEST_parseAndUpdateInfo($hash, $wsxml->{info});
  1345. } elsif($wsxml->{nowPlaying}) {
  1346. BOSEST_parseAndUpdateNowPlaying($hash, $wsxml->{nowPlaying});
  1347. } elsif($wsxml->{volume}) {
  1348. BOSEST_parseAndUpdateVolume($hash, $wsxml->{volume});
  1349. } elsif($wsxml->{presets}) {
  1350. BOSEST_parseAndUpdatePresets($hash, $wsxml->{presets});
  1351. } elsif($wsxml->{bass}) {
  1352. BOSEST_parseAndUpdateBass($hash, $wsxml->{bass});
  1353. } elsif($wsxml->{zone}) {
  1354. BOSEST_parseAndUpdateZone($hash, $wsxml->{zone});
  1355. } elsif($wsxml->{sources}) {
  1356. BOSEST_parseAndUpdateSources($hash, $wsxml->{sources}->{sourceItem});
  1357. } elsif($wsxml->{msg}) {
  1358. if($wsxml->{msg}->{body} && $wsxml->{msg}->{body}->{pingRequest}) {
  1359. #pingpong
  1360. } else {
  1361. Log3 $hash, 4, "BOSEST: Unknown event, please implement:\n".Dumper($wsxml);
  1362. }
  1363. } else {
  1364. Log3 $hash, 4, "BOSEST: Unknown event, please implement:\n".Dumper($wsxml);
  1365. }
  1366. if($hash->{helper}{stateCheck}{enabled}) {
  1367. #check if state is action state
  1368. if(ReadingsVal($hash->{NAME}, "contentItemItemName", "") eq $hash->{helper}{stateCheck}{actionContentItemItemName}) {
  1369. $hash->{helper}{stateCheck}{actionActive} = 1;
  1370. } else {
  1371. if($hash->{helper}{stateCheck}{actionActive}) {
  1372. if(ReadingsVal($hash->{NAME}, "contentItemItemName", "") ne $hash->{helper}{stateCheck}{actionContentItemItemName}) {
  1373. #call function with $hash as argument
  1374. $hash->{helper}{stateCheck}{function}->($hash);
  1375. $hash->{helper}{stateCheck}{enabled} = 0;
  1376. $hash->{helper}{stateCheck}{actionActive} = 0;
  1377. }
  1378. }
  1379. }
  1380. }
  1381. return undef;
  1382. }
  1383. sub BOSEST_parseAndUpdateClock($$) {
  1384. my ($hash, $clock) = @_;
  1385. if($clock->{clockTime}->{brightness} eq "0") {
  1386. readingsSingleUpdate($hash, "clockDisplay", "off", 1);
  1387. } else {
  1388. readingsSingleUpdate($hash, "clockDisplay", "on", 1);
  1389. }
  1390. return undef;
  1391. }
  1392. sub BOSEST_parseAndUpdateSources($$) {
  1393. my ($hash, $sourceItems) = @_;
  1394. $hash->{helper}->{sources} = ();
  1395. foreach my $sourceItem (@{$sourceItems}) {
  1396. Log3 $hash, 5, "BOSEST: Add $sourceItem->{source}";
  1397. #save source information
  1398. # - source (BLUETOOTH, STORED_MUSIC, ...)
  1399. # - sourceAccount
  1400. # - status
  1401. # - isLocal
  1402. # - name
  1403. $sourceItem->{isLocal} = "" if(!defined($sourceItem->{isLocal}));
  1404. $sourceItem->{sourceAccount} = "" if(!defined($sourceItem->{sourceAccount}));
  1405. $sourceItem->{sourceAccount} = "" if(!defined($sourceItem->{sourceAccount}));
  1406. my %source = (source => $sourceItem->{source},
  1407. sourceAccount => $sourceItem->{sourceAccount},
  1408. status => $sourceItem->{status},
  1409. isLocal => $sourceItem->{isLocal},
  1410. name => $sourceItem->{content});
  1411. push @{$hash->{helper}->{sources}}, \%source;
  1412. }
  1413. my $connectedDlnaServers = "";
  1414. foreach my $sourceItem (@{ $hash->{helper}->{sources} }) {
  1415. if($sourceItem->{source} eq "STORED_MUSIC") {
  1416. $connectedDlnaServers .= $sourceItem->{name}.",";
  1417. }
  1418. }
  1419. #remove last comma
  1420. $connectedDlnaServers = substr($connectedDlnaServers, 0, length($connectedDlnaServers) - 1);
  1421. #replace blank with hyphen
  1422. $connectedDlnaServers =~ s/\ /_/g;
  1423. readingsSingleUpdate($hash, "connectedDLNAServers", $connectedDlnaServers, 1);
  1424. return undef;
  1425. }
  1426. sub BOSEST_parseAndUpdateChannel($$) {
  1427. my ($hash, $preset) = @_;
  1428. readingsBeginUpdate($hash);
  1429. if($preset->{id} ne "0") {
  1430. BOSEST_XMLUpdate($hash, "channel", $preset->{id});
  1431. } else {
  1432. BOSEST_XMLUpdate($hash, "channel", "");
  1433. $preset->{ContentItem}->{itemName} = "" if(!defined($preset->{ContentItem}->{itemName}));
  1434. $preset->{ContentItem}->{location} = "" if(!defined($preset->{ContentItem}->{location}));
  1435. $preset->{ContentItem}->{source} = "" if(!defined($preset->{ContentItem}->{source}));
  1436. $preset->{ContentItem}->{sourceAccount} = "" if(!defined($preset->{ContentItem}->{sourceAccount}));
  1437. my $channelString = $preset->{ContentItem}->{itemName}."|".$preset->{ContentItem}->{location}."|".
  1438. $preset->{ContentItem}->{source}."|".$preset->{ContentItem}->{sourceAccount};
  1439. foreach my $channelNr (7..20) {
  1440. my $channelVal = AttrVal($hash->{NAME}, sprintf("channel_%02d", $channelNr), "0");
  1441. if($channelVal eq $channelString) {
  1442. BOSEST_XMLUpdate($hash, "channel", $channelNr);
  1443. }
  1444. }
  1445. }
  1446. readingsEndUpdate($hash, 1);
  1447. return undef;
  1448. }
  1449. sub BOSEST_parseAndUpdateZone($$) {
  1450. my ($hash, $zone) = @_;
  1451. #only update zone from master
  1452. if(defined($zone->{master})) {
  1453. my $masterHash = BOSEST_getBosePlayerByDeviceId($hash, $zone->{master});
  1454. if($masterHash->{DEVICEID} ne $hash->{DEVICEID}) {
  1455. return undef;
  1456. }
  1457. }
  1458. my $i = 1;
  1459. readingsBeginUpdate($hash);
  1460. BOSEST_XMLUpdate($hash, "zoneMaster", $zone->{master});
  1461. readingsEndUpdate($hash, 1);
  1462. if($zone->{member}) {
  1463. foreach my $member (@{$zone->{member}}) {
  1464. my $player = BOSEST_getBosePlayerByDeviceId($hash, $member->{content});
  1465. readingsBeginUpdate($hash);
  1466. BOSEST_XMLUpdate($hash, "zoneMember_$i", $player->{DEVICEID});
  1467. readingsEndUpdate($hash, 1);
  1468. readingsBeginUpdate($player);
  1469. BOSEST_XMLUpdate($player, "zoneMaster", $zone->{master});
  1470. readingsEndUpdate($player, 1);
  1471. $i++;
  1472. }
  1473. my $memberCnt = $i - 1;
  1474. foreach my $member (@{$zone->{member}}) {
  1475. my $player = BOSEST_getBosePlayerByDeviceId($hash, $member->{content});
  1476. readingsBeginUpdate($player);
  1477. foreach my $cnt ($memberCnt..1) {
  1478. BOSEST_XMLUpdate($player, "zoneMember_$cnt", ReadingsVal($hash->{NAME}, "zoneMember_$cnt", ""));
  1479. }
  1480. readingsEndUpdate($player, 1);
  1481. }
  1482. }
  1483. while ($i < 20) {
  1484. if(defined($hash->{READINGS}{"zoneMember_$i"})) {
  1485. my $zoneMemberUdn = ReadingsVal($hash->{NAME}, "zoneMember_$i", "");
  1486. if($zoneMemberUdn ne "") {
  1487. my $memberHash = BOSEST_getBosePlayerByDeviceId($hash, $zoneMemberUdn);
  1488. readingsBeginUpdate($memberHash);
  1489. BOSEST_XMLUpdate($memberHash, "zoneMaster", "");
  1490. my $j = 1;
  1491. while($j < 20) {
  1492. BOSEST_XMLUpdate($memberHash, "zoneMember_$j", "") if(defined($hash->{READINGS}{"zoneMember_$j"}));
  1493. $j++;
  1494. }
  1495. readingsEndUpdate($memberHash, 1);
  1496. }
  1497. readingsBeginUpdate($hash);
  1498. BOSEST_XMLUpdate($hash, "zoneMember_$i", "");
  1499. readingsEndUpdate($hash, 1);
  1500. }
  1501. $i++;
  1502. }
  1503. return undef;
  1504. }
  1505. sub BOSEST_parseAndUpdatePresets($$) {
  1506. my ($hash, $presets) = @_;
  1507. my $maxpresets = 6;
  1508. my %activePresets = ();
  1509. readingsBeginUpdate($hash);
  1510. foreach my $preset (1..6) {
  1511. $activePresets{$preset} = "-";
  1512. }
  1513. foreach my $preset (@{ $presets->{preset} }) {
  1514. $activePresets{$preset->{id}} = $preset->{ContentItem}->{itemName};
  1515. }
  1516. foreach my $preset (1..6) {
  1517. BOSEST_XMLUpdate($hash, sprintf("channel_%02d", $preset), $activePresets{$preset});
  1518. }
  1519. readingsEndUpdate($hash, 1);
  1520. return undef;
  1521. }
  1522. sub BOSEST_parseAndUpdateRecents($$) {
  1523. my ($hash, $recents) = @_;
  1524. my $i = 1;
  1525. readingsBeginUpdate($hash);
  1526. foreach my $recentEntry (@{$recents->{recent}}) {
  1527. BOSEST_XMLUpdate($hash, sprintf("recent_%02d", $i), $recentEntry->{contentItem}->{itemName});
  1528. $hash->{helper}{recents}{$i}{location} = $recentEntry->{contentItem}->{location};
  1529. $hash->{helper}{recents}{$i}{source} = $recentEntry->{contentItem}->{source};
  1530. $hash->{helper}{recents}{$i}{sourceAccount} = $recentEntry->{contentItem}->{sourceAccount};
  1531. $hash->{helper}{recents}{$i}{itemName} = $recentEntry->{contentItem}->{itemName};
  1532. $i++;
  1533. last if($i > 15);
  1534. }
  1535. foreach my $x ($i..15) {
  1536. BOSEST_XMLUpdate($hash, sprintf("recent_%02d", $x), "-");
  1537. delete $hash->{helper}{recents}{$x};
  1538. }
  1539. readingsEndUpdate($hash, 1);
  1540. return undef;
  1541. }
  1542. sub BOSEST_parseAndUpdateVolume($$) {
  1543. my ($hash, $volume) = @_;
  1544. readingsBeginUpdate($hash);
  1545. BOSEST_XMLUpdate($hash, "volume", $volume->{actualvolume});
  1546. BOSEST_XMLUpdate($hash, "mute", $volume->{muteenabled});
  1547. readingsEndUpdate($hash, 1);
  1548. return undef;
  1549. }
  1550. sub BOSEST_parseAndUpdateBass($$) {
  1551. my ($hash, $bass) = @_;
  1552. my $currBass = $bass->{actualbass} + 10;
  1553. readingsBeginUpdate($hash);
  1554. BOSEST_XMLUpdate($hash, "bass", $currBass);
  1555. readingsEndUpdate($hash, 1);
  1556. return undef;
  1557. }
  1558. sub BOSEST_parseAndUpdateInfo($$) {
  1559. my ($hash, $info) = @_;
  1560. $info->{name} = Encode::encode('UTF-8', $info->{name});
  1561. readingsBeginUpdate($hash);
  1562. BOSEST_XMLUpdate($hash, "deviceName", $info->{name});
  1563. BOSEST_XMLUpdate($hash, "type", $info->{type});
  1564. BOSEST_XMLUpdate($hash, "deviceID", $info->{deviceID});
  1565. BOSEST_XMLUpdate($hash, "softwareVersion", $info->{components}->{component}[0]->{softwareVersion});
  1566. readingsEndUpdate($hash, 1);
  1567. return undef;
  1568. }
  1569. sub BOSEST_parseAndUpdateNowPlaying($$) {
  1570. my ($hash, $nowPlaying) = @_;
  1571. Log3 $hash, 5, "BOSEST: parseAndUpdateNowPlaying";
  1572. readingsBeginUpdate($hash);
  1573. BOSEST_XMLUpdate($hash, "stationName", $nowPlaying->{stationName});
  1574. BOSEST_XMLUpdate($hash, "track", $nowPlaying->{track});
  1575. BOSEST_XMLUpdate($hash, "source", $nowPlaying->{source});
  1576. BOSEST_XMLUpdate($hash, "album", $nowPlaying->{album});
  1577. BOSEST_XMLUpdate($hash, "artist", $nowPlaying->{artist});
  1578. BOSEST_XMLUpdate($hash, "playStatus", $nowPlaying->{playStatus});
  1579. BOSEST_XMLUpdate($hash, "stationLocation", $nowPlaying->{stationLocation});
  1580. BOSEST_XMLUpdate($hash, "trackID", $nowPlaying->{trackID});
  1581. BOSEST_XMLUpdate($hash, "artistID", $nowPlaying->{artistID});
  1582. BOSEST_XMLUpdate($hash, "rating", $nowPlaying->{rating});
  1583. BOSEST_XMLUpdate($hash, "description", $nowPlaying->{description});
  1584. BOSEST_XMLUpdate($hash, "shuffle", $nowPlaying->{shuffleSetting});
  1585. BOSEST_XMLUpdate($hash, "repeat", $nowPlaying->{repeatSetting});
  1586. if($nowPlaying->{time}) {
  1587. BOSEST_XMLUpdate($hash, "time", $nowPlaying->{time}->{content});
  1588. BOSEST_XMLUpdate($hash, "timeTotal", $nowPlaying->{time}->{total});
  1589. } else {
  1590. BOSEST_XMLUpdate($hash, "time", "");
  1591. BOSEST_XMLUpdate($hash, "timeTotal", "");
  1592. }
  1593. if($nowPlaying->{art}) {
  1594. BOSEST_XMLUpdate($hash, "art", $nowPlaying->{art}->{content});
  1595. BOSEST_XMLUpdate($hash, "artStatus", $nowPlaying->{art}->{artImageStatus});
  1596. } else {
  1597. BOSEST_XMLUpdate($hash, "art", "");
  1598. BOSEST_XMLUpdate($hash, "artStatus", "");
  1599. }
  1600. if($nowPlaying->{ContentItem}) {
  1601. BOSEST_XMLUpdate($hash, "contentItemItemName", $nowPlaying->{ContentItem}->{itemName});
  1602. BOSEST_XMLUpdate($hash, "contentItemLocation", $nowPlaying->{ContentItem}->{location});
  1603. BOSEST_XMLUpdate($hash, "contentItemSourceAccount", $nowPlaying->{ContentItem}->{sourceAccount});
  1604. BOSEST_XMLUpdate($hash, "contentItemSource", $nowPlaying->{ContentItem}->{source});
  1605. BOSEST_XMLUpdate($hash, "contentItemIsPresetable", $nowPlaying->{ContentItem}->{isPresetable});
  1606. BOSEST_XMLUpdate($hash, "contentItemType", $nowPlaying->{ContentItem}->{type});
  1607. #TODO
  1608. #if location is the same as on other speaker, start auto-zone
  1609. BOSEST_updateAutoZone($hash, ReadingsVal($hash->{NAME}, "contentItemLocation", 1));
  1610. } else {
  1611. BOSEST_XMLUpdate($hash, "contentItemItemName", "");
  1612. BOSEST_XMLUpdate($hash, "contentItemLocation", "");
  1613. BOSEST_XMLUpdate($hash, "contentItemSourceAccount", "");
  1614. BOSEST_XMLUpdate($hash, "contentItemSource", "");
  1615. BOSEST_XMLUpdate($hash, "contentItemIsPresetable", "");
  1616. BOSEST_XMLUpdate($hash, "contentItemType", "");
  1617. }
  1618. if($nowPlaying->{connectionStatusInfo}) {
  1619. BOSEST_XMLUpdate($hash, "connectionStatusInfo", $nowPlaying->{connectionStatusInfo}->{status});
  1620. } else {
  1621. BOSEST_XMLUpdate($hash, "connectionStatusInfo", "");
  1622. }
  1623. #handle state based on play status and standby state
  1624. if($nowPlaying->{source} eq "STANDBY") {
  1625. BOSEST_XMLUpdate($hash, "state", "online");
  1626. } else {
  1627. if(defined($nowPlaying->{playStatus})) {
  1628. if($nowPlaying->{playStatus} eq "BUFFERING_STATE") {
  1629. BOSEST_XMLUpdate($hash, "state", "buffering");
  1630. } elsif($nowPlaying->{playStatus} eq "PLAY_STATE") {
  1631. BOSEST_XMLUpdate($hash, "state", "playing");
  1632. } elsif($nowPlaying->{playStatus} eq "STOP_STATE") {
  1633. BOSEST_XMLUpdate($hash, "state", "stopped");
  1634. } elsif($nowPlaying->{playStatus} eq "PAUSE_STATE") {
  1635. BOSEST_XMLUpdate($hash, "state", "paused");
  1636. } elsif($nowPlaying->{playStatus} eq "INVALID_PLAY_STATUS") {
  1637. BOSEST_XMLUpdate($hash, "state", "invalid");
  1638. }
  1639. }
  1640. }
  1641. #reset sent_off/on to enable the command again
  1642. #it's not allowed to send 2 times off/on due to toggle
  1643. #therefore I'm waiting for one signal to be
  1644. #received via websocket
  1645. $hash->{helper}{sent_off} = 0;
  1646. $hash->{helper}{sent_on} = 0;
  1647. readingsEndUpdate($hash, 1);
  1648. return undef;
  1649. }
  1650. ###### DISCOVERY #######
  1651. sub BOSEST_startDiscoveryProcess($) {
  1652. my ($hash) = @_;
  1653. if(!$init_done) {
  1654. #init not done yet, wait 3 more seconds
  1655. InternalTimer(gettimeofday()+3, "BOSEST_startDiscoveryProcess", $hash, 0);
  1656. }
  1657. if (!defined($hash->{helper}{DISCOVERY_PID})) {
  1658. $hash->{helper}{DISCOVERY_PID} = BlockingCall("BOSEST_Discovery", $hash->{NAME}."|".$hash, "BOSEST_finishedDiscovery");
  1659. }
  1660. }
  1661. sub BOSEST_handleDeviceByIp {
  1662. my ($hash, $ip) = @_;
  1663. my $return = "";
  1664. my $info = BOSEST_HTTPGET($hash, $ip, "/info");
  1665. #remove info tag to reduce line length
  1666. $info = $info->{info} if (defined($info->{info}));
  1667. #skip entry if no deviceid was found
  1668. return "" if (!defined($info->{deviceID}));
  1669. #TODO return if the device is already defined and IP is the same
  1670. # make sure that this can be done and no further code below is needed
  1671. #create new device if it doesn't exist
  1672. if(!defined(BOSEST_getBosePlayerByDeviceId($hash, $info->{deviceID}))) {
  1673. $info->{name} = Encode::encode('UTF-8',$info->{name});
  1674. Log3 $hash, 3, "BOSEST: Device $info->{name} ($info->{deviceID}) found.";
  1675. $return = $return."|commandDefineBOSE|$info->{deviceID},$info->{name}";
  1676. #set supported capabilities
  1677. my $capabilities = BOSEST_HTTPGET($hash, $ip, "/capabilities");
  1678. $return .= "|capabilities|$info->{deviceID}";
  1679. if($capabilities->{capabilities}->{clockDisplay}) {
  1680. $return .= ",".$capabilities->{capabilities}->{clockDisplay};
  1681. } else {
  1682. $return .= ",false";
  1683. }
  1684. #set supported bass capabilities
  1685. my $bassCapabilities = BOSEST_HTTPGET($hash, $ip, "/bassCapabilities");
  1686. $return .= "|bassCapabilities|$info->{deviceID}";
  1687. if($bassCapabilities->{bassCapabilities}) {
  1688. my $bassCap = $bassCapabilities->{bassCapabilities};
  1689. $return .= ",".$bassCap->{bassAvailable}.",".$bassCap->{bassMin}.",".
  1690. $bassCap->{bassMax}.",".$bassCap->{bassDefault};
  1691. }
  1692. }
  1693. #TODO create own function (add own DLNA server)
  1694. my $myIp = BOSEST_getMyIp($hash);
  1695. my $listMediaServers = BOSEST_HTTPGET($hash, $ip, "/listMediaServers");
  1696. #set supported sources
  1697. my $sources = BOSEST_HTTPGET($hash, $ip, "/sources");
  1698. $return .= "|supportedSources|$info->{deviceID}";
  1699. foreach my $source (@{ $sources->{sources}->{sourceItem} }) {
  1700. $return .= ",".$source->{source};
  1701. }
  1702. my $returnListMediaServers = "|listMediaServers|".$info->{deviceID};
  1703. foreach my $mediaServer (@{ $listMediaServers->{ListMediaServersResponse}->{media_server} }) {
  1704. $returnListMediaServers .= ",".$mediaServer->{friendly_name};
  1705. #check if it is already connected
  1706. my $isConnected = 0;
  1707. foreach my $source (@{ $sources->{sources}->{sourceItem} }) {
  1708. next if($source->{source} ne "STORED_MUSIC");
  1709. if(substr($source->{sourceAccount}, 0, length($mediaServer->{id})) eq $mediaServer->{id}) {
  1710. #already connected
  1711. $isConnected = 1;
  1712. next;
  1713. }
  1714. }
  1715. next if($isConnected);
  1716. if(($myIp eq $mediaServer->{ip}) ||
  1717. (AttrVal($hash->{NAME}, "autoAddDLNAServers", "0") eq "1" )) {
  1718. $return = $return."|setMusicServiceAccount|".$info->{deviceID}.",".$mediaServer->{friendly_name}.",".$mediaServer->{id};
  1719. Log3 $hash, 3, "BOSEST: DLNA Server ".$mediaServer->{friendly_name}." added.";
  1720. }
  1721. }
  1722. #append listMediaServers
  1723. $return .= $returnListMediaServers;
  1724. #update IP address of the device
  1725. $return = $return."|updateIP|".$info->{deviceID}.",".$ip;
  1726. return $return;
  1727. }
  1728. sub BOSEST_Discovery($) {
  1729. my ($string) = @_;
  1730. my ($name, $hash) = split("\\|", $string);
  1731. my $return = "$name";
  1732. $hash = $main::defs{$name};
  1733. eval {
  1734. my $res = Net::Bonjour->new('soundtouch');
  1735. $res->discover;
  1736. foreach my $device ($res->entries) {
  1737. $return .= BOSEST_handleDeviceByIp($hash, $device->address);
  1738. }
  1739. };
  1740. #update static players
  1741. my @staticIPs = split(",", AttrVal($hash->{NAME}, "staticIPs", ""));
  1742. foreach my $ip (@staticIPs) {
  1743. $return .= BOSEST_handleDeviceByIp($hash, $ip);
  1744. }
  1745. if($@) {
  1746. Log3 $hash, 3, "BOSEST: Discovery failed with: $@";
  1747. }
  1748. return $return;
  1749. }
  1750. sub BOSEST_finishedDiscovery($) {
  1751. my ($string) = @_;
  1752. my @commands = split("\\|", $string);
  1753. my $name = $commands[0];
  1754. my $hash = $defs{$name};
  1755. my $i = 0;
  1756. my $ignoreDeviceIDs = AttrVal($hash->{NAME}, "ignoreDeviceIDs", "");
  1757. delete($hash->{helper}{DISCOVERY_PID});
  1758. #start discovery again after 67s
  1759. InternalTimer(gettimeofday()+67, "BOSEST_startDiscoveryProcess", $hash, 1);
  1760. for($i = 1; $i < @commands; $i = $i+2) {
  1761. my $command = $commands[$i];
  1762. my @params = split(",", $commands[$i+1]);
  1763. my $deviceId = shift(@params);
  1764. next if($ignoreDeviceIDs =~ /$deviceId/);
  1765. if($command eq "commandDefineBOSE") {
  1766. my $deviceName = $params[0];
  1767. BOSEST_commandDefine($hash, $deviceId, $deviceName);
  1768. } elsif($command eq "updateIP") {
  1769. my $ip = $params[0];
  1770. BOSEST_updateIP($hash, $deviceId, $ip);
  1771. } elsif($command eq "setMusicServiceAccount") {
  1772. my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId);
  1773. #0...friendly name
  1774. #1...UUID
  1775. BOSEST_setMusicServiceAccount($deviceHash, $params[0], $params[1]);
  1776. } elsif($command eq "listMediaServers") {
  1777. my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId);
  1778. $deviceHash->{helper}{dlnaServers} = join(",", @params);
  1779. $deviceHash->{helper}{dlnaServers} =~ s/\ /_/g;
  1780. } elsif($command eq "bassCapabilities") {
  1781. my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId);
  1782. #bassAvailable, bassMin, bassMax, bassDefault
  1783. $deviceHash->{helper}{bassAvailable} = 1 if($params[0] eq "true");
  1784. $deviceHash->{helper}{bassMin} = $params[1];
  1785. $deviceHash->{helper}{bassMax} = $params[2];
  1786. $deviceHash->{helper}{bassDefault} = $params[3];
  1787. if($params[0] eq "true") {
  1788. $deviceHash->{helper}{supportedBassCmds} = "bass:slider,1,1,10";
  1789. } else {
  1790. $deviceHash->{helper}{supportedBassCmds} = "";
  1791. }
  1792. } elsif($command eq "supportedSources") {
  1793. my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId);
  1794. #list of supported sources
  1795. $deviceHash->{helper}{bluetoothSupport} = 0;
  1796. $deviceHash->{helper}{auxSupport} = 0;
  1797. $deviceHash->{helper}{airplaySupport} = 0;
  1798. $deviceHash->{helper}{supportedSourcesCmds} = "";
  1799. foreach my $source (@params) {
  1800. if($source eq "BLUETOOTH") {
  1801. $deviceHash->{helper}{bluetoothSupport} = 1;
  1802. $deviceHash->{helper}{supportedSourcesCmds} .= "bluetooth,bt-discover,";
  1803. } elsif($source eq "AUX") {
  1804. $deviceHash->{helper}{auxSupport} = 1;
  1805. $deviceHash->{helper}{supportedSourcesCmds} .= "aux,";
  1806. } elsif($source eq "AIRPLAY") {
  1807. $deviceHash->{helper}{airplaySupport} = 1;
  1808. $deviceHash->{helper}{supportedSourcesCmds} .= "airplay,";
  1809. }
  1810. }
  1811. $deviceHash->{helper}{supportedSourcesCmds} = substr($deviceHash->{helper}{supportedSourcesCmds}, 0, length($deviceHash->{helper}{supportedSourcesCmds})-1);
  1812. } elsif($command eq "capabilities") {
  1813. my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceId);
  1814. if(ReadingsVal($deviceHash->{NAME}, "supportClockDisplay", "") ne $params[0]) {
  1815. readingsSingleUpdate($deviceHash, "supportClockDisplay", $params[0], 1);
  1816. }
  1817. }
  1818. }
  1819. }
  1820. sub BOSEST_updateIP($$$) {
  1821. my ($hash, $deviceID, $ip) = @_;
  1822. my $deviceHash = BOSEST_getBosePlayerByDeviceId($hash, $deviceID);
  1823. #check current IP of the device
  1824. my $currentIP = $deviceHash->{helper}{IP};
  1825. $currentIP = "unknown" if(!defined($currentIP));
  1826. #if update is needed, get info/now_playing
  1827. if($currentIP ne $ip) {
  1828. $deviceHash->{helper}{IP} = $ip;
  1829. readingsSingleUpdate($deviceHash, "IP", $ip, 1);
  1830. readingsSingleUpdate($deviceHash, "presence", "online", 1);
  1831. Log3 $hash, 3, "BOSEST: $deviceHash->{NAME}, new IP ($ip)";
  1832. #get info
  1833. Log3 $hash, 5, "BOSEST: BOSEST_updateInfo";
  1834. BOSEST_updateInfo($deviceHash, $deviceID);
  1835. Log3 $hash, 5, "BOSEST: BOSEST_updateNowPlaying";
  1836. #get now_playing
  1837. BOSEST_updateNowPlaying($deviceHash, $deviceID);
  1838. Log3 $hash, 5, "BOSEST: BOSEST_setVolume";
  1839. #set previous volume if not playing anything
  1840. if(ReadingsVal($deviceHash->{NAME}, "state", "") eq "online") {
  1841. BOSEST_setVolume($deviceHash, ReadingsVal($deviceHash->{NAME}, "volume", 10));
  1842. }
  1843. #get current volume
  1844. Log3 $hash, 5, "BOSEST: BOSEST_updateVolume";
  1845. BOSEST_updateVolume($deviceHash, $deviceID);
  1846. #get current presets
  1847. Log3 $hash, 5, "BOSEST: BOSEST_updatePresets";
  1848. BOSEST_updatePresets($deviceHash, $deviceID);
  1849. #get current bass settings
  1850. Log3 $hash, 5, "BOSEST: BOSEST_updateBass";
  1851. BOSEST_updateBass($deviceHash, $deviceID);
  1852. #get current zone settings
  1853. Log3 $hash, 5, "BOSEST: BOSEST_updateZone";
  1854. BOSEST_updateZone($deviceHash, $deviceID);
  1855. #get current sources
  1856. Log3 $hash, 5, "BOSEST: BOSEST_updateSources";
  1857. BOSEST_updateSources($deviceHash, $deviceID);
  1858. #get current clock state
  1859. Log3 $hash, 5, "BOSEST: BOSEST_updateClock";
  1860. BOSEST_updateClock($deviceHash, $deviceID);
  1861. #connect websocket
  1862. Log3 $hash, 4, "BOSEST: $deviceHash->{NAME}, start new WebSocket.";
  1863. BOSEST_startWebSocketConnection($deviceHash);
  1864. BOSEST_checkWebSocketConnection($deviceHash);
  1865. }
  1866. return undef;
  1867. }
  1868. sub BOSEST_commandDefine($$$) {
  1869. my ($hash, $deviceID, $deviceName) = @_;
  1870. #check if device exists already
  1871. if(!defined(BOSEST_getBosePlayerByDeviceId($hash, $deviceID))) {
  1872. CommandDefine(undef, "BOSE_$deviceID BOSEST $deviceID");
  1873. CommandAttr(undef, "BOSE_$deviceID alias $deviceName");
  1874. }
  1875. return undef;
  1876. }
  1877. ###### WEBSOCKET #######
  1878. sub BOSEST_webSocketCallback($$$) {
  1879. my ($hash, $ua, $tx) = @_;
  1880. Log3 $hash, 5, "BOSEST: Callback called";
  1881. if(!$tx->is_websocket) {
  1882. Log3 $hash, 3, "BOSEST: $hash->{NAME}, WebSocket failed, retry.";
  1883. BOSEST_startWebSocketConnection($hash);
  1884. return undef;
  1885. } else {
  1886. #avoid multiple websocket connections to one speaker
  1887. $hash->{helper}{wsconnected} += 1;
  1888. if($hash->{helper}{wsconnected} > 1) {
  1889. $tx->finish;
  1890. return undef;
  1891. }
  1892. Log3 $hash, 3, "BOSEST: $hash->{NAME}, WebSocket connection succeed.";
  1893. }
  1894. #register on message method
  1895. $tx->on(message => sub { my ($tx2, $msg) = @_; BOSEST_webSocketReceivedMsg($hash, $tx2, $msg); });
  1896. #register on finish method
  1897. $tx->on(finish => sub { my $ws = shift; BOSEST_webSocketFinished($hash, $ws); });
  1898. #add recurring ping to mojo ioloop due to inactivity timeout
  1899. $hash->{helper}{mojoping} = Mojo::IOLoop->recurring(19 => sub { BOSEST_webSocketPing($hash, $tx); });
  1900. return undef;
  1901. }
  1902. sub BOSEST_webSocketFinished($$) {
  1903. my ($hash, $ws) = @_;
  1904. Log3 $hash, 3, "BOSEST: $hash->{NAME}, WebSocket connection dropped - try reconnect.";
  1905. #set IP to unknown due to connection drop
  1906. $hash->{helper}{IP} = "unknown";
  1907. #connection dropped
  1908. $hash->{helper}{wsconnected} -= 1;
  1909. #set presence & state to offline due to connection drop
  1910. readingsBeginUpdate($hash);
  1911. BOSEST_readingsSingleUpdateIfChanged($hash, "IP", "unknown", 1);
  1912. BOSEST_readingsSingleUpdateIfChanged($hash, "presence", "offline", 1);
  1913. BOSEST_readingsSingleUpdateIfChanged($hash, "state", "offline", 1);
  1914. readingsEndUpdate($hash, 1);
  1915. Mojo::IOLoop->remove($hash->{helper}{mojoping});
  1916. $ws->finish;
  1917. return undef;
  1918. }
  1919. sub BOSEST_webSocketPing($$) {
  1920. my ($hash, $tx) = @_;
  1921. #reset requestid for ping to avoid overflows
  1922. $hash->{helper}{requestId} = 1 if($hash->{helper}{requestId} > 9999);
  1923. $tx->send('<msg><header deviceID="'.
  1924. $hash->{DEVICEID}.
  1925. '" url="webserver/pingRequest" method="GET"><request requestID="'.
  1926. $hash->{helper}{requestId}.
  1927. '"><info type="new"/></request></header></msg>');
  1928. #$tx->send([1, 0, 0, 0, WS_PING, 'Hello World!']);
  1929. return undef;
  1930. }
  1931. sub BOSEST_webSocketReceivedMsg($$$) {
  1932. my ($hash, $tx, $msg) = @_;
  1933. Log3 $hash, 5, "BOSEST: $hash->{NAME}, received message.";
  1934. #parse XML
  1935. my $xml = "";
  1936. eval {
  1937. $xml = XMLin($msg, KeepRoot => 1, ForceArray => [qw(media_server item member recent preset)], KeyAttr => []);
  1938. };
  1939. if($@) {
  1940. Log3 $hash, 3, "BOSEST: Wrong XML format: $@";
  1941. }
  1942. #process message
  1943. BOSEST_processXml($hash, $xml);
  1944. $tx->resume;
  1945. }
  1946. sub BOSEST_startWebSocketConnection($) {
  1947. my ($hash) = @_;
  1948. Log3 $hash, 5, "BOSEST: $hash->{NAME}, start WebSocket connection.";
  1949. $hash->{helper}{requestId} = 1;
  1950. if($hash->{helper}{wsconnected} > 0) {
  1951. Log3 $hash, 3, "BOSEST: There are already $hash->{helper}{wsconnected} WebSockets connected.";
  1952. Log3 $hash, 3, "BOSEST: Prevent new connections.";
  1953. return undef;
  1954. }
  1955. eval {
  1956. $hash->{helper}{bosewebsocket} = $hash->{helper}{useragent}->websocket('ws://'.$hash->{helper}{IP}.':8080'
  1957. => ['gabbo'] => sub {
  1958. my ($ua, $tx) = @_;
  1959. BOSEST_webSocketCallback($hash, $ua, $tx);
  1960. return undef;
  1961. });
  1962. };
  1963. if($@) {
  1964. InternalTimer(gettimeofday()+5, "BOSEST_startWebSocketConnection", $hash, 1);
  1965. }
  1966. $hash->{helper}{useragent}->inactivity_timeout(25);
  1967. $hash->{helper}{useragent}->request_timeout(10);
  1968. Log3 $hash, 4, "BOSEST: $hash->{NAME}, WebSocket connected.";
  1969. return undef;
  1970. }
  1971. sub BOSEST_checkWebSocketConnection($) {
  1972. my ($hash) = @_;
  1973. if(defined($hash->{helper}{bosewebsocket})) {
  1974. #run mojo loop not longer than 0.5ms
  1975. my $id = Mojo::IOLoop->timer(0.0005 => sub {});
  1976. Mojo::IOLoop->one_tick;
  1977. Mojo::IOLoop->remove($id);
  1978. }
  1979. InternalTimer(gettimeofday()+0.7, "BOSEST_checkWebSocketConnection", $hash, 1);
  1980. return undef;
  1981. }
  1982. ###### GENERIC ######
  1983. sub BOSEST_getMyIp($) {
  1984. #Attention: Blocking function
  1985. my ($hash) = @_;
  1986. my $socket = IO::Socket::INET->new(
  1987. Proto => 'udp',
  1988. PeerAddr => '198.41.0.4', #a.root-servers.net
  1989. PeerPort => '53' #DNS
  1990. );
  1991. my $local_ip_address = $socket->sockhost;
  1992. return $local_ip_address;
  1993. }
  1994. sub BOSEST_getSourceAccountByName($$) {
  1995. my ($hash, $sourceName) = @_;
  1996. foreach my $source (@{$hash->{helper}{sources}}) {
  1997. if($source->{name} eq $sourceName) {
  1998. return $source->{sourceAccount};
  1999. }
  2000. }
  2001. return undef;
  2002. }
  2003. sub BOSEST_getBosePlayerByDeviceId($$) {
  2004. my ($hash, $deviceId) = @_;
  2005. if (defined($deviceId)) {
  2006. foreach my $fhem_dev (sort keys %main::defs) {
  2007. return $main::defs{$fhem_dev} if($main::defs{$fhem_dev}{TYPE} eq 'BOSEST' && $main::defs{$fhem_dev}{DEVICEID} eq $deviceId);
  2008. }
  2009. } else {
  2010. return $hash;
  2011. }
  2012. return undef;
  2013. }
  2014. sub BOSEST_getAllBosePlayers($) {
  2015. my ($hash) = @_;
  2016. my @players = ();
  2017. foreach my $fhem_dev (sort keys %main::defs) {
  2018. push @players, $main::defs{$fhem_dev} if($main::defs{$fhem_dev}{TYPE} eq 'BOSEST' && $main::defs{$fhem_dev}{DEVICEID} ne "0");
  2019. }
  2020. return @players;
  2021. }
  2022. sub BOSEST_sendKey($$) {
  2023. my ($hash, $key) = @_;
  2024. my $postXml = '<key state="press" sender="Gabbo">'.$key.'</key>';
  2025. if(BOSEST_HTTPPOST($hash, '/key', $postXml)) {
  2026. $postXml = '<key state="release" sender="Gabbo">'.$key.'</key>';
  2027. if(BOSEST_HTTPPOST($hash, '/key', $postXml)) {
  2028. return undef;
  2029. }
  2030. }
  2031. #FIXME error handling
  2032. return undef;
  2033. }
  2034. sub BOSEST_HTTPGET($$$) {
  2035. my ($hash, $ip, $getURI) = @_;
  2036. if(!defined($ip) or $ip eq "unknown") {
  2037. Log3 $hash, 3, "BOSEST: $hash->{NAME}, Can't HTTP GET as long as IP is unknown.";
  2038. return undef;
  2039. }
  2040. my $ua = LWP::UserAgent->new();
  2041. my $req = HTTP::Request->new(GET => 'http://'.$ip.':8090'.$getURI);
  2042. my $response = $ua->request($req);
  2043. if($response->is_success) {
  2044. my $xmlres = "";
  2045. eval {
  2046. $xmlres = XMLin($response->decoded_content, KeepRoot => 1, ForceArray => [qw(media_server item member recent preset)], KeyAttr => []);
  2047. };
  2048. if($@) {
  2049. Log3 $hash, 3, "BOSEST: Wrong XML format: $@";
  2050. return undef;
  2051. }
  2052. return $xmlres;
  2053. }
  2054. return undef;
  2055. }
  2056. sub BOSEST_HTTPPOST($$$) {
  2057. my ($hash, $postURI, $postXml) = @_;
  2058. my $ua = LWP::UserAgent->new();
  2059. my $ip = $hash->{helper}{IP};
  2060. my $req = HTTP::Request->new(POST => 'http://'.$ip.':8090'.$postURI);
  2061. Log3 $hash, 4, "BOSEST: set ".$postURI." => ".$postXml;
  2062. $req->content($postXml);
  2063. my $response = $ua->request($req);
  2064. if($response->is_success) {
  2065. Log3 $hash, 4, "BOSEST: success: ".$response->decoded_content;
  2066. my $xmlres = "";
  2067. eval {
  2068. $xmlres = XMLin($response->decoded_content, KeepRoot => 1, ForceArray => [qw(media_server item member recent preset)], KeyAttr => []);
  2069. };
  2070. if($@) {
  2071. Log3 $hash, 3, "BOSEST: Wrong XML format: $@";
  2072. return undef;
  2073. }
  2074. return $xmlres;
  2075. } else {
  2076. #TODO return error
  2077. Log3 $hash, 3, "BOSEST: failed: ".$response->status_line;
  2078. return undef;
  2079. }
  2080. return undef;
  2081. }
  2082. sub BOSEST_XMLUpdate($$$) {
  2083. my ($hash, $readingName, $xmlItem) = @_;
  2084. my $curVal = ReadingsVal($hash->{NAME}, $readingName, "");
  2085. my $newVal = "";
  2086. #TODO update only on change
  2087. if(ref $xmlItem eq ref {}) {
  2088. if(keys %{$xmlItem}) {
  2089. $newVal = Encode::encode('UTF-8', $xmlItem);
  2090. }
  2091. } elsif($xmlItem) {
  2092. $newVal = Encode::encode('UTF-8', $xmlItem);
  2093. }
  2094. if($curVal ne $newVal) {
  2095. readingsBulkUpdate($hash, $readingName, $newVal);
  2096. }
  2097. return undef;
  2098. }
  2099. sub BOSEST_readingsSingleUpdateIfChanged {
  2100. my ($hash, $reading, $value, $trigger) = @_;
  2101. my $curVal = ReadingsVal($hash->{NAME}, $reading, "");
  2102. if($curVal ne $value) {
  2103. readingsSingleUpdate($hash, $reading, $value, $trigger);
  2104. }
  2105. }
  2106. 1;
  2107. =pod
  2108. =item device
  2109. =item summary Easily autodiscover and control your BOSE SoundTouch devices
  2110. =item summary_DE Autodiscover und einfache Steuerung deiner BOSE SoundTouch Geräte
  2111. =begin html
  2112. <a name="BOSEST"></a>
  2113. <h3>BOSEST</h3>
  2114. <ul>
  2115. BOSEST is used to control a BOSE SoundTouch system (one or more SoundTouch 10, 20 or 30 devices)<br><br>
  2116. <b>Note:</b> The followig libraries are required for this module:
  2117. <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>
  2118. Use <b>sudo apt-get install libwww-perl libmojolicious-perl libxml-simple-perl libnet-bonjour-perl libev-perl</b> to install this libraries.<br>Please note:
  2119. libmojolicious-perl must be >=5.54, but under wheezy is only 2.x avaible.<br>
  2120. Use <b>sudo apt-get install cpanminus</b> and <b>sudo cpanm Mojolicious</b> to update to the newest version<br>
  2121. TTS can be configured as described in the following thread: <a href=https://forum.fhem.de/index.php/topic,46838.0.html>Link</a><br>
  2122. <br>
  2123. Questions and/or feedback can be posted on the FHEM forum: <a https://forum.fhem.de/index.php/topic,46838.msg533050.html#new>Link</a><br>
  2124. </ul><br>
  2125. <a name="BOSESTdefine" id="BOSESTdefine"></a>
  2126. <b>Define</b>
  2127. <ul>
  2128. <code>define &lt;name&gt; BOSEST</code><br>
  2129. <br>
  2130. Example:
  2131. <ul>
  2132. <code>define bosesystem BOSEST</code><br>
  2133. Defines BOSE SoundTouch system. All devices/speakers will show up after 60s under "Unsorted" in FHEM.<br/>
  2134. </ul>
  2135. </ul>
  2136. <br>
  2137. <a name="BOSESTset" id="BOSESTset"></a>
  2138. <b>Set</b>
  2139. <ul>
  2140. <code>set &lt;name&gt; &lt;command&gt; [&lt;parameter&gt;]</code><br>
  2141. The following commands are defined for the devices/speakers (execpt <b>autoAddDLNAServers</b> is for the "main" BOSEST) :<br><br>
  2142. <ul><u>General commands</u>
  2143. <li><code><b>on</b></code> &nbsp;&nbsp;-&nbsp;&nbsp; power on the device</li>
  2144. <li><code><b>off</b></code> &nbsp;&nbsp;-&nbsp;&nbsp; turn the device off</li>
  2145. <li><code><b>power</b></code> &nbsp;&nbsp;-&nbsp;&nbsp; toggle on/off</li>
  2146. <li><code><b>volume</b> [0...100] [+x|-x]</code> &nbsp;&nbsp;-&nbsp;&nbsp; set the volume level in percentage or change volume by ±x from current level</li>
  2147. <li><code><b>channel</b> 0...20</code> &nbsp;&nbsp;-&nbsp;&nbsp; select present to play</li>
  2148. <li><code><b>saveChannel</b> 07...20</code> &nbsp;&nbsp;-&nbsp;&nbsp; save current channel to channel 07 to 20</li>
  2149. <li><code><b>play</b></code> &nbsp;&nbsp;-&nbsp;&nbsp; start/resume to play </li>
  2150. <li><code><b>pause</b></code> &nbsp;&nbsp;-&nbsp;&nbsp; pause the playback</li>
  2151. <li><code><b>stop</b></code> &nbsp;&nbsp;-&nbsp;&nbsp; stop playback</li>
  2152. <li><code><b>nextTrack</b></code> &nbsp;&nbsp;-&nbsp;&nbsp; play next track</li>
  2153. <li><code><b>prevTrack</b></code> &nbsp;&nbsp;-&nbsp;&nbsp; play previous track</li>
  2154. <li><code><b>mute</b> on|off|toggle</code> &nbsp;&nbsp;-&nbsp;&nbsp; control volume mute</li>
  2155. <li><code><b>shuffle</b> on|off</code> &nbsp;&nbsp;-&nbsp;&nbsp; control shuffle mode</li>
  2156. <li><code><b>repeat</b> all|one|off</code> &nbsp;&nbsp;-&nbsp;&nbsp; control repeat mode</li>
  2157. <li><code><b>bass</b> 0...10</code> &nbsp;&nbsp;-&nbsp;&nbsp; set the bass level</li>
  2158. <li><code><b>recent</b> 0...15</code> &nbsp;&nbsp;-&nbsp;&nbsp; set number of names in the recent list in readings</li>
  2159. <li><code><b>source</b> bluetooth,bt-discover,aux mode, airplay</code> &nbsp;&nbsp;-&nbsp;&nbsp; select a local source</li><br>
  2160. <li><code><b>addDLNAServer</b> Name1 [Name2] [Namex]</code> &nbsp;&nbsp;-&nbsp;&nbsp; add DLNA servers Name1 (and Name2 to Namex) to the BOSE library</li>
  2161. <li><code><b>removeDLNAServer</b> Name1 [Name2] [Namex]</code> &nbsp;&nbsp;-&nbsp;&nbsp; remove DLNA servers Name1 (and Name2 to Namex) to the BOSE library</li>
  2162. </ul><br>Example: <code>set BOSE_1234567890AB volume 25</code>&nbsp;&nbsp;Set volume on device with the name BOSE_1234567890AB <br><br><br>
  2163. <ul><u>Timer commands:</u>
  2164. <li><code><b>on-for-timer</b> 1...x</code> &nbsp;&nbsp;-&nbsp;&nbsp; power on the device for x seconds</li>
  2165. <li><code><b>off-for-timer</b> 1...x</code> &nbsp;&nbsp;-&nbsp;&nbsp; turn the device off and power on again after x seconds</li>
  2166. <li><code><b>on-till</b> hh:mm:ss</code> &nbsp;&nbsp;-&nbsp;&nbsp; power on the device until defined time</li>
  2167. <li><code><b>off-till</b> hh:mm:ss</code> &nbsp;&nbsp;-&nbsp;&nbsp; turn the device off and power on again at defined time</li>
  2168. <li><code><b>on-till-overneight</b> hh:mm:ss</code> &nbsp;&nbsp;-&nbsp;&nbsp; power on the device until defined time on the next day</li>
  2169. <li><code><b>off-till-overneight</b> hh:mm:ss</code> &nbsp;&nbsp;-&nbsp;&nbsp; turn the device off at defined time on the next day</li>
  2170. </ul><br>Example: <code>set BOSE_1234567890AB on-till 23:00:00</code>&nbsp;&nbsp;Switches device with the name BOSE_1234567890AB now on and at 23:00:00 off<br><br><br>
  2171. <ul><u>Multiroom commands:</u>
  2172. <li><code><b>createZone</b> deviceID</code> &nbsp;&nbsp;-&nbsp;&nbsp; create multiroom zone (defines <code>&lt;name&gt;</code> as zoneMaster) </li>
  2173. <li><code><b>addToZone</b> deviceID</code> &nbsp;&nbsp;-&nbsp;&nbsp; add device <code>&lt;name&gt;</code> to multiroom zone</li>
  2174. <li><code><b>removeFromZone</b> deviceID</code> &nbsp;&nbsp;-&nbsp;&nbsp; remove device <code>&lt;name&gt;</code> from multiroom zone</li>
  2175. <li><code><b>playEverywhere</b></code> &nbsp;&nbsp;-&nbsp;&nbsp; play sound of device <code>&lt;name&gt;</code> on all others devices</li>
  2176. <li><code><b>stopPlayEverywhere</b></code> &nbsp;&nbsp;-&nbsp;&nbsp; stop playing sound on all devices</li>
  2177. </ul><br>Example: <code>set BOSE_1234567890AB playEverywhere</code>&nbsp;&nbsp;Starts Multiroom with device with the name BOSE_1234567890AB as master <br><br><br>
  2178. <ul><u>TextToSpeach commands (needs Google Translate):</u>
  2179. <li><code><b>speak</b> "message" [0...100] [+x|-x] [en|de|xx]</code> &nbsp;&nbsp;-&nbsp;&nbsp; Text to speak, optional with volume adjustment and language to use. The message to speak may have up to 100 letters</li>
  2180. <li><code><b>speakOff</b> "message" [0...100] [+x|-x] [en|de|xx]</code> &nbsp;&nbsp;-&nbsp;&nbsp; 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</li>
  2181. <li><code><b>ttsVolume</b> [0...100] [+x|-x]</code> &nbsp;&nbsp;-&nbsp;&nbsp; set the TTS volume level in percentage or change volume by ±x from current level</li>
  2182. <li><code><b>ttsDirectory</b> "directory"</code> &nbsp;&nbsp;-&nbsp;&nbsp; set DLNA TTS directory. FHEM user needs permissions to write to that directory. </li>
  2183. <li><code><b>ttsLanguage </b> en|de|xx</code> &nbsp;&nbsp;-&nbsp;&nbsp; set default TTS language (default: en)</li>
  2184. <li><code><b>ttsSpeakOnError</b> 0|1</code> &nbsp;&nbsp;-&nbsp;&nbsp; 0=disable to speak "not available" text</li>
  2185. <li><code><b>autoAddDLNAServers</b> 0|1</code> &nbsp;&nbsp;-&nbsp;&nbsp; 1=automatically add all DLNA servers to BOSE library. This command is only for "main" BOSEST, not for devices/speakers!</li> <br>
  2186. </ul><br> Example: <code>set BOSE_1234567890AB speakOff "Music is going to switch off now. Good night." 30 en</code>&nbsp;&nbsp;Speaks message at volume 30 and then switches off device.<br><br> <br>
  2187. </ul><br>
  2188. <a name="BOSESTget" id="BOSESTget"></a>
  2189. <b>Get</b>
  2190. <ul>
  2191. <code>n/a</code>
  2192. </ul>
  2193. <br>
  2194. </ul>
  2195. =end html
  2196. =cut