98_BOSEST.pm 97 KB

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