diff --git a/README.md b/README.md index 1a4a43a..5700214 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,37 @@ ## Release -v.0.9 (2016-11-20) +v1.0 (2017-02-18) + + -- Attention: cleaned-up configuration file. Please re-configure your Sonos Broker installation + -- command "transport_actions" to Sonos Broker and Sonos command line tool + -- this options shows all possible actions for the current track (e.g. Next, Stop, Play ...) + -- command 'nightmode' added (only for supported speakers) + -- bug: Spotify Radio was handled as a normal radio station and should be fixed + -- GoogleTTS improvements + -- GoogleTTS: files now stored with md5 sum of tts_language and tts_string to reduce the filename length + -- GoogleTTS: now works in streaning mode per default, no web service is needed. + -- GoogleTTS: the local ip address for the streaming url will be detected automatically (by default) + -- play_tts: (optional) attribute 'force_stream_mode' (re)-added to Sonos Broker and Command line tool + -- bug: endless loop while trying to play a track from a non-existing url + -- bug: wrong path in systemd script + -- bug: the 'volume' of all zone members will now be restored correctly after playing the snippet + -- command optional parameter 'play' added to command "unjoin" + -- with 'play' set to true, the prevoiusly played track (before joining a group) will resumed + -- changed executable name from "sonos_broker" to "sonos-broker" + -- changed command line tool from "sonos_cmd" to "sonos-cmd" + -- changed default installation path of sonos_broker.cfg to /etc/default/sonos-broker + -- stopping a running Sonos Broker instance is handled a bit more gracefully + -- updated setup script + -- auto-start scripts for systemd and upstart automatically placed in the appropriate folder by the + installation script + -- 'daemonize' behaviour removed + -- unnecessary parameter 'stop' removed + -- updated documentation + -- added "zone_member" command to Sonos Broker (to retrieve this value actively) + -- added commands 'join' and 'unjoin' to Sonos Broker commandline tool + -- bug: error when trying to decode non-ascii chars and the systems stdout was no set to utf8 + +v0.9 (2016-11-20) -- added missing 'track_album' property -- added missing 'track_album' to Sonos-Broker commandline @@ -14,55 +45,6 @@ v.0.9 (2016-11-20) -- bugfixed: Sonos Broker user-specific server port was ignored -- updated documentation -v.0.8.2 (2016-11-14) - - -- fixed bug in GoogleTTS - -v.0.8.1 (2016-11-14) - - -- changed commandline arguments to control the Sonos Broker. - Available arguments: - - start [-d] [-c] [-h] (-d=debug mode, -c=user-specified config file, -h=help) - stop - list - - -- fixed an issue when executing sonos_broker with '-l' parameter - -v0.8 (2016-11-11) - - -- **ATTENTION:** commands "get_playlist" and "set_playlist" removed. I decided to stick with - the internal Sonos playlists. - -- new implementation Google TTS: Captcha and other issues should now be solved (for this time) - -- **ATTENTION:** parameter "force_stream_mode" removed for command "play_tts" caused by the new - implementation for Google TTS. The possibility for an additional TTS "stream mode" was removed. - (see documentation Google TTS for setup) - -- new command "load_sonos_playlist". See documentation for implementation. - -- command "sonos_broker_version" added - -- command 'clear_queue' added - -- command 'play_tunein' added. Play any TuneIn radio station by a given name - -- SoCo framework changes (v0.12) with some bugfixes - -- removed some unused functions - -- fixed error when calling sonos_broker with 'l' (scan only flag) - -- small bugfixes - -- Deezer tracks and their metadata are handled correctly now - -- Sonos-Broker commandline tools has now parameters (type "sonos_cmd -h" for help) - -v0.7 (2016-01-04) - - -- command "discover" added to force a manual scan for Sonos speaker in the network - -- command "balance" can now take the optional parameter "group_command"; documentation updated - -- property "status" now triggers a value change notification to all connected clients - -- bugfix: setting play, pause, stop could lead to an infinite loop (play-pause-play ...) - -- added a valid user-agent for Google TTS requests, this should solve the captcha issue - -- property 'model_number' added - -- property 'display_version' added - -- property 'household_id' added (a unique identifier for all players in a household) - -- some changes in SoCo framework - -- bugfixes in command-line tool - -- command 'balance' (especially for sonos amp and stereo paired sonos speaker) added - - ## Overview @@ -80,78 +62,86 @@ smart home environment (https://github.com/mknx/smarthome/). ## Requirements +#### Deleting old files + +If're updating the Broker it may be a good idea to delete old files. Please adapt the paths to your system. + +``` +sudo rm -rf /usr/local/bin/sonos* +sudo rm -rf /usr/local/lib/python3.5/site-packages/*sonos* +sudo rm -rf /usr/local/lib/python3.5/site-packages/soco +``` + #### Server-side python3.4 python3 libraries 'requests' and 'xmltodict' -``` -pip3 install requests -pip3 install xmltodict -``` #### Client-side Nothing special, just send your commands over http (JSON format) or use the Smarthome.py plugin to control the speakers within Smarthome.py. +You can use the included built-in implementation, the [Sonos Broker commandline tool](#cmd_tool) ## Installation #### Setup -Under the github folder "server.sonos/dist/" you'll find the actual release as a tar.gz file. -Unzip this file with: +Under the github folder "server.sonos/dist/" you'll find the actual release as a tar.gz file. Here are two ways to +install the Sonos Broker: + +##### 1. pip - tar -xvf sonos_broker_release.tar.gz +If python3-pip is installed, you can simply call -(adjust the filename to your needs) + python3 -m pip -v install sonos-broker-{release-version}.tar.gz -Go to the unpacked folder and run setup.py with: +Every dependency should be installed automatically. + +##### 2. manually - sudo python3 setup.py install +Untar the file with and install it manually. -This command will install all the python packages and places the start script to the python folder -"/user/local/bin" + tar -xvf sonos-broker-{release}.tar.gz + cd sonos-broker-{release} + sudo python3 setup.py install --force -Make the file executable and run the sonos_broker with: +If an error occurred, you should try to (re)-install all necessary dependencies: + + pip3 install requests + pip3 install xmltodict - chmod +x sonos_broker - ./sonos_broker +Both methods will install ```sonos-broker``` and ```sonos-cmd``` under ```/usr/local/bin``` to make both commands +system-wide executable. +The default config file is installed under ```/etc/default/sonos-broker``. -Normally, the script finds the internal ip address of your computer. If not, you have to edit your sonos_broker.cfg. +The internal ip address should be set up automatically. If not, you have to edit ```/etc/default/sonos-broker``` [sonos_broker] - server_ip = x.x.x.x - -(x.x.x.x means your ip: run ifconfig - a to find it out) + server_ip = x.x.x.x #your ip here #### Configuration / Start options -You can edit the settings of Sonos Broker. Open 'sonos_broker.cfg' with your favorite editor and edit the file. -All values within the config file should be self-explaining. For Google-TTS options, see the appropriate section in this -Readme. +You can edit the settings of Sonos Broker. Open '/etc/default/sonos-broker' (by default) with your favorite editor and +edit the file. All values within the config file should be self-explaining. For Google-TTS options, see the appropriate +section in this Readme. -If you start the sonos broker with +You can start the sonos broker with ``` sonos_broker start ``` -the server will be automatically daemonized. -You can add the -d (--debug) parameter to hold the process in the foreground. +You can add the -d (--debug) parameter to get more output ``` sonos_broker start -d ``` -An user-specified config file can be passed with the '-c' flag -``` -sonos_broker start -c -``` - -You can stop the server with +An user-specified config file can be passed with the '-c' flag. Default: /etc/default/sonos-broker ``` -sonos_broker stop +sonos_broker start -c /your/config/file/path/here ``` To get a short overview of your speakers in the network start the server with @@ -163,23 +153,39 @@ To get an overview of all parameters type ``` sonos_broker -h ``` -or +and / or ``` sonos_broker {command} -h ``` -To autostart the service on system boot, please follow the instruction for your linux distribution and put this -script in the right place. +After the successful installation with ```sudo python3 setup.py install --force``` an autostart script should be placed +automatically in your systems autostart directory. The autostart implementations are integrated for systems based on +'SYSTEMD' and 'UPSTART'. 'SYSVINIT' is NOT supported because of there are too many different sysvinit implementations. +To control the Broker via the system service control, see the commands below: + +SYSTEMD: +```sudo systemctl [start|stop|restart] sonos-broker``` + +For autostart: +```sudo systemctl enable sonos-broker``` + + +UPSTART: +```sudo service sonos-broker [start|stop|restart]``` + +For autostart uncomment following line in ```/etc/init/sonos-broker.conf```: +```#start on runlevel [2345]``` + -To get some debug output, please edit the sonos_broker.cfg and uncomment this line in the logging section (or use the --d start parameter): +To get some more debug output when running the Broker as a service, please edit the Sonos Broker config file +(default: /etc/default/sonos-broker) and uncomment this line in the logging section: loglevel = debug You can set the debug level to debug, info, warning, error, critical. Additionally, you can specify a file to pipe the debug log to this file. - logfile = log.txt + logfile = /path/to/your/log.txt ## Interactive Command Line @@ -187,7 +193,7 @@ Additionally, you can specify a file to pipe the debug log to this file. You can control the Broker and your speakers without implementing your own client. To start the interactive command line (the Broker must be running) type ``` -./sonos_cmd +sonos-cmd ``` in the root folder of the Sonos Broker. @@ -231,54 +237,32 @@ exit redirects you to the first command line level. -## Google TTS Support - -Sonos broker features the Google Text-To-Speech API. You can play any text limited to 100 chars. - - -#### Prerequisite: - -- local / remote mounted folder or share with read/write access -- http access to this local folder (e.g. /var/www) -- settings configured in sonos_broker.conf +## Integrated webservice for audio files -#### Internals - -If a text is given to the google tts function, sonos broker makes a http request to the Google API. The response is -stored as a mp3-file to the local / remote folder. - -Before the request is made ('local mode'), the broker checks whether a file exists -with the same name. The file name of a tts-file is always: BASE64(_).mp3 -You can set a file quota in the config file. This limits the amount of disk space the broker can use to save tts files. -If the quota exceeds, you will receive a message. By default the quota is set to 100 mb. +Sonos Broker integrates an internal webservice to serve audio files for Sonos speakers. You can enable this feature in +the \[webservice\] section of the Sonos Broker configuration. If enabled, you can store audio files in the configurable +web root path (valid extensions: aac, mp4, mp3, ogg, wav, web) and they can be played with the +[play_snippet](#p_snippet) command. The URL is available via http://SONOS_BROKER_IP:SONOS_BROKER_PORT/. +This service is also helpful for the Google TTS functionality. - sonos_broker.cfg: - [google_tts] - quota = 200 - -By default, Google TTS support is disabled. To enable the service, add following line to sonos_broker.cfg: - - sonos_broker.cfg: +## Google TTS Support - [google_tts] - enable = true +Sonos Broker features the Google Text-To-Speech API. You can play any text limited to 100 chars. -You have to set the local save path (where the mp3 is stored) and the accessible local url: - sonos_broker.cfg +#### Prerequisite: - [google_tts] - save_path =/your/path/here - server_url = http://192.168.0.2/tts +- internet connection +- settings configured in sonos-broker configuration file (default: /etc/default/sonos-broker) -This is an example of a google_tts section in the sonos_broker.cfg file: +#### Internals - [google_tts] - enable=true - quota=200 - save_path =/your/path/here - server_url = http://192.168.0.2/tts +If a text is given to the google tts function, the Sonos Broker makes a http request to the Google API and the tts +string will be played by your Sonos loudspeakers. Additionally, you can set-up the Broker to store the request as a +mp3 file. This is a kind of local caching: if a tts string is requested which is already stored as a mp3 file, this +file will be served to the Sonos speakers. To enable this feature, you have to configure the webservice feature of +the Sonos Broker. Have a look at the configuration file for more details [/etc/default/sonos-broker] ## Implementation: @@ -348,6 +332,7 @@ In almost any cases, you'll get the appropriate response in the following JSON f "track_position": "00:00:00", "track_title": "Das Baby im Schafspelz", "track_uri": "x-sonos-spotify:spotify%3atrack%3a3xCk8npVehdV55KuPdjrmZ?sid=9&flags=32", + "transport_actions": "Set,Stop,Pause,Play,Next" "treble": 0, "uid": "rincon_000e58c3892e01410", "volume": 8, @@ -406,6 +391,8 @@ Click on the links below to get a detailed command descriptions and their usage. ###### [set_balance](#s_balance) ###### [get_loudness](#g_loudness) ###### [set_loudness](#s_loudness) +###### [get_nightmode](#g_nightmode) +###### [set_nightmode](#s_nightmode) ###### [get_led](#g_led) ###### [set_led](#s_led) ###### [get_playmode](#g_playmode) @@ -417,6 +404,7 @@ Click on the links below to get a detailed command descriptions and their usage. ###### [get_track_artist](#g_track_artist) ###### [get_track_album_art](#g_track_album_art) ###### [get_track_uri](#g_track_uri) +###### [get_transport_actions](#g_transport_actions) ###### [get_playlist_position](#g_playlist_position) ###### [get_playlist_total_tracks](#g_playlist_total_tracks) ###### [get_radio_station](#g_radio_station) @@ -437,6 +425,7 @@ Click on the links below to get a detailed command descriptions and their usage. ###### [get_sonos_playlists](#get_sonos_playlists) ###### [load_sonos_playlist](#load_sonos_playlist) ###### [refresh_media_library](#ref_lib) +###### [zone_members](#zone_members) ###### [get_wifi_state](#get_wifi) ###### [set_wifi_state](#set_wifi) ###### [discover](#discover) @@ -1319,6 +1308,72 @@ No special parameter needed. The response is only sent if the new value is different from the old value. +---- +#### get_nightmode + Gets the current nightmode setting from a Sonos speaker. The nightmode feature is only avaliable for certain Sonos + speakers, e.g. the Sonos Playbar. For all other speakers, this value should always be 0. + In most cases, you don't have to execute this command, because all subscribed clients will be notified automatically + about 'nightmode'-status changes. + +| parameter | required / optional | valid values | description | +| :-------- | :------------------ | :----------- | :---------- | +| uid | required | | The UID of the Sonos speaker. | + +######Example + JSON format: + { + 'command': 'get_nightmode', + 'parameter': { + 'uid': 'rincon_b8e93730d19801410' + } + } + +######HTTP Response + HTTP 200 OK or Exception with HTTP status 400 and the specific error message. + +######UDP Response sent to subscribed clients: + JSON format: + { + ... + "nightmode": 0|1, + "uid": "rincon_b8e93730d19801410", + ... + } + +---- +#### set_nightmode + Sets the nightmode option for a Sonos speaker. If the Sonos speaker does not support this feature, an error message + is thrown. + +| parameter | required / optional | valid values | description | +| :-------- | :------------------ | :----------- | :---------- | +| uid | required | | The UID of the Sonos speaker. | +| nightmode | required | 0 or 1 | The nightmode to be set. | + +######Example + JSON format: + { + 'command': 'set_nightmode', + 'parameter': { + 'uid': 'rincon_b8e93730d19801410', + 'nightmode': 1 + } + } + +######HTTP Response + HTTP 200 OK or Exception with HTTP status 400 and the specific error message. + +######UDP Response sent to subscribed clients: + JSON format: + { + ... + "nightmode": 0|1, + "uid": "rincon_b8e93730d19801410", + ... + } + + The response is only sent if the new value is different from the old value. + ---- #### get_led Gets the current led status from a Sonos speaker. @@ -1515,6 +1570,7 @@ No special parameter needed. The response is only sent if the new value is different from the old value. +---- #### get_track_album Returns the album title of the currently played track. In most cases, you don't have to execute this command, because all subscribed clients will be notified automatically @@ -1545,6 +1601,7 @@ No special parameter needed. ... } +---- #### get_track_title Returns the title of the currently played track. In most cases, you don't have to execute this command, because all subscribed clients will be notified automatically @@ -1575,6 +1632,7 @@ No special parameter needed. ... } +---- #### get_track_artist Returns the artist of the currently played track. In most cases, you don't have to execute this command, because all subscribed clients will be notified automatically @@ -1605,6 +1663,7 @@ No special parameter needed. ... } +---- #### get_track_album_art Returns the album-cover url of the currently played track. In most cases, you don't have to execute this command, because all subscribed clients will be notified automatically @@ -1635,6 +1694,7 @@ No special parameter needed. ... } +---- #### get_track_uri Returns the track url of the currently played track. In most cases, you don't have to execute this command, because all subscribed clients will be notified automatically @@ -1666,7 +1726,39 @@ No special parameter needed. } All URIs can be passed to the play_uri and play_snippet functions. + +---- +#### get_transport_actions + Returns the available transport actions for the current track. This could be useful for a UI to display certain + control items (or not). + In most cases, you don't have to execute this command, because all subscribed clients will be notified automatically + about 'transport_actions'-status changes. + +| parameter | required / optional | valid values | description | +| :-------- | :------------------ | :----------- | :---------- | +| uid | required | | The UID of the Sonos speaker. | + +######Example + JSON format: + { + 'command': 'get_transport_actions', + 'parameter': { + 'uid': 'rincon_b8e93730d19801410' + } + } +######HTTP Response + HTTP 200 OK or Exception with HTTP status 400 and the specific error message. + +######UDP Response sent to subscribed clients: + JSON format: + { + ... + "transport_actions": "Set,Stop,Pause,Play,Next", + "uid": "rincon_b8e93730d19801410", + ... + } + ---- #### get_playlist_position Returns the position of the currently played track in the playlist. @@ -1833,13 +1925,15 @@ No special parameter needed. | parameter | required / optional | valid values | description | | :-------- | :------------------ | :----------- | :---------- | | uid | required | | The UID of the Sonos speaker. | +| play | optional | True/False, 0/1 | Should the speaker automatically start playing after unjoin. | ######Example JSON format: { 'command': 'unjoin', 'parameter': { - 'uid': 'rincon_b8e93730d19801410' + 'uid': 'rincon_b8e93730d19801410', + 'play': False } } @@ -2053,6 +2147,7 @@ No special parameter needed. | fade_in | optional | 0 or 1 | If True, the volume for the resumed track / radio fades in | | volume | optional | -1 - 100 | The snippet volume. If -1 (default) the current volume is used. After the snippet was played, the prevoius volume value is set. | | group_command | optional | 0 or 1 | If 'True', the command is executed for all zone members of the speaker. This affects only the parameter 'volume'.| +| force_stream_mode | optional | 0 or 1 | If 'True', Google TTS is streamed directly without storing the track locally. This overrides the Broker settings.| ######Example JSON format: @@ -2063,7 +2158,8 @@ No special parameter needed. 'tts': 'Die Temperatur im Wohnzimmer beträgt 2 Grad Celsius.' 'language': 'de', 'volume': 30, - 'group_command': 1 + 'group_command': 1, + 'force_stream_mode': 0 } } @@ -2146,47 +2242,52 @@ No special parameter needed. ######UDP Response sent to subscribed clients: JSON format: { - "additional_zone_members": "", - "alarms": "", - "balance": 0, + "additional_zone_members": "rincon_112ef9e4892e00001", + "alarms": { + "32": { + "Duration": "02:00:00", + "Enabled": false, + "IncludedLinkZones": false, + "PlayMode": "SHUFFLE_NOREPEAT", + "Recurrence": "DAILY", + "StartTime": "07:00:00", + "Volume": 25 + } + }, "bass": 0, - "display_version": "6.0", - "hardware_version": "1.8.1.2-2", - "household_id": "Sonos_Ef8RhcyY1ijYDDFp1I3GitguTP", - "ip": "192.168.0.11", - "is_coordinator": true, + "hardware_version": "1.8.3.7-2", + "ip": "192.168.0.4", "led": 1, "loudness": 1, - "mac_address": "B8-E9-37-38-E1-72", + "mac_address": "10:1F:21:C3:77:1A", "max_volume": -1, - "model": "Sonos PLAY:3", - "model_number": "S3", - "mute": 0, - "pause": 1, + "model": "Sonos PLAY:1", + "mute": "0", + "pause": 0, "play": 0, "playlist_position": "1", "playlist_total_tracks": "10", "playmode": "normal", "radio_show": "", "radio_station": "", - "serial_number": "B8-E9-37-38-E1-72:5", - "software_version": "31.3-22220", - "sonos_playlists": "Morning,Evening,U2", + "serial_number": "00-0E-58-C3-89-2E:7", + "software_version": "27.2-80271", + "sonos_playlists": "DepecheMode,my_fav-list2,my-fav-list2", "status": true, - "stop": 0, + "stop": 1, "streamtype": "music", - "track_album_art": "http://192.168.0.11:1400/getaa?s=1&u=x-sonos-http%3atr%253a119434742.mp3%3fsid%3d2%26flags%3d8224%26sn%3d1", - "track_artist": "Was ist Was", - "track_duration": "0:02:22", + "track_album": "Feuerwehrmann Sam 02", + "track_album_art": "http://192.168.0.4:1400/getaa?s=1&u=x-sonos-spotify%3aspotify%253atrack%253a3xCk8npVehdV55KuPdjrmZ%3fsid%3d9%26flags%3d32", + "track_artist": "Feuerwehrmann Sam & Clemens Gerhard", + "track_duration": "0:10:15", "track_position": "00:00:00", - "track_title": "Europa - Teil 10", - "track_uri": "x-sonos-http:tr%3a119434742.mp3?sid=2&flags=8224&sn=1", + "track_title": "Das Baby im Schafspelz", + "track_uri": "x-sonos-spotify:spotify%3atrack%3a3xCk8npVehdV55KuPdjrmZ?sid=9&flags=32", + "transport_actions": "Set,Stop,Pause,Play,Next" "treble": 0, - "tts_local_mode": false, - "uid": "rincon_b8e91111d11111400", - "volume": 17, - "wifi_state": 1, - "zone_icon": "/img/icon-S3.png", + "uid": "rincon_000e58c3892e01410", + "volume": 8, + "zone_icon": "x-rincon-roomicon:bedroom", "zone_name": "Kinderzimmer" } @@ -2390,6 +2491,39 @@ This has some disadvantages. Please read the Google TTS section in this document No UDP response +---- +#### zone_members + Gets all additional zone members of the group the current speaker is part of. If the speaker is the only member of the + group, the response is empty. + In most cases, you don't have to execute this command, because all subscribed clients will be notified automatically + about 'zone_members'-status changes. + +| parameter | required / optional | valid values | description | +| :-------- | :------------------ | :----------- | :---------- | +| uid | required | | The UID of the Sonos speaker. | + +######Example + JSON format: + { + 'command': 'zone_members', + 'parameter': { + 'uid': rincon_b8e91111d11111400 + } + +######HTTP Response + HTTP 200 OK + or + Exception with HTTP status 400 and the specific error message. + +###### UDP Response sent to subscribed clients: + JSON format: + { + ... + "zone_members": ["rincon_c4441111d11111400", "rincon_d5ee1111d11111400"] + "uid": "rincon_b8e91111d11111400" + ... + } + ---- #### get_wifi_state Gets the current wifi status. Since there is no sonos event for the wifi state, you have to trigger @@ -2492,4 +2626,4 @@ This has some disadvantages. Please read the Google TTS section in this document Exception with HTTP status 400 and the specific error message. ###### UDP Response sent to subscribed clients: - No UDP response + No UDP response \ No newline at end of file diff --git a/plugin.sonos/README.md b/plugin.sonos/README.md index 5d155e3..11930d9 100644 --- a/plugin.sonos/README.md +++ b/plugin.sonos/README.md @@ -1,47 +1,49 @@ -This sub-project is a client implementation fpr the Sonos Broker. It is a plugin for the -Smarthome.py framework (https://github.com/mknx/smarthome). +This sub-project is a client implementation for the Sonos Broker. It is a plugin for the +SmarthomeNG framework (https://github.com/smarthomeNG). ##Release -v0.9 (2016-11-20) - +v1.0 (2017-02-15) + + -- dpt3 functionality added for volume item + -- command 'transport_actions' added + -- command 'nightmode' added + -- play_tts: attribute 'force_stream_mode' (re)-added + -- added attribute 'play' to 'unjoin' + -- resumes the last played track / radio before join to another group -- added missing 'track_album' property -- add new property 'playlist_total_tracks' - -- change expected Sonos Broker version to 0.9 + -- attribute 'is_coordiantor' in example has now the right value + -- version string updated + -- change expected Sonos Broker version to v1.0 -v0.8.2 (2016-11-14) +## Overview - -- change expected Sonos Broker version to 0.8.2 - -v0.8.1 (2016-11-14) +[1. Requirements](#req) + +[2. Integration in SmarthomeNG](#shng) + +[3. Volume DPT3 support](#dpt) + +[4. Group behavior](#group) + +[5. Methods](#meth) + +[6. SmartVISU Integration](#visu) + +[7. FAQ](#faq) - -- switching versioning to the current Sonos Broker version - -- change expected Sonos Broker version to 0.8.1 - -v1.8 (2016-11-11) - - -- ATTENTION: commands 'get_playlist' and 'set_playlist' removed and replaced by 'sonos_playlists' and - 'load_sonos_playlist' - --command "load_sonos_playlist" with parameter added. The commands loads a Sonos playlist by its name. - -- optional parameters: play_after_insert, clear_queue - -- command "play_tunein" added - -- 'play_tunein' expects a radio station name. The name will be searched within TuneIn and the - first match is played. To make sure the correct radio station is played provide the full radio - station showing in the Sonos app. - -- 'clear_queue' command added. The command clears the current queue. - -- version check against Sonos Broker to identify an out-dated plugin or Broker - -## Requirements: +##Requirements: - sonos_broker server v0.8.3 + Sonos Broker v1.0 (https://github.com/pfischi/shSonos) - SmarthomeNG + SmarthomeNG 1.3 (https://github.com/smarthomeNG/smarthome) -## Integration in Smarthome.py +##Integration in SmarthomeNG Go to /usr/smarthome/etc and edit plugins.conf and add ths entry: @@ -57,9 +59,7 @@ the same system. The ***refresh*** parameter specifies, how often the broker is requested for sonos status updates (default: 120s). Normally, all changes to the speakers will be triggered automatically to the plugin. -Go to /usr/smarthome/items - -Create a file named sonos.conf. +Go to /usr/smarthome/items. Create a file named sonos.conf or copy the sonos.conf from the examples folder. Edit file with this sample of mine: @@ -90,8 +90,6 @@ Edit file with this sample of mine: [[volume]] type = num - enforce_updates = True - visu_acl = rw sonos_recv = volume sonos_send = volume @@ -99,6 +97,16 @@ Edit file with this sample of mine: type = bool value = 0 + [[[volume_dpt3]]] + type = list + sonos_volume_dpt3 = foo + sonos_vol_step = 2 + sonos_vol_time = 1 + + [[[[helper]]]] + type = num + sonos_send = volume + [[max_volume]] type = num enforce_updates = True @@ -238,6 +246,10 @@ Edit file with this sample of mine: [[[group_command]]] type = bool value = 0 + + [[[force_stream_mode]]] + type = bool + value = 0 [[radio_show]] type = str @@ -325,6 +337,10 @@ Edit file with this sample of mine: enforce_updates = True sonos_send = unjoin visu_acl = rw + + [[[play]]] + type = bool + value = 1 [[partymode]] type = foo @@ -386,7 +402,14 @@ Edit file with this sample of mine: [[[group_command]]] type = bool value = 0 - + + [[nightmode]] + type = bool + enforce_updates = True + visu_acl = rw + sonos_recv = nightmode + sonos_send = nightmode + [[playmode]] type = str enforce_updates = True @@ -451,6 +474,11 @@ Edit file with this sample of mine: type = bool enforce_updates = True sonos_send = clear_queue + + [[transport_actions]] + type = str + sonos_recv = transport_actions + This sonos.conf file implements most of the commands to interact with the Sonos Broker. Please follow the detailed @@ -463,7 +491,49 @@ Edit file with this sample of mine: http:///client/list -## Group behaviour +##Volume DPT3 support + +If you take look at the ```volume``` item in your Sonos items configuration you should find something like this: +``` +[[volume]] + type = num + sonos_recv = volume + sonos_send = volume + + [[[group_command]]] + type = bool + value = 0 + + [[[volume_dpt3]]] + type = list + sonos_volume_dpt3 = foo + sonos_vol_step = 2 + sonos_vol_time = 1 + + [[[[helper]]]] + type = num + sonos_send = volume +``` +If you want to use a dim-like functionality to control the volume (e.g. with a button), you can edit the +```volume_dpt3``` item. ***sonos_vol_step*** (default: 2) defines the volume step for up and down, ***sonos_vol_time*** +(default: 1) the time between the steps. Both values are optional, if not set, the default value is used. A real-world +example could look like this: +``` +[[[volume_dpt3]]] + type = list + knx_dpt = 3 + knx_listen = 7/0/0 + sonos_volume_dpt3 = foo + sonos_vol_step = 2 + sonos_vol_time = 1 + + [[[[helper]]]] + type = num + sonos_send = volume +``` +Don't change the items name, otherwise the function will not work. + +##Group behaviour If two or more speakers are in the same zone, most of the commands are automatically executed for all zone members. Normally the Sonos API requires to send the command to the zone master. This is done by the Broker @@ -501,7 +571,7 @@ Edit file with this sample of mine: loudness balance -## Methods +##Methods get_favorite_radiostations(, ) @@ -559,36 +629,22 @@ discover() sh.sonos.discover() -## smartVISU Integration - -more information here: https://github.com/pfischi/shSonos/tree/develop/widget.smartvisu +##smartVISU Integration +More information [--> HERE <--](https://github.com/pfischi/shSonos/tree/develop/widget.smartvisu) -## Logic examples - -To run this plugin with a logic, here is my example: - -Go to /usr/smarthome/logics and create a self-named file (e.g. sonos.py) -Edit this file and place your logic here: - - - #!/usr/bin/env python - # - - if sh.ow.ibutton(): - sh.sonos.mute(1) - else: - sh.sonos.mute(0) +##FAQ - - Last step: go to /usr/smarthome/etc and edit logics.conf - Add a section for your logic: - - # logic - [sonos_logic] - filename = sonos.py - watch_item = ow.ibutton - - -In this small example, the sonos speaker with uid RINCON_000E58D5892E11230 is muted when the iButton is connected -to an iButton Probe. \ No newline at end of file +##### utf-8 codec error +If you're using Onkelandy's SmarthomeNG Image (and other Linux distros), following error can occurred if you're using +non-ASCII characters for Sonos speaker names: +``` +UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb0 in position 37: invalid start byte +``` +This happens because the 'stdout' setting of the system is set to an ASCII character set. You can this by entering +following command in your console: +``` +export LC_ALL=de_DE.utf8 +export LANGUAGE=de_DE.utf8 +``` +For more information about 'locales', please follow this [--> LINK <--](https://www.thomas-krenn.com/de/wiki/Locales_unter_Ubuntu_konfigurieren) \ No newline at end of file diff --git a/plugin.sonos/__init__.py b/plugin.sonos/__init__.py index 8084fb0..b85546e 100755 --- a/plugin.sonos/__init__.py +++ b/plugin.sonos/__init__.py @@ -1,46 +1,25 @@ #!/usr/bin/env python3 -# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab -# ######################################################################## -# Copyright 2013 KNX-User-Forum e.V. http://knx-user-forum.de/ -######################################################################### -# This file is part of SmartHome.py. http://mknx.github.io/smarthome/ -# -# SmartHome.py is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# SmartHome.py is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with SmartHome.py. If not, see . -######################################################################### import http import logging import lib.connection import lib.tools -import os import re -import socket import threading import json -import fcntl -import struct import requests +import time +from lib.model.smartplugin import SmartPlugin -EXPECTED_BROKER_VERSION = "0.9" -logger = logging.getLogger('') +EXPECTED_BROKER_VERSION = "1.0" sonos_speaker = {} class UDPDispatcher(lib.connection.Server): def __init__(self, ip, port, sh): + self._logger = logging.getLogger('sonos') lib.connection.Server.__init__(self, ip, port, proto='UDP') self.dest = 'udp:' + ip + ':{port}'.format(port=port) - logger.debug('starting udp listener with {url}'.format(url=self.dest)) + self._logger.debug('starting udp listener with {url}'.format(url=self.dest)) self._sh = sh self.connect() @@ -48,9 +27,9 @@ def handle_connection(self): try: data, address = self.socket.recvfrom(10000) address = "{}:{}".format(address[0], address[1]) - logger.debug("{}: incoming connection from {}".format('sonos', address)) + self._logger.debug("{}: incoming connection from {}".format('sonos', address)) except Exception as err: - logger.error("{}: {}".format(self._name, err)) + self._logger.error("{}: {}".format(self._name, err)) return try: @@ -58,9 +37,9 @@ def handle_connection(self): uid = sonos['uid'] if not uid: - logger.error("No uid found in sonos udp response!\nResponse: {}") + self._logger.error("No uid found in sonos udp response!\nResponse: {}") if uid not in sonos_speaker: - logger.warning("no sonos speaker configured with uid '{uid}".format(uid=uid)) + self._logger.warning("no sonos speaker configured with uid '{uid}".format(uid=uid)) return for key, value in sonos.items(): @@ -71,20 +50,28 @@ def handle_connection(self): item(value, 'Sonos', '') except Exception as err: - logger.error("Error parsing sonos broker response!\nError: {}".format(err)) + self._logger.error("Error parsing sonos broker response!\nError: {}".format(err)) -class Sonos(): - def __init__(self, smarthome, listen_host='0.0.0.0', listen_port=9999, broker_url=None, refresh=120): +class Sonos(SmartPlugin): + PLUGIN_VERSION = "1.3.0.1" + ALLOW_MULTIINSTANCE = False + + def __init__(self, sh, listen_host='0.0.0.0', listen_port=9999, broker_url=None, refresh=120): + self._sonoslock = threading.Lock() self._lan_ip = get_lan_ip() + self._logger = logging.getLogger('sonos') + self._dpt3_vol_step = 1 + self._dpt3_vol_time = 1 + self._dpt3_vol_max = 100 if not self._lan_ip: - logger.critical("Could not fetch internal ip address. Set it manually!") + self._logger.critical("Could not fetch internal ip address. Set it manually!") self.alive = False return - logger.info("using local ip address {ip}".format(ip=self._lan_ip)) + self._logger.info("using local ip address {ip}".format(ip=self._lan_ip)) # check broker variable if broker_url: @@ -92,10 +79,10 @@ def __init__(self, smarthome, listen_host='0.0.0.0', listen_port=9999, broker_ur else: self._broker_url = "http://{ip}:12900".format(ip=self._lan_ip) if self._broker_url: - logger.warning("No broker url given, assuming current ip and default broker port: {url}". - format(url=self._broker_url)) + self._logger.warning("No broker url given, assuming current ip and default broker port: {url}". + format(url=self._broker_url)) else: - logger.error("Could not detect broker url !!!") + self._logger.error("Could not detect broker url !!!") return # normalize broker url @@ -105,30 +92,29 @@ def __init__(self, smarthome, listen_host='0.0.0.0', listen_port=9999, broker_ur # version check against Sonos Broker broker_version = self._send_cmd(SonosCommand.sonos_broker_version()) - logger.debug("Sonos broker version: {version}".format(version=broker_version)) + self._logger.debug("Sonos broker version: {version}".format(version=broker_version)) try: if EXPECTED_BROKER_VERSION != broker_version: - logger.warning("This plugin is desgined to work with Sonos Broker version {version}. " - "Your plugin version is probably out-of-date or too new. " - "Please update your plugin and/or the Sonos Broker Server".format( + self._logger.warning("This plugin is desgined to work with Sonos Broker version {version}. " + "Your plugin version is probably out-of-date or too new. " + "Please update your plugin and/or the Sonos Broker Server".format( version=EXPECTED_BROKER_VERSION)) except Exception: - logger.warning("Unknown Sonos broker version string '{version_string}.'". - format(version_string=broker_version)) + self._logger.warning("Unknown Sonos broker version string '{version_string}.'". + format(version_string=broker_version)) - # ini vars self._listen_host = listen_host self._listen_port = listen_port - self._sh = smarthome + self._sh = sh self._command = SonosCommand() - logger.debug('refresh sonos speakers every {refresh} seconds'.format(refresh=refresh)) + self._logger.debug('refresh sonos speakers every {refresh} seconds'.format(refresh=refresh)) # add current_state command to scheduler self._sh.scheduler.add('sonos-update', self._subscribe, cycle=refresh) # start UDP listener - UDPDispatcher(self._listen_host, self._listen_port, smarthome) + UDPDispatcher(self._listen_host, self._listen_port, self._sh) def run(self): self.alive = True @@ -138,7 +124,7 @@ def _subscribe(self): """ Subscribe the plugin to the Sonos Broker """ - logger.debug('(re)registering to sonos broker server ...') + self._logger.debug('(re)registering to sonos broker server ...') self._send_cmd(SonosCommand.subscribe(self._lan_ip, self._listen_port)) for uid, speaker in sonos_speaker.items(): @@ -148,7 +134,7 @@ def _unsubscribe(self): """ Unsubscribe the plugin from the Sonos Broker """ - logger.debug('unsubscribing from sonos broker server ...') + self._logger.debug('unsubscribing from sonos broker server ...') self._send_cmd(SonosCommand.unsubscribe(self._lan_ip, self._listen_port)) def stop(self): @@ -162,11 +148,14 @@ def stop(self): def _resolve_uid(self, item): uid = None - parent_item = item.return_parent() + if 'volume_dpt3.helper' in item._name: + parent_item = item.return_parent().return_parent().return_parent() + else: + parent_item = item.return_parent() if (parent_item is not None) and ('sonos_uid' in parent_item.conf): uid = parent_item.conf['sonos_uid'].lower() else: - logger.warning("sonos: could not resolve sonos_uid".format(item)) + self._logger.warning("sonos: could not resolve sonos_uid".format(item)) return uid def parse_item(self, item): @@ -178,7 +167,7 @@ def parse_item(self, item): return None attr = item.conf['sonos_recv'] - logger.debug("sonos: {} receives updates by {}".format(item, attr)) + self._logger.debug("sonos: {} receives updates by {}".format(item, attr)) if not uid in sonos_speaker: sonos_speaker[uid] = SonosSpeaker() @@ -189,20 +178,71 @@ def parse_item(self, item): attr_list.append(item) if 'sonos_send' in item.conf: - try: - self._sonoslock.acquire() - uid = self._resolve_uid(item) - if uid is None: - return None + uid = self._resolve_uid(item) + if uid is None: + return None + + attr = item.conf['sonos_send'] + self._logger.debug("sonos: {} is send to {}".format(item, attr)) + return self._update_item + + if self.has_iattr(item.conf, 'sonos_volume_dpt3'): + if not self.has_iattr(item.conf, 'sonos_vol_step'): + item.conf['sonos_vol_step'] = self._dpt3_vol_step + self._logger.warning("Sonos: no sonos_vol_step defined, using default value {step}.". + format(step=self._dpt3_vol_step)) + + if not self.has_iattr(item.conf, 'sonos_vol_time'): + item.conf['sonos_vol_time'] = self._dpt3_vol_time + self._logger.warning("Sonos: no sonos_vol_time defined, using default value {time}.". + format(time=self._dpt3_vol_time)) + + if not self.has_iattr(item.conf, 'sonos_vol_max'): + item.conf['sonos_vol_max'] = self._dpt3_vol_max + self._logger.warning("Sonos: no sonos_vol_max defined, using default value {max}.". + format(max=self._dpt3_vol_max)) - attr = item.conf['sonos_send'] - logger.debug("sonos: {} is send to {}".format(item, attr)) - return self._update_item - finally: - self._sonoslock.release() + return self._handle_volume_dpt3 return None + def _handle_volume_dpt3(self, item, caller=None, source=None, dest=None): + self._logger.debug(caller) + if caller != 'Sonos': + + volume_helper = None + + volume_item = item.return_parent() + if volume_item is None: + self._logger.warning("Sonos: no parent volume item found for volume_dpt3 item!") + return + + dpt3_helper_name = '{}.helper'.format(item._name) + + for child in item.return_children(): + if child._name == dpt3_helper_name: + volume_helper = child + + if volume_helper is None: + self._logger.warning("Sonos: no child helper item found for volume_dpt3 item!") + return + + volume_helper(volume_item()) + vol_step = int(item.conf['sonos_vol_step']) + vol_time = int(item.conf['sonos_vol_time']) + vol_max = int(item.conf['sonos_vol_max']) + + if item()[1] == 1: + if item()[0] == 1: + # up + volume_helper.fade(vol_max, vol_step, vol_time) + else: + # down + volume_helper.fade(0-vol_step, vol_step, vol_time) + else: + volume_helper(int(volume_helper() + 1)) + volume_helper(int(volume_helper() - 1)) + def parse_logic(self, logic): pass @@ -259,6 +299,7 @@ def _update_item(self, item, caller=None, source=None, dest=None): if child._name.lower() == group_item_name.lower(): group_command = child() break + value = 0 if value < 0 else value cmd = self._command.volume(uid, value, group_command) if command == 'max_volume': @@ -301,6 +342,10 @@ def _update_item(self, item, caller=None, source=None, dest=None): break cmd = self._command.treble(uid, value, group_command) + if command == 'nightmode': + if isinstance(value, bool): + cmd = self._command.nightmode(uid, value) + if command == 'loudness': if isinstance(value, bool): group_item_name = '{}.group_command'.format(item._name) @@ -316,7 +361,7 @@ def _update_item(self, item, caller=None, source=None, dest=None): if value in ['normal', 'shuffle_norepeat', 'shuffle', 'repeat_all']: cmd = self._command.playmode(uid, value) else: - logger.warning( + self._logger.warning( "Ignoring PLAYMODE command. Value {value} not a valid paramter!".format(value=value)) if command == 'next': @@ -369,8 +414,10 @@ def _update_item(self, item, caller=None, source=None, dest=None): force_stream_mode = child() if child._name.lower() == fade_item_name.lower(): fade_in = child() - cmd = self._command.play_tts(uid, value, language, volume, group_command, force_stream_mode, - fade_in) + if child._name.lower() == fade_item_name.lower(): + fade_in = child() + cmd = self._command.play_tts(uid, value, language, volume, group_command, fade_in, + force_stream_mode) if command == 'load_sonos_playlist': clear_item_name = '{}.clear_queue'.format(item._name) @@ -386,7 +433,7 @@ def _update_item(self, item, caller=None, source=None, dest=None): if command == 'seek': if not re.match(r'^[0-9][0-9]?:[0-9][0-9]:[0-9][0-9]$', value): - logger.warning('invalid timestamp for sonos seek command, use HH:MM:SS format') + self._logger.warning('invalid timestamp for sonos seek command, use HH:MM:SS format') cmd = None else: cmd = self._command.seek(uid, value) @@ -398,7 +445,13 @@ def _update_item(self, item, caller=None, source=None, dest=None): cmd = self._command.join(uid, value) if command == 'unjoin': - cmd = self._command.unjoin(uid) + play_item_name = '{}.play'.format(item._name) + play = 0 + for child in item.return_children(): + if child._name.lower() == play_item_name.lower(): + play = child() + break + cmd = self._command.unjoin(uid, play) if command == 'partymode': cmd = self._command.partymode(uid) @@ -432,10 +485,10 @@ def _update_item(self, item, caller=None, source=None, dest=None): for child in item.return_children(): if child._name.lower() == persistent_item_name.lower(): if value != 0 and persistent == 1: - logger.warning("command wifi_state: persistent parameter with value '1' will" - "only affect wifi_state with value '1' (the wifi interface will" - "remain deactivated after reboot). Ignoring 'persistent' " - "parameter.") + self._logger.warning("command wifi_state: persistent parameter with value '1' will" + "only affect wifi_state with value '1' (the wifi interface will" + "remain deactivated after reboot). Ignoring 'persistent' " + "parameter.") else: persistent = child() break @@ -448,7 +501,7 @@ def _update_item(self, item, caller=None, source=None, dest=None): def _send_cmd(self, payload): try: - logger.debug("Sending request: {0}".format(payload)) + self._logger.debug("Sending request: {0}".format(payload)) headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} response = requests.post(self._broker_url, data=json.dumps(payload), headers=headers) @@ -457,16 +510,16 @@ def _send_cmd(self, payload): html_end = "" if response.status_code == 200: - logger.info("Sonos: Message %s %s successfully sent - %s %s" % - (self._broker_url, payload, response.status_code, response.reason)) + self._logger.info("Sonos: Message %s %s successfully sent - %s %s" % + (self._broker_url, payload, response.status_code, response.reason)) return response.text.replace(html_start, "", 1).replace(html_end, "", 1) else: - logger.warning("Sonos: Could not send message %s %s - %s %s" % - (self._broker_url, payload, response.status_code, response.text)) + self._logger.warning("Sonos: Could not send message %s %s - %s %s" % + (self._broker_url, payload, response.status_code, response.text)) return None except Exception as e: - logger.warning( + self._logger.warning( "Could not send sonos notification: {0}. Error: {1}".format(payload, e)) def _send_cmd_response(self, cmd): @@ -477,24 +530,24 @@ def _send_cmd_response(self, cmd): conn.request("GET", cmd) response = conn.getresponse() if response.status == 200: - logger.info("Sonos: Message %s %s successfully sent - %s %s" % - (self._broker_url, cmd, response.status, response.reason)) + self._logger.info("Sonos: Message %s %s successfully sent - %s %s" % + (self._broker_url, cmd, response.status, response.reason)) data = response.read() else: - logger.warning("Sonos: Could not send message %s %s - %s %s" % - (self._broker_url, cmd, response.status, response.reason)) + self._logger.warning("Sonos: Could not send message %s %s - %s %s" % + (self._broker_url, cmd, response.status, response.reason)) conn.close() return data except Exception as e: - logger.warning( + self._logger.warning( "Could not send sonos notification: {0}. Error: {1}".format(cmd, e)) - logger.debug("Sending request: {0}".format(cmd)) + self._logger.debug("Sending request: {0}".format(cmd)) def load_sonos_playlist(self, uid, sonos_playlist, play_after_insert=0, clear_queue=0): return self._send_cmd(SonosCommand.load_sonos_playlist(uid, sonos_playlist, play_after_insert, - clear_queue)) + clear_queue)) def get_favorite_radiostations(self, start_item=0, max_items=50): return self._send_cmd_response(SonosCommand.favradio(start_item, max_items)) @@ -503,7 +556,7 @@ def refresh_media_library(self, display_option='none'): return self._send_cmd(SonosCommand.refresh_media_library(display_option)) def version(self): - return "v0.9\t2016-11-20" + return "v1.0\t2017-02-15" def discover(self): return self._send_cmd(SonosCommand.discover()) @@ -549,14 +602,16 @@ def __init__(self): self.treble = [] self.loudness = [] self.playmode = [] + self.nightmode = [] self.alarms = [] self.tts_local_mode = [] self.wifi_state = [] self.balance = [] self.sonos_playlists = [] + self.transport_actions = [] -class SonosCommand: +class SonosCommand: @staticmethod def subscribe(ip, port): return { @@ -598,11 +653,12 @@ def join(uid, value): } @staticmethod - def unjoin(uid): + def unjoin(uid, play=0): return { 'command': 'unjoin', 'parameter': { - 'uid': '{uid}'.format(uid=uid) + 'uid': '{uid}'.format(uid=uid), + 'play': play } } @@ -839,13 +895,23 @@ def loudness(uid, value, group_command=0): } } + @staticmethod + def nightmode(uid, value): + return { + 'command': 'set_nightmode', + 'parameter': { + 'nightmode': int(value), + 'uid': '{uid}'.format(uid=uid) + } + } + @staticmethod def sonos_playlists(uid): return { 'command': 'sonos_playlists', 'parameter': { 'uid': uid.lower(), - } + } } @staticmethod @@ -879,15 +945,16 @@ def sonos_broker_version(): @staticmethod def favradio(start_item, max_items): + _logger = logging.getLogger('sonos') try: start_item = int(start_item) except ValueError: - logger.error('favradio: command ignored - start_item value \'{}\' is not an integer'.format(start_item)) + _logger.error('favradio: command ignored - start_item value \'{}\' is not an integer'.format(start_item)) return try: max_items = int(max_items) except ValueError: - logger.error('favradio: command ignored - max_items value \'{}\' is not an integer'.format(max_items)) + _logger.error('favradio: command ignored - max_items value \'{}\' is not an integer'.format(max_items)) return return { 'command': 'get_favorite_radio_stations', @@ -899,10 +966,11 @@ def favradio(start_item, max_items): @staticmethod def refresh_media_library(display_option): + _logger = logging.getLogger('sonos') display_option = display_option.lower() if display_option not in ['none', 'itunes', 'wmp']: - logger.warning("refresh_media_library: invalid 'display_option' value '{val}'. Value has to be 'none', " - "'itunes' or 'wmp'. Using default value 'none'.".format(val=display_option)) + _logger.warning("refresh_media_library: invalid 'display_option' value '{val}'. Value has to be 'none', " + "'itunes' or 'wmp'. Using default value 'none'.".format(val=display_option)) display_option = 'none' return { 'command': 'refresh_media_library', @@ -929,33 +997,14 @@ def clear_queue(uid): # UTIL FUNCTIONS ####################################################################### -def get_interface_ip(ifname): - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - return socket.inet_ntoa(fcntl.ioctl(s.fileno(), 0x8915, struct.pack('256s', ifname[:15].encode('utf-8')))[20:24]) - - def get_lan_ip(): try: - ip = socket.gethostbyname(socket.gethostname()) - if ip.startswith("127.") and os.name != "nt": - interfaces = ["eth0", "eth1", "eth2", "wlan0", "wlan1", "wifi0", "ath0", "ath1", "ppp0"] - for ifname in interfaces: - try: - ip = get_interface_ip(ifname) - break - except IOError: - pass - return ip - except Exception as err: - return get_lan_ip_fallback() - -def get_lan_ip_fallback(): - try: + import socket s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - s.connect(('', 0)) - return s.getsockname()[0] - except Exception as err: - logger.critical(err) + s.settimeout(5) + s.connect(("google.com", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except: return None - diff --git a/plugin.sonos/examples/sonos.conf b/plugin.sonos/examples/sonos.conf index 53e6289..46b171b 100644 --- a/plugin.sonos/examples/sonos.conf +++ b/plugin.sonos/examples/sonos.conf @@ -30,8 +30,6 @@ [[volume]] type = num - enforce_updates = True - visu_acl = rw sonos_recv = volume sonos_send = volume @@ -39,6 +37,16 @@ type = bool value = 0 + [[[volume_dpt3]]] + type = list + sonos_volume_dpt3 = foo + sonos_vol_step = 2 + sonos_vol_time = 1 + + [[[[helper]]]] + type = num + sonos_send = volume + [[max_volume]] type = num enforce_updates = True @@ -184,14 +192,14 @@ type = bool value = 0 - [[[force_stream_mode]]] - type = bool - value = 0 - [[[fade_in]]] type = bool value = 1 + [[[force_stream_mode]]] + type = bool + value = 0 + [[radio_show]] type = str sonos_recv = radio_show @@ -279,6 +287,10 @@ sonos_send = unjoin visu_acl = rw + [[[play]]] + type = bool + value = 1 + [[partymode]] type = foo enforce_updates = True @@ -340,6 +352,13 @@ type = bool value = 0 + [[nightmode]] + type = bool + enforce_updates = True + visu_acl = rw + sonos_recv = nightmode + sonos_send = nightmode + [[playmode]] type = str enforce_updates = True @@ -356,7 +375,7 @@ [[is_coordinator]] type = bool - sonos_recv = playmode + sonos_recv = is_coordinator [[tts_local_mode]] type = bool @@ -393,4 +412,8 @@ [[clear_queue]] type = bool enforce_updates = True - sonos_send = clear_queue \ No newline at end of file + sonos_send = clear_queue + + [[transport_actions]] + type = str + sonos_recv = transport_actions diff --git a/server.sonos/CHANGES.txt b/server.sonos/CHANGES.txt index d11240d..87ab4ca 100644 --- a/server.sonos/CHANGES.txt +++ b/server.sonos/CHANGES.txt @@ -1,3 +1,50 @@ +v1.0 (2017-02-18) + + -- Attention: cleaned-up configuration file. Please re-configure your Sonos Broker installation + -- command "transport_actions" to Sonos Broker and Sonos command line tool + -- this options shows all possible actions for the current track (e.g. Next, Stop, Play ...) + -- command 'nightmode' added (only for supported speakers) + -- bug: Spotify Radio was handled as a normal radio station and should be fixed + -- GoogleTTS improvements + -- GoogleTTS: files now stored with md5 sum of tts_language and tts_string to reduce the filename length + -- GoogleTTS: now works in streaning mode per default, no web service is needed. + -- GoogleTTS: the local ip address for the streaming url will be detected automatically (by default) + -- play_tts: (optional) attribute 'force_stream_mode' (re)-added to Sonos Broker and Command line tool + -- bug: endless loop while trying to play a track from a non-existing url + -- bug: wrong path in systemd script + -- bug: the 'volume' of all zone members will now be restored correctly after playing the snippet + -- command optional parameter 'play' added to command "unjoin" + -- with 'play' set to true, the prevoiusly played track (before joining a group) will resumed + -- changed executable name from "sonos_broker" to "sonos-broker" + -- changed command line tool from "sonos_cmd" to "sonos-cmd" + -- changed default installation path of sonos_broker.cfg to /etc/default/sonos-broker + -- stopping a running Sonos Broker instance is handled a bit more gracefully + -- updated setup script + -- auto-start scripts for systemd and upstart automatically placed in the appropriate folder by the + installation script + -- 'daemonize' behaviour removed + -- unnecessary parameter 'stop' removed + -- updated documentation + -- added "zone_member" command to Sonos Broker (to retrieve this value actively) + -- added commands 'join' and 'unjoin' to Sonos Broker commandline tool + -- bug: error when trying to decode non-ascii chars and the systems stdout was no set to utf8 + + +v0.9 (2016-11-20) + + -- added missing 'track_album' property + -- added missing 'track_album' to Sonos-Broker commandline + -- added "get_playlist_position" command and "playlist_position" property to Sonos Broker and + Sonos Broker commandline tool + -- added "get_playlist_total_tracks" command and "playlist_total_tracks" property to Sonos Broker and + Sonos Broker commandline tool + -- added missing 'track_album_art' property to Sonos Broker commandline tool + -- bug: 'playlist_position' was not handled correctly + -- changed maximum snippet length to 15 seconds + -- bugfixed: Sonos Broker user-specific server port was ignored + -- updated documentation + + v.0.8.2 (2016-11-14) -- fixed bug in GoogleTTS diff --git a/server.sonos/MANIFEST b/server.sonos/MANIFEST deleted file mode 100644 index 00b4d42..0000000 --- a/server.sonos/MANIFEST +++ /dev/null @@ -1,37 +0,0 @@ -# file GENERATED by distutils, do NOT edit -setup.py -sonos_broker -sonos_broker.cfg -sonos_cmd -lib_sonos/__init__.py -lib_sonos/daemon.py -lib_sonos/definitions.py -lib_sonos/radio_parser.py -lib_sonos/sonos_commands.py -lib_sonos/sonos_library.py -lib_sonos/sonos_service.py -lib_sonos/sonos_speaker.py -lib_sonos/tts.py -lib_sonos/udp_broker.py -lib_sonos/utils.py -soco/__init__.py -soco/alarms.py -soco/cache.py -soco/compat.py -soco/config.py -soco/core.py -soco/data_structures.py -soco/discovery.py -soco/events.py -soco/exceptions.py -soco/groups.py -soco/ms_data_structures.py -soco/music_library.py -soco/services.py -soco/snapshot.py -soco/soap.py -soco/utils.py -soco/xml.py -soco/music_services/__init__.py -soco/music_services/accounts.py -soco/music_services/music_service.py diff --git a/server.sonos/MANIFEST.in b/server.sonos/MANIFEST.in new file mode 100644 index 0000000..4bb9b50 --- /dev/null +++ b/server.sonos/MANIFEST.in @@ -0,0 +1,3 @@ +graft scripts/systemd/ +graft scripts/upstart/ +graft config diff --git a/server.sonos/config/sonos-broker b/server.sonos/config/sonos-broker new file mode 100644 index 0000000..6247db2 --- /dev/null +++ b/server.sonos/config/sonos-broker @@ -0,0 +1,78 @@ +# This is the config file for sonos broker. Adapt and uncomment the lines to your purpose. + +####################################################################################################################### + +[logging] + +#------------------------------------- +# Sets the log level for the server. WARNING is the default value. +# Possible values are: debug, info, warning (default), error, critical +# Default logfile path: /tmp/sonos-broker.log + +# loglevel = warning +# logfile = /tmp/log.txt +#------------------------------------- + +####################################################################################################################### + +[sonos_broker] + +#------------------------------------- +# Binding host address. Default: 0.0.0.0 (listening on all interfaces) + +# host = 0.0.0.0 +#------------------------------------- + + +#------------------------------------- +# Server port. Default: 12900 + +# port = 12900 +#------------------------------------- + +####################################################################################################################### + +[webservice] + +# Sonos Broker starts an own webservice. This is helpful for playing audio snippets and the Google TTS functionality. +# The server handles these files by their extension. Valid extensions are: aac, mp4, mp3, ogg, wav, web. +# This doesn't mean a Sonos speaker can playback such a file. + + +#------------------------------------- +# Webservice root path. The directory must exists and readable (better writeable) for the current user. This path is +# used for storing Google TTS files if the option 'local_goolge_tts' is set to 'true'. You can also store all your own +# audio snippets files there and play them with the 'play_snippet' command. If you leave this parameter empty, the +# webservice functionality is disabled on startup. Default: empty / deactivated + +# webservice_path = +#------------------------------------- + + +#------------------------------------- +# Specifies the destination url which Sonos Broker refers to the Sonos speakers. This must be the hosts IP address the +# webservice is running on. If the value is empty, Sonos Broker tries to detect the local IP address automatically. +# If you're inside a docker container, the automatic detection might fail. In this case you have to set the IP to the +# docker host IP. Default: empty (automatic detection) + +# webservice_ip = +#------------------------------------- + + +#------------------------------------- +# Maximum file size quota in megabytes. Up to this size, the Sonos Broker will save files to 'root_path' if +# 'local_google_tts' is set to true. +# Default: 200mb + +# quota = 200 +#------------------------------------- + + +#------------------------------------- +# The Google text-to-speech functionality streams the mp3s from Google directly to the sonos speaker. In case the +# parameter local_google_tts is set to true, the corresponding mp3 will be cached locally instead, so a connection to +# Google is only necessary in case of un-cached TTS phrases. This speeds up the TTS execution and reduces internet +# traffic. Default: false + +# local_google_tts = false +#------------------------------------- \ No newline at end of file diff --git a/server.sonos/dist/sonos-broker-0.9.tar.gz b/server.sonos/dist/sonos-broker-0.9.tar.gz deleted file mode 100644 index 51b8b73..0000000 Binary files a/server.sonos/dist/sonos-broker-0.9.tar.gz and /dev/null differ diff --git a/server.sonos/dist/sonos-broker-1.0.tar.gz b/server.sonos/dist/sonos-broker-1.0.tar.gz new file mode 100644 index 0000000..924fe1a Binary files /dev/null and b/server.sonos/dist/sonos-broker-1.0.tar.gz differ diff --git a/server.sonos/lib_sonos/daemon.py b/server.sonos/lib_sonos/daemon.py deleted file mode 100755 index 49df3e6..0000000 --- a/server.sonos/lib_sonos/daemon.py +++ /dev/null @@ -1,170 +0,0 @@ -# #!/usr/bin/python3 - -import fcntl -import os -import pwd -import grp -import sys -import signal -import resource -import logging -import atexit - -logger = logging.getLogger('') - -class Daemonize(object): - """ Daemonize object - Object constructor expects three arguments: - - app: contains the application name which will be sent to syslog. - - pid: path to the pidfile. - - action: your custom function which will be executed after daemonization. - - keep_fds: optional list of fds which should not be closed. - - privileged_action: action that will be executed before drop privileges if user or - group parameter is provided. - - user: drop privileges to this user if provided. - - group: drop privileges to this group if provided. - """ - def __init__(self, app, pid, action, keep_fds=None, privileged_action=None, user=None, group=None, verbose=False): - self.app = app - self.pid = pid - self.action = action - self.keep_fds = keep_fds or [] - self.privileged_action = privileged_action or (lambda: ()) - self.user = user - self.group = group - # Display log messages only on defined handlers. - self.verbose = verbose - - def sigterm(self, signum, frame): - """ sigterm method - These actions will be done after SIGTERM. - """ - logger.warn("Caught signal %s. Stopping daemon." % signum) - os.remove(self.pid) - sys.exit(0) - - def exit(self): - """ exit method - Cleanup pid file at exit. - """ - logger.warn("Stopping daemon.") - os.remove(self.pid) - sys.exit(0) - - def start(self): - """ start method - Main daemonization process. - """ - # Fork, creating a new process for the child. - process_id = os.fork() - if process_id < 0: - # Fork error. Exit badly. - sys.exit(1) - elif process_id != 0: - # This is the parent process. Exit. - sys.exit(0) - # This is the child process. Continue. - - # Stop listening for signals that the parent process receives. - # This is done by getting a new process id. - # setpgrp() is an alternative to setsid(). - # setsid puts the process in a new parent group and detaches its controlling terminal. - process_id = os.setsid() - if process_id == -1: - # Uh oh, there was a problem. - sys.exit(1) - - # Close all file descriptors, except the ones mentioned in self.keep_fds. - devnull = "/dev/null" - if hasattr(os, "devnull"): - # Python has set os.devnull on this system, use it instead as it might be different - # than /dev/null. - devnull = os.devnull - - for fd in range(resource.getrlimit(resource.RLIMIT_NOFILE)[0]): - if fd not in self.keep_fds: - try: - os.close(fd) - except OSError: - pass - - os.open(devnull, os.O_RDWR) - os.dup(0) - os.dup(0) - - # Set umask to default to safe file permissions when running as a root daemon. 027 is an - # octal number which we are typing as 0o27 for Python3 compatibility. - os.umask(0o27) - - # Change to a known directory. If this isn't done, starting a daemon in a subdirectory that - # needs to be deleted results in "directory busy" errors. - os.chdir("/") - - # Execute privileged action - priviled_action_result = self.privileged_action() - - # Change gid - if self.group: - try: - gid = grp.getgrnam(self.group).gr_gid - except KeyError: - logger.error("Group {0} not found".format(self.group)) - sys.exit(1) - try: - os.setgid(gid) - except OSError: - logger.error("Unable to change gid.") - sys.exit(1) - - # Change uid - if self.user: - try: - uid = pwd.getpwnam(self.user).pw_uid - except KeyError: - logger.error("User {0} not found.".format(self.user)) - sys.exit(1) - try: - os.setuid(uid) - except OSError: - logger.error("Unable to change uid.") - sys.exit(1) - - try: - # Create a lockfile so that only one instance of this daemon is running at any time. - lockfile = open(self.pid, "w") - except IOError: - logger.error("Unable to create a pidfile.") - sys.exit(1) - try: - # Try to get an exclusive lock on the file. This will fail if another process has the file - # locked. - fcntl.lockf(lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) - # Record the process id to the lockfile. This is standard practice for daemons. - lockfile.write("%s" % (os.getpid())) - lockfile.flush() - except IOError: - logger.error("Unable to lock on the pidfile.") - os.remove(self.pid) - sys.exit(1) - - # Set custom action on SIGTERM. - signal.signal(signal.SIGTERM, self.sigterm) - atexit.register(self.exit) - - logger.warn("Starting daemon.") - self.action(*priviled_action_result) - - -def get_pid(filename): - cpid = str(os.getpid()) - for pid in os.listdir('/proc'): - if pid.isdigit() and pid != cpid: - try: - with open('/proc/{}/cmdline'.format(pid), 'r') as f: - cmdline = f.readline() - if filename in cmdline: - if 'python' in cmdline: - return int(pid) - except: - pass - return 0 \ No newline at end of file diff --git a/server.sonos/lib_sonos/definitions.py b/server.sonos/lib_sonos/definitions.py index 5ed4b67..581da96 100644 --- a/server.sonos/lib_sonos/definitions.py +++ b/server.sonos/lib_sonos/definitions.py @@ -2,20 +2,12 @@ import os import tempfile -__author__ = 'pfischi' - PLAYER_SEARCH = """M-SEARCH * HTTP/1.1 HOST: 239.255.255.250:reservedSSDPport MAN: ssdp:discover MX: 1 ST: urn:schemas-upnp-org:device:ZonePlayer:1""" -MCAST_GRP = "239.255.255.250" -MCAST_PORT = 1900 - -RADIO_STATIONS = 0 -RADIO_SHOWS = 1 - NS = {'dc': '{http://purl.org/dc/elements/1.1/}', 'upnp': '{urn:schemas-upnp-org:metadata-1-0/upnp/}', '': '{urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/}'} @@ -23,20 +15,21 @@ # regular expressions to find sonos meta info through udp stream ip_pattern = '^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$' -VERSION_BUILDSTRING = "v0.9 (2016-11-20)" -VERSION = "0.9" - +VERSION_BUILDSTRING = "v1.0b7 (2017-02-14)" +VERSION = "1.0" DEFAULT_HOST = '0.0.0.0' DEFAULT_PORT = 12900 -DEFAULT_QUOTA = 100 -DEFAULT_CFG = 'sonos_broker.cfg' -DEFAULT_LOG = os.path.join(tempfile.gettempdir(), 'sonos_broker.log') -DEFAULT_PID = os.path.join(tempfile.gettempdir(), 'sonos_broker.pid') +DEFAULT_QUOTA = 200 +DEFAULT_CFG = '/etc/default/sonos-broker' +DEFAULT_LOG = os.path.join(tempfile.gettempdir(), 'sonos-broker.log') HTTP_SUCCESS = 200 HTTP_ERROR = 400 SCAN_TIMEOUT = 180 TIMESTAMP_PATTERN = "([0-5]?[0-9]):([0-5]?[0-9]):([0-5][0-9])" MB_PLAYLIST = "#so_pl#" SUBSCRIPTION_TIMEOUT = 240 - +MCAST_GRP = "239.255.255.250" +MCAST_PORT = 1900 +RADIO_STATIONS = 0 +RADIO_SHOWS = 1 \ No newline at end of file diff --git a/server.sonos/lib_sonos/radio_parser.py b/server.sonos/lib_sonos/radio_parser.py index 0376753..cdc2a90 100644 --- a/server.sonos/lib_sonos/radio_parser.py +++ b/server.sonos/lib_sonos/radio_parser.py @@ -1,7 +1,7 @@ import re import logging -logger = logging.getLogger('') +logger = logging.getLogger('sonos_broker') # dictionary: pattern_station : [ pattern_track_artist_1, pattern_track_artist_2, ...] # ALWAYS use a named group for track and artist diff --git a/server.sonos/lib_sonos/sonos_commands.py b/server.sonos/lib_sonos/sonos_commands.py index f7e1ff6..6d6c706 100644 --- a/server.sonos/lib_sonos/sonos_commands.py +++ b/server.sonos/lib_sonos/sonos_commands.py @@ -14,7 +14,7 @@ from lib_sonos.utils import underscore_to_camel from lib_sonos import definitions -logger = logging.getLogger('') +logger = logging.getLogger('sonos_broker') class MyDecoder(json.JSONDecoder): @@ -56,7 +56,7 @@ def missing_param_error(err): return "Missing parameter '{parameter}'!".format(parameter=s_args[-1]) -### CLIENT SUBSCRIBE / UNSUBSCRIE ###################################################################################### +# CLIENT SUBSCRIBE / UNSUBSCRIE ######################################################################################## class ClientSubscribe(JsonCommandBase): def __init__(self, parameter): @@ -116,7 +116,7 @@ def run(self): return self._status, self._response -### CURRENT STATE ###################################################################################################### +# CURRENT STATE ######################################################################################################## class CurrentState(JsonCommandBase): def __init__(self, parameter): @@ -148,7 +148,7 @@ def run(self): finally: return self._status, self._response -### BALANCE ############################################################################################################ +# BALANCE ############################################################################################################## class GetBalance(JsonCommandBase): def __init__(self, parameter): @@ -217,7 +217,7 @@ def run(self): finally: return self._status, self._response -### VOLUME ############################################################################################################# +# VOLUME ############################################################################################################### class GetVolume(JsonCommandBase): def __init__(self, parameter): @@ -286,7 +286,7 @@ def run(self): return self._status, self._response -# ## VOLUME UP ########################################################################################################## +# VOLUME UP ############################################################################################################ class VolumeUp(JsonCommandBase): def __init__(self, parameter): @@ -322,7 +322,7 @@ def run(self): return self._status, self._response -### VOLUME DOWN ######################################################################################################## +# VOLUME DOWN ########################################################################################################## class VolumeDown(JsonCommandBase): def __init__(self, parameter): @@ -358,7 +358,7 @@ def run(self): return self._status, self._response -### MAX VOLUME ######################################################################################################### +# MAX VOLUME ########################################################################################################### class GetMaxVolume(JsonCommandBase): def __init__(self, parameter): @@ -432,7 +432,7 @@ def run(self): return self._status, self._response -### MUTE ############################################################################################################### +# MUTE ################################################################################################################# class GetMute(JsonCommandBase): def __init__(self, parameter): @@ -494,7 +494,7 @@ def run(self): return self._status, self._response -### BASS ############################################################################################################### +# BASS ################################################################################################################# class GetBass(JsonCommandBase): def __init__(self, parameter): @@ -559,7 +559,7 @@ def run(self): return self._status, self._response -### TREBLE ############################################################################################################# +# TREBLE ############################################################################################################### class GetTreble(JsonCommandBase): def __init__(self, parameter): @@ -626,7 +626,7 @@ def run(self): return self._status, self._response -### LOUDNESS ########################################################################################################### +# LOUDNESS ############################################################################################################# class GetLoudness(JsonCommandBase): def __init__(self, parameter): @@ -696,7 +696,7 @@ def run(self): return self._status, self._response -### STOP ############################################################################################################### +# STOP ################################################################################################################# class GetStop(JsonCommandBase): def __init__(self, parameter): @@ -754,7 +754,7 @@ def run(self): return self._status, self._response -### PLAYLIST POSITION ################################################################################################## +# PLAYLIST POSITION #################################################################################################### class GetPlaylistPosition(JsonCommandBase): def __init__(self, parameter): @@ -782,7 +782,7 @@ def run(self): return self._status, self._response -### PLAYLIST TOTAL TRACKS ############################################################################################## +# PLAYLIST TOTAL TRACKS ################################################################################################ class GetPlaylistTotalTracks(JsonCommandBase): def __init__(self, parameter): @@ -810,7 +810,7 @@ def run(self): return self._status, self._response -### PLAY ############################################################################################################### +# PLAY ################################################################################################################# class GetPlay(JsonCommandBase): def __init__(self, parameter): @@ -868,7 +868,7 @@ def run(self): return self._status, self._response -### PAUSE ############################################################################################################## +# PAUSE ################################################################################################################ class GetPause(JsonCommandBase): def __init__(self, parameter): @@ -926,7 +926,7 @@ def run(self): return self._status, self._response -### RADIO STATION ###################################################################################################### +# RADIO STATION ######################################################################################################## class GetRadioStation(JsonCommandBase): def __init__(self, parameter): @@ -954,7 +954,7 @@ def run(self): return self._status, self._response -### RADIO SHOW ######################################################################################################### +# RADIO SHOW ########################################################################################################### class GetRadioShow(JsonCommandBase): def __init__(self, parameter): @@ -982,7 +982,7 @@ def run(self): return self._status, self._response -### PLAYMODE ########################################################################################################### +# PLAYMODE ############################################################################################################# class GetPlaymode(JsonCommandBase): def __init__(self, parameter): @@ -1046,7 +1046,7 @@ def run(self): return self._status, self._response -### ALARMS ############################################################################################################# +# ALARMS ############################################################################################################### class GetAlarms(JsonCommandBase): def __init__(self, parameter): @@ -1074,7 +1074,7 @@ def run(self): return self._status, self._response -### TRACK ARTIST ####################################################################################################### +# TRACK ARTIST ######################################################################################################### class GetTrackArtist(JsonCommandBase): def __init__(self, parameter): @@ -1102,7 +1102,7 @@ def run(self): return self._status, self._response -### TRACK TITLE ######################################################################################################## +# TRACK TITLE ########################################################################################################## class GetTrackTitle(JsonCommandBase): def __init__(self, parameter): @@ -1129,7 +1129,35 @@ def run(self): finally: return self._status, self._response -### TRACK ALBUM ######################################################################################################## +# TRANSPORT ACTIONS #################################################################################################### + +class GetTransportActions(JsonCommandBase): + def __init__(self, parameter): + super().__init__(parameter) + + def run(self): + try: + logger.debug( + 'COMMAND {classname} -- attributes: {attributes}'.format(classname=self.__class__.__name__, + attributes=utils.dump_attributes( + self))) + if self.uid not in sonos_speaker.sonos_speakers: + raise Exception('No speaker found with uid \'{uid}\'!'.format(uid=self.uid)) + + sonos_speaker.sonos_speakers[self.uid].dirty_property('transport_actions') + sonos_speaker.sonos_speakers[self.uid].send() + self._status = True + except requests.ConnectionError: + self._response = 'Unable to process command. Speaker with uid \'{uid}\'seems to be offline.'. \ + format(uid=self.uid) + except AttributeError as err: + self._response = JsonCommandBase.missing_param_error(err) + except Exception as err: + self._response = err + finally: + return self._status, self._response + +# TRACK ALBUM ########################################################################################################## class GetTrackAlbum(JsonCommandBase): def __init__(self, parameter): @@ -1156,7 +1184,7 @@ def run(self): finally: return self._status, self._response -### TRACK ALBUM COVER ################################################################################################## +# TRACK ALBUM COVER #################################################################################################### class GetTrackAlbumArt(JsonCommandBase): def __init__(self, parameter): @@ -1184,7 +1212,7 @@ def run(self): return self._status, self._response -### TRACK URI ########################################################################################################## +# TRACK URI ############################################################################################################ class GetTrackUri(JsonCommandBase): def __init__(self, parameter): @@ -1211,8 +1239,65 @@ def run(self): finally: return self._status, self._response +# NIGHTMODE ############################################################################################################ + +class SetNightmode(JsonCommandBase): + def __init__(self, parameter): + super().__init__(parameter) + + def run(self): + try: + logger.debug( + 'COMMAND {classname} -- attributes: {attributes}'.format(classname=self.__class__.__name__, + attributes=utils.dump_attributes( + self))) + if self.uid not in sonos_speaker.sonos_speakers: + raise Exception('No speaker found with uid \'{uid}\'!'.format(uid=self.uid)) + + if self.nightmode not in [0, 1, True, False, '1', '0']: + raise Exception('Nightmode has to be 0|1 or True|False !') + + nightmode = int(self.nightmode) + + sonos_speaker.sonos_speakers[self.uid].set_nightmode(nightmode, trigger_action=True) + self._status = True + except requests.ConnectionError: + self._response = 'Unable to process command. Speaker with uid \'{uid}\'seems to be offline.'. \ + format(uid=self.uid) + except AttributeError as err: + self._response = JsonCommandBase.missing_param_error(err) + except Exception as err: + self._response = err + finally: + return self._status, self._response + +class GetNightmode(JsonCommandBase): + def __init__(self, parameter): + super().__init__(parameter) + + def run(self): + try: + logger.debug( + 'COMMAND {classname} -- attributes: {attributes}'.format(classname=self.__class__.__name__, + attributes=utils.dump_attributes( + self))) + if self.uid not in sonos_speaker.sonos_speakers: + raise Exception('No speaker found with uid \'{uid}\'!'.format(uid=self.uid)) + + sonos_speaker.sonos_speakers[self.uid].dirty_property('nightmode') + sonos_speaker.sonos_speakers[self.uid].send() + self._status = True + except requests.ConnectionError: + self._response = 'Unable to process command. Speaker with uid \'{uid}\'seems to be offline.'. \ + format(uid=self.uid) + except AttributeError as err: + self._response = JsonCommandBase.missing_param_error(err) + except Exception as err: + self._response = err + finally: + return self._status, self._response -### LED ################################################################################################################ +# LED ################################################################################################################# class SetLed(JsonCommandBase): def __init__(self, parameter): @@ -1283,7 +1368,7 @@ def run(self): return self._status, self._response -### NEXT ############################################################################################################### +# NEXT ################################################################################################################# class Next(JsonCommandBase): def __init__(self, parameter): @@ -1318,7 +1403,7 @@ def run(self): return self._status, self._response -### PREVIOUS ########################################################################################################### +# PREVIOUS ############################################################################################################# class Previous(JsonCommandBase): def __init__(self, parameter): @@ -1354,7 +1439,7 @@ def run(self): return self._status, self._response -### TRACK POSITION ##################################################################################################### +# TRACK POSITION ####################################################################################################### class GetTrackPosition(JsonCommandBase): def __init__(self, parameter): @@ -1422,7 +1507,7 @@ def run(self): return self._status, self._response -### PARTYMODE ########################################################################################################## +# PARTYMODE ############################################################################################################ class Partymode(JsonCommandBase): def __init__(self, parameter): @@ -1450,7 +1535,7 @@ def run(self): return self._status, self._response -### JOIN ############################################################################################################### +# JOIN ################################################################################################################# class Join(JsonCommandBase): def __init__(self, parameter): @@ -1478,7 +1563,7 @@ def run(self): return self._status, self._response -### UNJOIN ############################################################################################################# +# UNJOIN ############################################################################################################### class Unjoin(JsonCommandBase): def __init__(self, parameter): @@ -1492,7 +1577,16 @@ def run(self): if self.uid not in sonos_speaker.sonos_speakers: raise Exception('No speaker found with uid \'{uid}\'!'.format(uid=self.uid)) - sonos_speaker.sonos_speakers[self.uid].unjoin() + play = False + if hasattr(self, 'play'): + if self.play in [1, True, '1', 'True', 'yes']: + play = True + elif self.play in [0, False, '0', 'False', 'no']: + play = False + else: + raise Exception('The parameter \'play\' has to be 0|1 or True|False !') + + sonos_speaker.sonos_speakers[self.uid].unjoin(play) self._status = True except requests.ConnectionError: @@ -1506,7 +1600,7 @@ def run(self): return self._status, self._response -### CLIENT LIST ######################################################################################################## +# CLIENT LIST ########################################################################################################## class ClientList(JsonCommandBase): def __init__(self, parameter): @@ -1538,7 +1632,7 @@ def run(self): return self._status, self._response -### PLAY URI ########################################################################################################### +# PLAY URI ############################################################################################################# class PlayUri(JsonCommandBase): def __init__(self, parameter): @@ -1565,7 +1659,7 @@ def run(self): return self._status, self._response -### PLAY TUNEIN RADIO ################################################################################################## +# PLAY TUNEIN RADIO #################################################################################################### class PlayTunein(JsonCommandBase): def __init__(self, parameter): @@ -1592,7 +1686,7 @@ def run(self): return self._status, self._response -### PLAY SNIPPET ####################################################################################################### +# PLAY SNIPPET ######################################################################################################### class PlaySnippet(JsonCommandBase): def __init__(self, parameter): @@ -1647,7 +1741,7 @@ def run(self): return self._status, self._response -### PLAY TTS ########################################################################################################### +# PLAY TTS ############################################################################################################# class PlayTts(JsonCommandBase): def __init__(self, parameter): @@ -1690,14 +1784,21 @@ def run(self): if volume not in range(-1, 101, 1): raise Exception('Volume has to be set between -1 and 100!') + force_stream_mode = False if hasattr(self, 'force_stream_mode'): - logger.warning("FORCE_STREAM_MOD_OPTION for play_tts is deprecated and ignored.") + if self.force_stream_mode in [1, True, '1', 'True', 'yes']: + force_stream_mode = True + elif self.force_stream_mode in [0, False, '0', 'False', 'no']: + force_stream_mode = False + else: + raise Exception('The parameter \'force_stream_mode\' has to be 0|1 or True|False !') + language = 'en' if hasattr(self, 'language'): language = self.language sonos_speaker.sonos_speakers[self.uid].play_tts(self.tts, volume, language, group_command=group_command, - fade_in=fade_in) + fade_in=fade_in, force_stream_mode=force_stream_mode) self._status = True except requests.ConnectionError: self._response = 'Unable to process command. Speaker with uid \'{uid}\'seems to be offline.'. \ @@ -1710,7 +1811,7 @@ def run(self): return self._status, self._response -### GET FAVORITE RADIO STATIONS ######################################################################################## +# GET FAVORITE RADIO STATIONS ########################################################################################## class GetFavoriteRadioStations(JsonCommandBase): def __init__(self, parameter): @@ -1746,7 +1847,7 @@ def run(self): return self._status, self._response -### GET SONOS BROKER VERSION ########################################################################################### +# GET SONOS BROKER VERSION ############################################################################################# class SonosBrokerVersion(JsonCommandBase): def __init__(self, parameter): @@ -1766,8 +1867,33 @@ def run(self): finally: return self._status, self._response +# ZoneMembers ########################################################################################################## + +class ZoneMembers(JsonCommandBase): + def __init__(self, parameter): + super().__init__(parameter) + + def run(self): + try: + logger.debug('COMMAND {classname} -- attributes: {attributes}'.format(classname=self.__class__.__name__, + attributes=utils.dump_attributes( + self))) + sonos_speaker.sonos_speakers[self.uid].dirty_property('zone_members') + sonos_speaker.sonos_speakers[self.uid].send() + self._status = True + + except requests.ConnectionError: + self._response = 'Unable to process command. Speaker with uid \'{uid}\'seems to be offline.'. \ + format(uid=self.uid) + except AttributeError as err: + self._response = JsonCommandBase.missing_param_error(err) + except Exception as err: + self._response = err + finally: + return self._status, self._response + -### IsCoordiantor ###################################################################################################### +# IsCoordiantor ######################################################################################################## class IsCoordinator(JsonCommandBase): def __init__(self, parameter): @@ -1836,7 +1962,7 @@ def run(self): finally: return self._status, self._response -### QUEUE ############################################################################################################## +# QUEUE ################################################################################################################ class ClearQueue(JsonCommandBase): def __init__(self, parameter): @@ -1928,7 +2054,7 @@ def run(self): return self._status, self._response -### MEDIA LIBRARY ###################################################################################################### +# MEDIA LIBRARY ######################################################################################################## class RefreshMediaLibrary(JsonCommandBase): def __init__(self, parameter): @@ -1958,7 +2084,7 @@ def run(self): return self._status, self._response -### Wifi State ######################################################################################################### +# Wifi State ########################################################################################################### class GetWifiState(JsonCommandBase): def __init__(self, parameter): diff --git a/server.sonos/lib_sonos/sonos_library.py b/server.sonos/lib_sonos/sonos_library.py index ade0b91..ea6a9d4 100644 --- a/server.sonos/lib_sonos/sonos_library.py +++ b/server.sonos/lib_sonos/sonos_library.py @@ -2,7 +2,7 @@ from lib_sonos import sonos_speaker from lib_sonos import utils -logger = logging.getLogger('') +logger = logging.getLogger('sonos_broker') class SonosLibrary: diff --git a/server.sonos/lib_sonos/sonos_service.py b/server.sonos/lib_sonos/sonos_service.py index 1ed99ee..17ad103 100644 --- a/server.sonos/lib_sonos/sonos_service.py +++ b/server.sonos/lib_sonos/sonos_service.py @@ -1,10 +1,17 @@ from __future__ import unicode_literals # -*- coding: utf-8 -*- +import json +import os import queue +import socketserver import weakref from collections import namedtuple import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import sys +from lib_sonos import definitions from lib_sonos import sonos_speaker from lib_sonos.sonos_speaker import SonosSpeaker from lib_sonos.definitions import SCAN_TIMEOUT @@ -14,7 +21,7 @@ from time import sleep from soco import discover from threading import Lock -from soco.data_structures import DidlAudioBroadcast +from soco.data_structures import DidlAudioBroadcast, DidlMusicTrack from soco.services import zone_group_state_shared_cache from lib_sonos import utils @@ -23,7 +30,7 @@ except ImportError: import xml.etree.ElementTree as XML -logger = logging.getLogger('') +logger = logging.getLogger('sonos_broker') NS = { 'r': 'urn:schemas-rinconnetworks-com:metadata-1-0/', @@ -39,132 +46,148 @@ log = logging.getLogger(__name__) Argument = namedtuple('Argument', 'name, vartype') Action = namedtuple('Action', 'name, in_args, out_args') +event_lock = Lock() -# noinspection PyProtectedMember -class SonosServerService(): - _sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - _sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) - def __init__(self, host, port, remote_folder, local_folder, quota, tts_enabled): - self.event_lock = Lock() - self.lock = Lock() - self.host = host - self.port = port +class WebserviceHttpHandler(BaseHTTPRequestHandler): + webroot = None - SonosSpeaker.event_queue = queue.Queue() - SonosSpeaker.set_tts(local_folder, remote_folder, quota, tts_enabled) + def do_GET(self): - p_t = threading.Thread(target=self.process_events) - p_t.daemon = True - p_t.start() - g_t = threading.Thread(target=self.get_speakers_periodically) - g_t.daemon = True - g_t.start() + file_handler = None - def unsubscribe_speaker_events(self): - for speaker in sonos_speaker.sonos_speakers.values(): - speaker.event_unsubscribe() - - def get_speakers_periodically(self): + try: + if WebserviceHttpHandler.webroot is None: + self.send_error(404, 'Service Not Enabled') + return + + # prevent path traversal + file_path = os.path.normpath('/' + self.path).lstrip('/') + file_path = os.path.join(WebserviceHttpHandler.webroot, file_path) + + if not os.path.exists(file_path): + self.send_error(404, 'File Not Found: %s' % self.path) + return + + # get registered mime-type + mime_type = utils.get_mime_type_by_filetype(file_path) + + if mime_type is None: + self.send_error(406, 'File With Unsupported Media-Type : %s' % self.path) + return + + client = "{ip}:{port}".format(ip=self.client_address[0], port=self.client_address[1]) + logger.debug("Webservice: delivering file '{path}' to client ip {client}.".format(path=file_path, + client=client)) + file = open(file_path, 'rb').read() + self.send_response(200) + self.send_header('Content-Type', mime_type) + self.send_header('Content-Length', sys.getsizeof(file)) + self.end_headers() + self.wfile.write(file) + except Exception as ex: + logger.error("Error delivering file {file}".format(file=file_path)) + logger.error(ex) + finally: + self.connection.close() - sleep_scan = SCAN_TIMEOUT + def do_POST(self): + try: + size = int(self.headers["Content-length"]) + command = self.rfile.read(size).decode('utf-8') - while 1: try: - logger.debug('active threads: {}'.format(len(threading.enumerate()))) - logger.info('scan devices ...') - zone_group_state_shared_cache.clear() - SonosServerService.discover() + from lib_sonos.sonos_commands import MyDecoder + cmd_obj = json.loads(command, cls=MyDecoder) + except AttributeError as err: + err_command = list(filter(None, err.args[0].split("'")))[-1] + self.make_response(False, "No command '{command}' found!".format(command=err_command)) + return + status, response = cmd_obj.run() + self.make_response(status, response) + logger.debug('Server response -- status: {status} -- response: {response}'.format(status=status, + response=response)) + finally: + self.connection.close() - except Exception as err: - logger.exception(err) - finally: - sleep(sleep_scan) + def make_response(self, status, response): + if status: + self.send_response(definitions.HTTP_SUCCESS, 'OK') + else: + self.send_response(definitions.HTTP_ERROR, 'Bad request') + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write("Sonos Broker".encode('utf-8')) + self.wfile.write("{response}".format(response=response).encode('utf-8')) + self.wfile.write("".encode('utf-8')) - @staticmethod - def _discover(): - return discover(timeout=2, include_invisible=False) - @classmethod - def discover(cls): - try: - with sonos_speaker._sonos_lock: +class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer): + allow_reuse_address = True - active_uids = [] - soco_speakers = SonosServerService._discover() + def shutdown(self): + self.socket.close() + HTTPServer.shutdown(self) - if soco_speakers is None: - return - speaker_to_remove = [] +class SimpleHttpServer: + def __init__(self, ip, port, root_path): + self.server = ThreadedHTTPServer((ip, port), WebserviceHttpHandler) + WebserviceHttpHandler.webroot = root_path - for soco_speaker in soco_speakers: - uid = soco_speaker.uid.lower() + def start(self): + self.thread = threading.Thread(target=self.server.serve_forever) + self.thread.daemon = True + self.thread.start() - # new speaker found, update it - if uid not in sonos_speaker.sonos_speakers: - try: - soco_speaker.get_speaker_info(refresh=True) - active_uids.append(uid) - except Exception: - # !! sometimes an offline speaker is cached and will be found by the discover function - speaker_to_remove.append(uid) - continue - try: - _sp = SonosSpeaker(soco_speaker) - sonos_speaker.sonos_speakers[uid] = _sp - except Exception as ex: - speaker_to_remove.append(uid) - continue # speaker maybe deleted by another thread - else: - try: - sonos_speaker.sonos_speakers[uid].soco.get_speaker_info(refresh=True) - active_uids.append(uid) - except Exception: - speaker_to_remove.append(uid) - continue # speaker maybe deleted by another thread - try: - offline_uids = set(list(sonos_speaker.sonos_speakers.keys())) - set(active_uids) - offline_uids = set(list(offline_uids) + speaker_to_remove) - except KeyError as err: - print(err) - pass # speaker maybe deleted by another thread + def waitForThread(self): + self.thread.join() - for uid in offline_uids: - logger.info("offline speaker: {uid} -- removing from list (maybe cached)".format(uid=uid)) - try: - sonos_speaker.sonos_speakers[uid].status = False - sonos_speaker.sonos_speakers[uid].send() - sonos_speaker.sonos_speakers[uid].terminate() - except KeyError: - continue # speaker maybe deleted by another thread - finally: - try: - del sonos_speaker.sonos_speakers[uid] - except: - pass + def stop(self): + self.server.shutdown() + self.waitForThread() - # register events for all speaker, this has to be the last step due to some logics in the event - # handling routine - for speaker in sonos_speaker.sonos_speakers.values(): - try: - speaker.set_zone_coordinator() - speaker.set_group_members() - speaker.event_subscription() - except KeyError: - pass # speaker maybe deleted by another thread +class GetSonosSpeakerThread: + def __init__(self): + self._running_flag = False + self.stop = threading.Event() + self.thread = threading.Thread(target=self.get_speakers_periodically) + self.thread.daemon = True + self.thread.start() - except ReferenceError: - pass + def get_speakers_periodically(self): + try: + while not self.stop.wait(1): + self._running_flag = True + logger.debug('active threads: {}'.format(len(threading.enumerate()))) + logger.info('scan devices ...') + zone_group_state_shared_cache.clear() + SonosServerService.discover() + logger.debug('Start wait') + self.stop.wait(SCAN_TIMEOUT) + logger.debug('Done waiting') except Exception as err: - logger.exception('Error in method discover()!\nError: {err}'.format(err=err)) + logger.exception(err) finally: - pass + self._running_flag = False + + def terminate(self): + self.stop.set() + logger.debug("GetSonosSpeakerThread terminated") + + +class SonosEventThread: + def __init__(self): + self._running_flag = False + self.stop = threading.Event() + self.thread = threading.Thread(target=self.process_events) + self.thread.daemon = True + self.thread.start() def process_events(self): speakers = [] - while True: + while not self.stop.wait(1): try: event = SonosSpeaker.event_queue.get() if event is None: @@ -189,13 +212,12 @@ def process_events(self): print("No sonos speaker found for subscription {}".format(event.sid.lower())) continue - with sonos_speaker._sonos_lock: - try: - speaker = weakref.proxy(sonos_speaker.sonos_speakers[uid]) - if speaker not in speakers: - speakers.append(speaker) - except KeyError: - pass # speaker maybe removed from another thread + try: + speaker = weakref.proxy(sonos_speaker.sonos_speakers[uid]) + if speaker not in speakers: + speakers.append(speaker) + except KeyError: + pass # speaker maybe removed from another thread if event.service.service_type == 'ZoneGroupTopology': speaker.set_zone_coordinator() @@ -211,102 +233,31 @@ def process_events(self): if event.service.service_type == 'AlarmClock': self.handle_AlarmClock_event(speaker, event.variables) + SonosSpeaker.event_queue.task_done() + except queue.Empty: pass - except KeyboardInterrupt: - break finally: - self.event_lock.acquire() - SonosSpeaker.event_queue.task_done() if not SonosSpeaker.event_queue.unfinished_tasks: for speaker in speakers: speaker.send() del speakers[:] - self.event_lock.release() - - @staticmethod - def set_radio_data(speaker, variables): - - speaker.streamtype = "radio" - speaker.track_duration = "00:00:00" - speaker.radio_station = '' - speaker.radio_show = '' - - radio_station_title = variables['enqueued_transport_uri_meta_data'] - radio_data = variables['current_track_meta_data'] - - if hasattr(radio_station_title, 'title'): - speaker.radio_station = radio_station_title.title - - if hasattr(radio_data, 'radio_show'): - # the format of a radio_show item seems to be this format: - # <,p123456> --> rstrip ,p.... - radio_show = radio_data.radio_show - if radio_show: - radio_show = radio_show.split(',p', 1) - if len(radio_show) > 1: - speaker.radio_show = radio_show[0] - - if hasattr(radio_data, 'album_art_uri'): - speaker.track_album_art = '' - album_art = radio_data.album_art_uri - if album_art: - if not album_art.startswith(('http:', 'https:')): - album_art = 'http://' + speaker.ip + ':1400' + album_art - speaker.track_album_art = album_art - - if hasattr(radio_data, 'stream_content'): - ignore_title_string = ('ZPSTR_BUFFERING', 'ZPSTR_BUFFERING', 'ZPSTR_CONNECTING', 'x-sonosapi-stream') - artist = '' - title = '' - - stream_content = radio_data.stream_content - - if stream_content: - if not stream_content.startswith(ignore_title_string): - # if radio, in most cases the following format is used: artist - title - # if stream_content is not null, radio is assumed - - artist, title = title_artist_parser(speaker.radio_station if speaker.radio_station else '', - stream_content) - speaker.track_artist = artist - speaker.track_title = title - - @staticmethod - def set_music_data(speaker, variables): - speaker.streamtype = "music" - speaker.radio_show = '' - speaker.radio_station = '' - if 'current_track_duration' in variables: - speaker.track_duration = variables['current_track_duration'] + self._running_flag = False - ml_track = variables['current_track_meta_data'] - if ml_track: - if hasattr(ml_track, 'album_art_uri'): - if ml_track.album_art_uri: - if not ml_track.album_art_uri.startswith(('http:', 'https:')): - album_art_uri = 'http://' + speaker.ip + ':1400' + ml_track.album_art_uri - speaker.track_album_art = album_art_uri - else: - speaker.track_album_art = '' + def handle_AVTransport_event(self, speaker, variables): - if hasattr(ml_track, 'album'): - speaker.track_album = ml_track.album - else: - speaker.track_album = '' + # stop tts from restarting the track + if 'restart_pending' in variables: + if variables['restart_pending'] == "1": + speaker.stop_tts.set() - if hasattr(ml_track, 'title'): - speaker.track_title = ml_track.title - else: - speaker.title = '' + if 'current_transport_actions' in variables: + speaker.transport_actions = variables['current_transport_actions'] - if hasattr(ml_track, 'creator'): - speaker.track_artist = ml_track.creator - else: - speaker.track_artist = '' - - def handle_AVTransport_event(self, speaker, variables): + # stop tts thread if an transport error occurred + if "transport_error_description" in variables: + speaker.stop_tts.set() # meta data for both types (radio, music) if 'current_track_uri' in variables: @@ -343,11 +294,8 @@ def handle_AVTransport_event(self, speaker, variables): # get current track info, if new track is played or resumed to get track_uri, track_album_art speaker.get_trackposition(force_refresh=True) - if 'enqueued_transport_uri_meta_data' in variables: - if isinstance(variables['enqueued_transport_uri_meta_data'], DidlAudioBroadcast): - SonosServerService.set_radio_data(speaker, variables) - else: - SonosServerService.set_music_data(speaker, variables) + SonosServerService.set_music_data(speaker, variables) + def handle_AlarmClock_event(self, speaker, variables): """ @@ -380,6 +328,9 @@ def handle_RenderingControl_event(self, speaker, variables): if 'mute' in variables: speaker.mute = int(variables['mute']['Master']) + if 'nightmode' in variables: + speaker.nightmode = int(variables['nightmode']) + if 'bass' in variables: speaker.bass = int(variables['bass']) @@ -388,3 +339,193 @@ def handle_RenderingControl_event(self, speaker, variables): if 'loudness' in variables: speaker.loudness = int(variables['loudness']['Master']) + + def terminate(self): + self.stop.set() + logger.debug("SonosEventThread terminated") + + +# noinspection PyProtectedMember +class SonosServerService(object): + + _sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + _sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) + + def __init__(self, host, port, server_url, webservice_path, quota, tts_local_mode): + self.lock = Lock() + self.host = host + self.port = port + + self.webservice = SimpleHttpServer(self.host, self.port, webservice_path) + SonosSpeaker.event_queue = queue.Queue() + SonosSpeaker.set_tts(webservice_path, server_url, quota, tts_local_mode) + + self.sonos_events_thread = SonosEventThread() + self.sonos_speakers_thread = GetSonosSpeakerThread() + + 'HTTP Server Running...........' + self.webservice.start() + self.webservice.waitForThread() + + def terminate_threads(self): + self.webservice.stop() + self.sonos_speakers_thread.terminate() + self.sonos_events_thread.terminate() + + def unsubscribe_speaker_events(self): + for speaker in sonos_speaker.sonos_speakers.values(): + speaker.event_unsubscribe() + + @staticmethod + def _discover(): + return discover(timeout=10, include_invisible=False) + + @classmethod + def discover(cls): + try: + with sonos_speaker._sonos_lock: + + active_uids = [] + soco_speakers = SonosServerService._discover() + + if soco_speakers is None: + return + + speaker_to_remove = [] + + for soco_speaker in soco_speakers: + uid = soco_speaker.uid.lower() + + # new speaker found, update it + if uid not in sonos_speaker.sonos_speakers: + try: + soco_speaker.get_speaker_info(refresh=True) + active_uids.append(uid) + except Exception: + # !! sometimes an offline speaker is cached and will be found by the discover function + speaker_to_remove.append(uid) + continue + try: + _sp = SonosSpeaker(soco_speaker) + sonos_speaker.sonos_speakers[uid] = _sp + except Exception as ex: + speaker_to_remove.append(uid) + continue # speaker maybe deleted by another thread + else: + try: + sonos_speaker.sonos_speakers[uid].soco.get_speaker_info(refresh=True) + active_uids.append(uid) + except Exception: + speaker_to_remove.append(uid) + continue # speaker maybe deleted by another thread + try: + offline_uids = set(list(sonos_speaker.sonos_speakers.keys())) - set(active_uids) + offline_uids = set(list(offline_uids) + speaker_to_remove) + except KeyError as err: + print(err) + pass # speaker maybe deleted by another thread + + for uid in offline_uids: + logger.info("offline speaker: {uid} -- removing from list (maybe cached)".format(uid=uid)) + try: + sonos_speaker.sonos_speakers[uid].status = False + sonos_speaker.sonos_speakers[uid].send() + sonos_speaker.sonos_speakers[uid].terminate() + except KeyError: + continue # speaker maybe deleted by another thread + finally: + try: + del sonos_speaker.sonos_speakers[uid] + except: + pass + + # register events for all speaker, this has to be the last step due to some logics in the event + # handling routine + + for speaker in sonos_speaker.sonos_speakers.values(): + try: + speaker.set_zone_coordinator() + speaker.set_group_members() + speaker.event_subscription() + except KeyError: + pass # speaker maybe deleted by another thread + + except ReferenceError: + pass + except Exception as err: + logger.exception('Error in method discover()!\nError: {err}'.format(err=err)) + finally: + pass + + @staticmethod + def set_music_data(speaker, variables): + + if 'current_track_duration' in variables: + speaker.track_duration = variables['current_track_duration'] + + music_data = variables['current_track_meta_data'] + if music_data: + track_album_art = '' + track_album = '' + track_title = '' + track_artist = '' + + if hasattr(music_data, 'album_art_uri'): + if music_data.album_art_uri: + if not music_data.album_art_uri.startswith(('http:', 'https:')): + track_album_art = 'http://' + speaker.ip + ':1400' + music_data.album_art_uri + + if hasattr(music_data, 'album'): + track_album = music_data.album + + if hasattr(music_data, 'title'): + track_title = music_data.title + + if hasattr(music_data, 'creator'): + track_artist = music_data.creator + + if not isinstance(variables['current_track_meta_data'], DidlMusicTrack): + # radio stream + speaker.streamtype = "radio" + speaker.track_duration = "00:00:00" + speaker.radio_station = '' + speaker.radio_show = '' + + radio_station_title = variables['enqueued_transport_uri_meta_data'] + + if hasattr(radio_station_title, 'title'): + speaker.radio_station = radio_station_title.title + + if hasattr(music_data, 'radio_show'): + # the format of a radio_show item seems to be this format: + # <,p123456> --> rstrip ,p.... + radio_show = music_data.radio_show + if radio_show: + radio_show = radio_show.split(',p', 1) + if len(radio_show) > 1: + speaker.radio_show = radio_show[0] + + if hasattr(music_data, 'stream_content'): + ignore_title_string = ('ZPSTR_BUFFERING', 'ZPSTR_BUFFERING', 'ZPSTR_CONNECTING', 'x-sonosapi-stream') + track_artist = '' + track_title = '' + + stream_content = music_data.stream_content + + if stream_content: + if not stream_content.startswith(ignore_title_string): + # if radio, in most cases the following format is used: artist - title + # if stream_content is not null, radio is assumed + + track_artist, track_title = title_artist_parser(speaker.radio_station if speaker.radio_station + else '', stream_content) + else: + speaker.streamtype = "music" + speaker.radio_show = '' + speaker.radio_station = '' + + speaker.track_artist = track_artist + speaker.track_title = track_title + speaker.track_album_art = track_album_art + speaker.track_album = track_album + diff --git a/server.sonos/lib_sonos/sonos_speaker.py b/server.sonos/lib_sonos/sonos_speaker.py index 7dcfb69..5c1520c 100644 --- a/server.sonos/lib_sonos/sonos_speaker.py +++ b/server.sonos/lib_sonos/sonos_speaker.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- - -from soco.data_structures import DidlItem, DidlResource +from lib_sonos import utils from soco.compat import quote_url +import queue from soco.data_structures import DidlItem, to_didl_string - import logging import requests from lib_sonos.utils import NotifyList @@ -12,7 +11,6 @@ import time import json from lib_sonos import udp_broker -from lib_sonos import utils from soco.snapshot import Snapshot from soco.music_services import MusicService from lib_sonos import definitions @@ -22,29 +20,29 @@ except ImportError: import xml.etree.ElementTree as XML -logger = logging.getLogger('') +logger = logging.getLogger('sonos_broker') + sonos_speakers = {} _sonos_lock = threading.Lock() +event_queue = queue.Queue() class SonosSpeaker(object): - tts_enabled = False - event_queue = None + tts_local_mode = False local_folder = '' - remote_folder = '' + local_url = '' + quota = 0 @classmethod - def set_tts(self, local_folder, remote_folder, quota, tts_enabled): + def set_tts(self, local_folder, local_url, quota, tts_local_mode): SonosSpeaker.local_folder = local_folder - SonosSpeaker.remote_folder = remote_folder + SonosSpeaker.local_url = local_url SonosSpeaker.quota = quota - SonosSpeaker.tts_enabled = tts_enabled - - def __del__(self): - logger.debug("DESTRUCTOR !!! Speaker object destructed") + SonosSpeaker.tts_local_mode = tts_local_mode def __init__(self, soco): info = soco.get_speaker_info(timeout=5) - + self._snippet_queue_lock = threading.Lock() + self.stop_tts = threading.Event() self._fade_in = False self._balance = 0 self._saved_music_item = None @@ -57,6 +55,7 @@ def __init__(self, soco): self._mute = 0 self._track_uri = '' self._track_album = '' + self._transport_actions = '' self._track_duration = "00:00:00" self._track_position = "00:00:00" self._streamtype = '' @@ -80,12 +79,13 @@ def __init__(self, soco): self._sub_zone_group = None self._sub_alarm = None self._sub_system_prop = None + self._sub_device_prop = None self._properties_hash = None self._zone_coordinator = None self._additional_zone_members = '' - self._snippet_queue_lock = threading.Lock() self._volume = self.soco.volume self._bass = self.soco.bass + self._nightmode = self.soco.night_mode self._treble = self.soco.treble self._loudness = self.soco.loudness self._playmode = self.soco.play_mode @@ -105,7 +105,7 @@ def __init__(self, soco): self.dirty_all() - ### SoCo instance ################################################################################################## + # SoCo instance #################################################################################################### @property def soco(self): @@ -116,25 +116,25 @@ def soco(self): """ return self._soco - ### MODEL ########################################################################################################## + # MODEL ############################################################################################################ @property def model(self): return self._model - ### MODEL NUMBER#################################################################################################### + # MODEL NUMBER###################################################################################################### @property def model_number(self): return self._model_number - ### DISPLAY VERSION ################################################################################################# + # DISPLAY VERSION ################################################################################################## @property def display_version(self): return self._display_version - ### METADATA ####################################################################################################### + # METADATA ######################################################################################################### @property def metadata(self): @@ -146,7 +146,7 @@ def metadata(self, value): return self._metadata = value - # ## zone_coordinator ####################################################################################### + # zone_coordinator ################################################################################################# @property def zone_coordinator(self): @@ -158,7 +158,7 @@ def is_coordinator(self): return True return False - # ## EVENTS ######################################################################################################### + # EVENTS ########################################################################################################### @property def sub_device_properties(self): @@ -184,37 +184,37 @@ def sub_zone_group(self): def sub_alarm(self): return self._sub_alarm - # ## SERIAL ######################################################################################################### + # SERIAL ########################################################################################################### @property def serial_number(self): return self._serial_number - ### SOFTWARE VERSION ############################################################################################### + # SOFTWARE VERSION ################################################################################################# @property def software_version(self): return self._software_version - ### HARDWARE VERSION ############################################################################################### + # HARDWARE VERSION ################################################################################################# @property def hardware_version(self): return self._hardware_version - ### HOUSEHOLD ID ################################################################################################### + # HOUSEHOLD ID ##################################################################################################### @property def household_id(self): return self._household_id - ### MAC ADDRESS #################################################################################################### + # MAC ADDRESS ###################################################################################################### @property def mac_address(self): return self._mac_address - ### LED ############################################################################################################ + # LED ############################################################################################################## def get_led(self): return self._led @@ -230,7 +230,7 @@ def set_led(self, value, trigger_action=False, group_command=False): self._led = value self.dirty_property('led') - ### BASS ########################################################################################################### + # BASS ############################################################################################################# def get_bass(self): return self._bass @@ -247,7 +247,7 @@ def set_bass(self, value, trigger_action=False, group_command=False): self._bass = value self.dirty_property('bass') - ### TREBLE ######################################################################################################### + # TREBLE ########################################################################################################### def get_treble(self): return self._treble @@ -264,7 +264,7 @@ def set_treble(self, value, trigger_action=False, group_command=False): self._treble = treble self.dirty_property('treble') - ### LOUDNESS ####################################################################################################### + # LOUDNESS ######################################################################################################### def get_loudness(self): return int(self._loudness) @@ -281,7 +281,7 @@ def set_loudness(self, value, trigger_action=False, group_command=False): self._loudness = value self.dirty_property('loudness') - ### PLAYMODE ####################################################################################################### + # PLAYMODE ######################################################################################################### def get_playmode(self): if not self.is_coordinator: @@ -308,7 +308,7 @@ def set_playmode(self, value, trigger_action=False): for speaker in self._zone_members: speaker.dirty_property('playmode') - ### ZONE NAME ###################################################################################################### + # ZONE NAME ######################################################################################################## @property def zone_name(self): @@ -318,7 +318,7 @@ def zone_name(self): return self.zone_coordinator.zone_name return self._zone_name - ### ZONE ICON ###################################################################################################### + # ZONE ICON ######################################################################################################## @property def zone_icon(self): @@ -328,14 +328,15 @@ def zone_icon(self): return self.zone_coordinator.zone_icon return self._zone_icon - ### ZONE MEMBERS ################################################################################################### + # ZONE MEMBERS ##################################################################################################### @property def zone_members(self): return self._zone_members def zone_member_changed(self): - self.dirty_property('additional_zone_members') + self.current_state(group_command=True) + # self.dirty_property('additional_zone_members') @property def additional_zone_members(self): @@ -348,13 +349,14 @@ def additional_zone_members(self): members = '' return members - ### IP ############################################################################################################# + # IP ############################################################################################################### @property def ip(self): return self._ip - ### BALANCE ######################################################################################################## + # BALANCE ########################################################################################################## + def get_balance(self): return self._balance @@ -373,7 +375,7 @@ def set_balance(self, balance, trigger_action=False, group_command=False): self._balance = balance self.dirty_property('balance') - ### VOLUME ######################################################################################################### + # VOLUME ########################################################################################################### def get_volume(self): return self._volume @@ -395,7 +397,7 @@ def set_volume(self, volume, trigger_action=False, group_command=False): self._volume = volume self.dirty_property('volume') - ### VOLUME UP####################################################################################################### + # VOLUME UP ######################################################################################################## def volume_up(self, group_command=False): """ @@ -414,7 +416,7 @@ def _volume_up(self): vol = 100 self.set_volume(vol, trigger_action=True) - ### VOLUME DOWN #################################################################################################### + # VOLUME DOWN ###################################################################################################### def volume_down(self, group_command=False): @@ -435,7 +437,7 @@ def _volume_down(self): vol = 0 self.set_volume(vol, trigger_action=True) - ### MAX VOLUME ##################################################################################################### + # MAX VOLUME ####################################################################################################### def get_maxvolume(self): @@ -471,13 +473,13 @@ def _set_maxvolume(self, value): self._max_volume = m_volume self.dirty_property('max_volume') - ### UID ############################################################################################################ + # UID ############################################################################################################## @property def uid(self): return self._uid.lower() - ### MUTE ########################################################################################################### + # MUTE ############################################################################################################# def get_mute(self): return self._mute @@ -503,7 +505,7 @@ def set_mute(self, value, trigger_action=False, group_command=False): self._mute = value self.dirty_property('mute') - ### TRACK_URI ###################################################################################################### + # TRACK_URI ######################################################################################################## @property def track_uri(self): @@ -518,14 +520,34 @@ def track_uri(self, value): if self._track_uri == value: return self._track_uri = value + # it seems, that Sonos not fire some events when cleaning a playlist + # one event that is fired is track_uri + # an empty track_uri signals an empty list + + if self._track_uri == "": + self.clear_sonos_metadata() + self.dirty_property('track_uri') # dirty properties for all zone members, if coordinator if self.is_coordinator: for speaker in self._zone_members: + if self.track_uri == "": + speaker.clear_sonos_metadata() speaker.dirty_property('track_uri') - ### TRACK DURATION ################################################################################################# + def clear_sonos_metadata(self): + self.track_album_art = "" + self.track_artist = "" + self.track_title = "" + self.playlist_position = 0 + self.playlist_total_tracks = 0 + self.track_album = "" + self.radio_show = "" + self.radio_station = "" + self.track_duration = "" + + # TRACK DURATION ################################################################################################### @property def track_duration(self): @@ -549,7 +571,7 @@ def track_duration(self, value): for speaker in self._zone_members: speaker.dirty_property('track_duration') - ### TRACK POSITION ################################################################################################# + # TRACK POSITION ################################################################################################### def get_trackposition(self, force_refresh=False): """ @@ -594,7 +616,7 @@ def set_trackposition(self, value, trigger_action=False): for speaker in self._zone_members: speaker.dirty_property('track_position') - ### PLAYLIST POSITION ############################################################################################## + # PLAYLIST POSITION ################################################################################################ @property def playlist_position(self): @@ -617,7 +639,7 @@ def playlist_position(self, value): for speaker in self._zone_members: speaker.dirty_property('playlist_position') - ### PLAYLIST TOTAL NUMBER TRACKS ################################################################################### + # PLAYLIST TOTAL NUMBER TRACKS ##################################################################################### @property def playlist_total_tracks(self): @@ -638,7 +660,7 @@ def playlist_total_tracks(self, value): for speaker in self._zone_members: speaker.dirty_property('playlist_total_tracks') - ### STREAMTYPE ##################################################################################################### + # STREAMTYPE ####################################################################################################### @property def streamtype(self): @@ -660,7 +682,7 @@ def streamtype(self, value): for speaker in self._zone_members: speaker.dirty_property('streamtype') - ### STOP ########################################################################################################### + # STOP ############################################################################################################# def get_stop(self): if not self.is_coordinator: @@ -697,7 +719,7 @@ def set_stop(self, value, trigger_action=False): for speaker in self._zone_members: speaker.dirty_property('pause', 'play', 'stop') - ### PLAY ########################################################################################################### + # PLAY ############################################################################################################# def get_play(self): if not self.is_coordinator: @@ -734,7 +756,7 @@ def set_play(self, value, trigger_action=False): for speaker in self._zone_members: speaker.dirty_property('pause', 'play', 'stop') - ### PAUSE ########################################################################################################## + # PAUSE ############################################################################################################ def get_pause(self): if not self.is_coordinator: @@ -771,8 +793,7 @@ def set_pause(self, value, trigger_action=False): for speaker in self._zone_members: speaker.dirty_property('pause', 'play', 'stop') - - ### TRACK ALBUM #################################################################################################### + # TRACK ALBUM ###################################################################################################### @property def track_album(self): @@ -794,7 +815,29 @@ def track_album(self, value): for speaker in self._zone_members: speaker.dirty_property('track_album') - ### RADIO STATION ################################################################################################## + # TRANSPORT ACTIONS ################################################################################################ + + @property + def transport_actions(self): + if not self.is_coordinator: + logger.debug("forwarding transport_actions getter to coordinator with uid {uid}". + format(uid=self.zone_coordinator.uid)) + return self.zone_coordinator.transport_actions + return self._transport_actions + + @transport_actions.setter + def transport_actions(self, value): + if self._transport_actions == value: + return + self._transport_actions = value + self.dirty_property('transport_actions') + + # dirty properties for all zone members, if coordinator + if self.is_coordinator: + for speaker in self._zone_members: + speaker.dirty_property('transport_actions') + + # RADIO STATION #################################################################################################### @property def radio_station(self): @@ -816,7 +859,7 @@ def radio_station(self, value): for speaker in self._zone_members: speaker.dirty_property('radio_station') - ### RADIO SHOW ##################################################################################################### + # RADIO SHOW ####################################################################################################### @property def radio_show(self): @@ -837,7 +880,7 @@ def radio_show(self, value): for speaker in self._zone_members: speaker.dirty_property('radio_show') - ### TRACK ALBUM ART ################################################################################################ + # TRACK ALBUM ART ################################################################################################## @property def track_album_art(self): @@ -858,7 +901,7 @@ def track_album_art(self, value): for speaker in self._zone_members: speaker.dirty_property('track_album_art') - ### TRACK TITLE #################################################################################################### + # TRACK TITLE ###################################################################################################### @property def track_title(self): @@ -881,7 +924,7 @@ def track_title(self, value): for speaker in self._zone_members: speaker.dirty_property('track_title') - ### TRACK ARTIST ################################################################################################### + # TRACK ARTIST ##################################################################################################### @property def track_artist(self): @@ -904,7 +947,7 @@ def track_artist(self, value): for speaker in self._zone_members: speaker.dirty_property('track_artist') - ### NEXT ########################################################################################################### + # NEXT ############################################################################################################# def next(self): if not self.is_coordinator: @@ -914,7 +957,7 @@ def next(self): else: self.soco.next() - ### PREVIOUS ####################################################################################################### + # PREVIOUS ######################################################################################################### def previous(self): if not self.is_coordinator: @@ -924,7 +967,32 @@ def previous(self): else: self.soco.previous() - ### PARTYMODE ###################################################################################################### + # NIGHTMODE ######################################################################################################## + + def get_nightmode(self): + if self._nightmode is None: + return 0 + return int(self._nightmode) + + def set_nightmode(self, value, trigger_action=False): + night_mode = 0 + try: + if bool(value): + night_mode = 1 + else: + night_mode = 0 + except: + pass + if self._nightmode == night_mode: + return + + if trigger_action: + self.soco.night_mode = night_mode + + self._nightmode = night_mode + self.dirty_property('nightmode') + + # PARTYMODE ######################################################################################################## def partymode(self): @@ -935,7 +1003,7 @@ def partymode(self): self.soco.partymode() - ### JOIN ########################################################################################################### + # JOIN ############################################################################################################# def join(self, join_uid): @@ -952,21 +1020,26 @@ def join(self, join_uid): else: speaker = sonos_speakers[join_uid] self.soco.join(speaker.soco) - + sec_to_wait = 3 + logger.debug("Waiting {sleep} seconds after join ...".format(sleep=sec_to_wait)) + time.sleep(sec_to_wait) except Exception: raise Exception('No master speaker found for uid \'{uid}\'!'.format(uid=join_uid)) - ### UNJOIN ######################################################################################################### + # UNJOIN ########################################################################################################### - def unjoin(self): + def unjoin(self, play=False): """ Unjoins the current speaker from a group. """ - self.soco.unjoin() + sec_to_wait = 3 + logger.debug("Waiting {sleep} seconds after unjoin ...".format(sleep=sec_to_wait)) + time.sleep(sec_to_wait) + self.set_play(play, trigger_action=True) - ### CURRENT STATE ################################################################################################## + # CURRENT STATE #################################################################################################### def current_state(self, group_command=False): @@ -980,8 +1053,7 @@ def current_state(self, group_command=False): for speaker in self._zone_members: speaker.current_state(group_command=False) - - ### WIFI STATE ##################################################################################################### + # WIFI STATE ####################################################################################################### def get_wifi_state(self, force_refresh=False): """ @@ -1055,7 +1127,7 @@ def set_wifi_state(self, value, persistent=False, trigger_action=False): self._wifi_state = value self.dirty_property('wifi_state') - ### LAOD SONOS PLAYLIST ############################################################################################ + # LOAD SONOS PLAYLIST ############################################################################################## def load_sonos_playlist(self, sonos_playlist_name, play_after_insert=False, clear_queue=False): try: @@ -1070,7 +1142,7 @@ def load_sonos_playlist(self, sonos_playlist_name, play_after_insert=False, clea except Exception: raise Exception("No Sonos playlist found with title '{title}'.".format(title=sonos_playlist_name)) - ### Clear Queue #################################################################################################### + # Clear Queue ###################################################################################################### def clear_queue(self): """ @@ -1079,7 +1151,7 @@ def clear_queue(self): """ self.soco.clear_queue() - ### Play TuneIn Radio ############################################################################################## + # Play TuneIn Radio ################################################################################################ def play_tunein(self, station_name): @@ -1115,7 +1187,7 @@ def play_tunein(self, station_name): ('CurrentURI', uri), ('CurrentURIMetaData', meta)]) self.soco.play() - ### Sonos Playlists ################################################################################################ + # Sonos Playlists ################################################################################################## @property def sonos_playlists(self): @@ -1143,6 +1215,7 @@ def dirty_music_metadata(self): 'track_uri', 'track_duration', 'track_album', + 'transport_actions', 'stop', 'play', 'pause', @@ -1163,6 +1236,7 @@ def dirty_all(self): self.dirty_music_metadata() self.dirty_property( + 'nightmode', 'sonos_playlists', 'household_id', 'display_version', @@ -1224,6 +1298,7 @@ def status(self, value): self._pause = False self._track_title = '' self._track_artist = '' + self._transport_actions = '' self._track_duration = "00:00:00" self._track_position = "00:00:00" self._playlist_position = 0 @@ -1241,19 +1316,17 @@ def status(self, value): self.dirty_property('status') - def play_uri(self, uri, metadata=None): + def play_uri(self, uri): """ Plays a song from a given uri :param uri: uri to be played - :param metadata: ATTENTION - currently not working due to a bug in SoCo framework :return: True, if the song is played. """ - if not self.is_coordinator: logger.debug("forwarding play_uri command to coordinator with uid {uid}". format(uid=self.zone_coordinator.uid)) - self.zone_coordinator.play_uri(uri, metadata) + self.zone_coordinator.play_uri(uri) else: return self.soco.play_uri(uri) @@ -1262,6 +1335,7 @@ def play_snippet(self, uri, volume=-1, group_command=False, fade_in=False): """ Plays a audio snippet. This will pause the current audio track , plays the snippet and after that, the previous track will be continued. + :param fade_in: Fade-In after the snippet was played. Default: false :param uri: uri to be played :param volume: Snippet volume [-1-100]. After the snippet was played, the previous/original volume is set. If volume is '-1', the current volume is used. Default: -1 @@ -1277,72 +1351,94 @@ def play_snippet(self, uri, volume=-1, group_command=False, fade_in=False): else: with self._snippet_queue_lock: try: - _saved_music_item = Snapshot(device=self.soco, snapshot_queue=False) - _saved_music_item.snapshot() - for prefix in ('http://', 'https://'): - if uri.startswith(prefix): - # Replace only the first instance - uri = uri.replace(prefix, 'x-rincon-mp3radio://', 1) + volumes = {} + # save all volumes from zone_member + for member in self.zone_members: + volumes[member] = member.volume + + # Take a snapshot of the current sonos device state, we will want + # to roll back to this when we are done + logger.debug("Speech: Taking snapshot") - snippet_index = self.soco.add_uri_to_queue(uri, title='Sonos Ansage') + # was GoogleTTS the last track? do not snapshot + last_station = self.radio_station + if last_station.lower() != "google tts": + snap = Snapshot(self.soco) + snap.snapshot() - if snippet_index > 0: - snippet_index -= 1 + # Get the URI and play it + logger.debug("Speech: Playing URI %s" % uri) self.set_stop(1, trigger_action=True) - time.sleep(1) + if volume == -1: volume = self.volume - logger.debug('Playing snippet \'{uri}\'. Volume: {volume}'.format(uri=uri, volume=volume)) if self.volume != volume: self.set_volume(volume, trigger_action=True, group_command=group_command) - self.soco.play_from_queue(snippet_index) - - time.sleep(1) - h, m, s = self.track_duration.split(":") - seconds = int(h) * 3600 + int(m) * 60 + int(s) + 1 - logger.debug('Estimated snippet length: {seconds}'.format(seconds=seconds)) - - # maximum snippet length is 60 sec - if seconds > 10: - seconds = 10 - if seconds < 3: - seconds = 3 - - logger.debug('Waiting {seconds} seconds until snippet has finished playing.'.format(seconds=seconds)) - time.sleep(seconds) - - self.soco.remove_from_queue(snippet_index) - _saved_music_item.restore(fade=fade_in) - - if fade_in: - for member in self.zone_members: - vol_to_ramp = member.soco.volume - member.soco.volume = 0 - member.soco.renderingControl.RampToVolume( - [('InstanceID', 0), ('Channel', 'Master'), - ('RampType', 'SLEEP_TIMER_RAMP_TYPE'), - ('DesiredVolume', vol_to_ramp), - ('ResetVolumeAfter', False), ('ProgramURI', '')]) + time.sleep(0.5) + self.soco.play_uri(uri, title="Google TTS") + self.stop_tts.wait(timeout=120) # wait max 120sec + self.stop_tts.clear() + time.sleep(0.5) + + # testing play_snippet stop for stereo pair + for speaker in self._zone_members: + try: + logger.debug("tts force stop trigger for speaker {speaker}".format(speaker=speaker.uid)) + res = speaker.soco.stop() + logger.debug("{uid}: stop result = {res}".format(uid=speaker.soco.uid, res=res)) + speaker.stop_tts.clear() + except Exception as err: + logger.debug("{uid} error: {err}".format(uid=speaker.soco.uid, err=err)) + + logger.debug("Speech: Stopping speech") + # Stop the stream playing + self.soco.stop() + logger.debug("Speech: Restoring snapshot") + + # Restore the Sonos device back to it's previous state + if last_station.lower() != "google tts": + snap.restore() + else: + self.radio_station = "" + + for member in self.zone_members: + if member in volumes: + if fade_in: + vol_to_ramp = volumes[member] + member.soco.volume = 0 + member.soco.renderingControl.RampToVolume( + [('InstanceID', 0), ('Channel', 'Master'), + ('RampType', 'SLEEP_TIMER_RAMP_TYPE'), + ('DesiredVolume', vol_to_ramp), + ('ResetVolumeAfter', False), ('ProgramURI', '')]) + else: + member.set_volume(volumes[member], trigger_action=True, group_command=False) except Exception as err: print(err) - def play_tts(self, tts, volume, language='en', group_command=False, fade_in=False): + def play_tts(self, tts, volume, language='en', group_command=False, fade_in=False, force_stream_mode=False): # we do not need any code here to get the zone coordinator. # The play_snippet function does the necessary work. - if not SonosSpeaker.tts_enabled: - print("Google TTS disabled. Check your config.") - return + local_mode = SonosSpeaker.tts_local_mode + # override if stream is set to True + if force_stream_mode: + local_mode = False + + # default mode: give the prepared url directly to our loudspeaker + if not local_mode: + url = utils.stream_google_tts(tts, language) + else: + filename = utils.save_google_tts(SonosSpeaker.local_folder, tts, language, SonosSpeaker.quota) - filename = utils.save_google_tts(SonosSpeaker.local_folder, tts, language, SonosSpeaker.quota) - if SonosSpeaker.local_folder.endswith('/'): - SonosSpeaker.local_folder = SonosSpeaker.local_folder[:-1] - url = '{}/{}'.format(SonosSpeaker.remote_folder, filename) + if SonosSpeaker.local_folder.endswith('/'): + SonosSpeaker.local_folder = SonosSpeaker.local_folder[:-1] + url = '{}/{}'.format(SonosSpeaker.local_url, filename) self.play_snippet(url, volume, group_command, fade_in) @@ -1565,4 +1661,5 @@ def terminate(self): pause = property(get_pause, set_pause) max_volume = property(get_maxvolume, set_maxvolume) track_position = property(get_trackposition, set_trackposition) - wifi_state = property(get_wifi_state, set_wifi_state) \ No newline at end of file + wifi_state = property(get_wifi_state, set_wifi_state) + nightmode = property(get_nightmode, set_nightmode) \ No newline at end of file diff --git a/server.sonos/lib_sonos/tts.py b/server.sonos/lib_sonos/tts.py index 69ac066..bc8fb34 100644 --- a/server.sonos/lib_sonos/tts.py +++ b/server.sonos/lib_sonos/tts.py @@ -2,9 +2,12 @@ import calendar import math import time + +import logging import requests import re +logger = logging.getLogger('sonos_broker') class Token: """ Token (Google Translate Token) @@ -138,8 +141,7 @@ class gTTS: 'cy' : 'Welsh' } - def __init__(self, text, lang = 'en', debug = False): - self.debug = debug + def __init__(self, text, lang='en'): if lang.lower() not in self.LANGUAGES: raise Exception('Language not supported: %s' % lang) else: @@ -168,35 +170,51 @@ def strip(x): return x.replace('\n', '').strip() def save(self, savefile): """ Do the Web request and save to `savefile` """ with open(savefile, 'wb') as f: - self.write_to_fp(f) + self._write_to_fp(f) f.close() - def write_to_fp(self, fp): + def stream_url(self): + req = self._prepare_request() + params = req.params + prep_req = req.prepare() + prep_req.prepare_url(req.url, params) + return prep_req.url + + def _prepare_request(self): """ Do the Web request and save to a file-like object """ for idx, part in enumerate(self.text_parts): - payload = { 'ie' : 'UTF-8', - 'q' : part, - 'tl' : self.lang, - 'total' : len(self.text_parts), - 'idx' : idx, - 'client' : 'tw-ob', - 'textlen' : len(part), - 'tk' : self.token.calculate_token(part)} + payload = {'ie': 'UTF-8', + 'q': part, + 'tl': self.lang, + 'total': len(self.text_parts), + 'idx': idx, + 'client': 'tw-ob', + 'textlen': len(part), + 'tk': self.token.calculate_token(part)} headers = { - "Referer" : "http://translate.google.com/", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36" + "Referer": "http://translate.google.com/", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/47.0.2526.106 Safari/537.36" } - if self.debug: print(payload) - try: - r = requests.get(self.GOOGLE_TTS_URL, params=payload, headers=headers) - if self.debug: - print("Headers: {}".format(r.request.headers)) - print("Reponse: {}, Redirects: {}".format(r.status_code, r.history)) - r.raise_for_status() - for chunk in r.iter_content(chunk_size=1024): - fp.write(chunk) - except Exception as e: - raise + + logger.debug("GoogleTTS: headers parameter: {param}".format(param=headers)) + logger.debug("GoogleTTS: request parameter: {param}".format(param=payload)) + return requests.Request(method='GET', url=self.GOOGLE_TTS_URL, headers=headers, params=payload) + + def _write_to_fp(self, fp): + try: + prepared_request = self._prepare_request().prepare() + s = requests.Session() + r = s.send(prepared_request) + logger.debug("Headers: {}".format(r.request.headers)) + logger.debug("Reponse: {}, Redirects: {}".format(r.status_code, r.history)) + + r.raise_for_status() + for chunk in r.iter_content(chunk_size=1024): + fp.write(chunk) + except Exception as err: + logger.error(err) + raise err def _tokenize(self, text, max_size): """ Tokenizer on basic roman punctuation """ diff --git a/server.sonos/lib_sonos/udp_broker.py b/server.sonos/lib_sonos/udp_broker.py index 6d054aa..94e94c5 100644 --- a/server.sonos/lib_sonos/udp_broker.py +++ b/server.sonos/lib_sonos/udp_broker.py @@ -3,9 +3,9 @@ import errno import socket -logger = logging.getLogger('') -registered_clients = {} +logger = logging.getLogger('sonos_broker') +registered_clients = {} class UdpBroker(): @staticmethod diff --git a/server.sonos/lib_sonos/utils.py b/server.sonos/lib_sonos/utils.py index 00a47b9..8c7f711 100644 --- a/server.sonos/lib_sonos/utils.py +++ b/server.sonos/lib_sonos/utils.py @@ -3,6 +3,7 @@ # -*- coding: utf-8 -*- import base64 import ctypes +import hashlib import json import os import platform @@ -16,7 +17,6 @@ import sys from lib_sonos.tts import gTTS - if os.name != "nt": import fcntl import struct @@ -28,7 +28,7 @@ StringType = bytes UnicodeType = str -logger = logging.getLogger('') +logger = logging.getLogger('sonos_broker') class WeakMethod: @@ -40,6 +40,41 @@ def __call__(self, *args): return getattr(self.proxy, self.method_name)(*args) +def read_in_chunks(file_object, chunk_size=1024): + """Lazy function (generator) to read a file piece by piece. + Default chunk size: 1k.""" + while True: + data = file_object.read(chunk_size) + if not data: + break + yield data + + +def get_mime_type_by_filetype(file_path): + try: + mapping = { + "audio/aac": "aac", + "audio/mp4": "mp4", + "audio/mpeg": "mp3", + "audio/ogg": "ogg", + "audio/wav": "wav", + "audio/webm": "web" + } + + filename, extension = os.path.splitext(file_path) + extension = extension.strip('.').lower() + + for mime_type, key in mapping.items(): + if extension == key: + return mime_type + + raise Exception("Could not found mime-type for extension '{ext}'.".format(extension)) + + except Exception as err: + logger.warning(err) + return None + + def really_unicode(in_string): """ Ensures s is returned as a unicode string and not just a string through @@ -105,14 +140,6 @@ def get_free_space_mb(folder): return int(round(st.f_bavail * st.f_frsize / 1024 / 1024, 0)) -def check_directory_permissions(local_share): - if not os.path.exists(local_share): - print('Local share \'{}\' does not exists!'.format(local_share)) - return False - - return os.access(local_share, os.W_OK) and os.access(local_share, os.R_OK) - - def get_folder_size(folder): total_size = 0 for dirpath, dirnames, filenames in os.walk(folder): @@ -123,8 +150,8 @@ def get_folder_size(folder): def stream_google_tts(tts_string, tts_language): - tts = gTTS(text=tts_string, lang=tts_language) - tts.stream() + return gTTS(text=tts_string, lang=tts_language).stream_url() + def save_google_tts(local_share, tts_string, tts_language, quota): size = int(get_folder_size(local_share) / 1024 / 1024) @@ -136,9 +163,13 @@ def save_google_tts(local_share, tts_string, tts_language, quota): tts_language = 'en' tts_string = 'Cannot save file. File size quota exceeded!' + m = hashlib.md5() + m.update('{}_{}'.format(tts_language, tts_string).encode('utf-8')) + file_name = m.hexdigest() + tts = gTTS(text=tts_string, lang=tts_language) - base64_name = base64.urlsafe_b64encode('{}__{}'.format(tts_language, tts_string).encode('utf-8')).decode('ascii', '') - fname = '{}.mp3'.format(base64_name) + + fname = '{}.mp3'.format(file_name) abs_fname = os.path.join(local_share, fname) # check if file exists, no need to browse google tts @@ -212,28 +243,13 @@ def debug_log_commands(ip, arguments): logger.debug("arguments: {arguments} | ip: {ip}".format(arguments=', '.join(arguments), ip=ip)) -def get_interface_ip(ifname): - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - return socket.inet_ntoa(fcntl.ioctl(s.fileno(), 0x8915, struct.pack('256s', ifname[:15].encode('utf-8')))[20:24]) - - -def get_lan_ip_fallback(): - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - s.connect(('', 0)) - return s.getsockname()[0] - except Exception as err: - logger.critical(err) - return None - def dump_attributes(obj): attrs = vars(obj) attributes = ', '.join("%s: %s" % item for item in attrs.items() if not item[0].startswith('_')) return attributes -def ip_address_is_valid(address): +def ip_address_is_valid(address): """ Tests if an ip address is valid. http://stackoverflow.com/questions/4011855/regexp-to-check-if-an-ip-is-valid @@ -248,6 +264,7 @@ def ip_address_is_valid(address): else: return address.count('.') == 3 + def check_int(s): if isinstance(s, int): return True @@ -258,24 +275,22 @@ def check_int(s): def get_lan_ip(): try: - ip = socket.gethostbyname(socket.gethostname()) - if ip.startswith("127.") and os.name != "nt": - interfaces = ["eth0", "eth1", "eth2", "wlan0", "wlan1", "wifi0", "ath0", "ath1", "ppp0"] - for ifname in interfaces: - try: - ip = get_interface_ip(ifname) - break - except IOError: - pass - except socket.gaierror: - return get_lan_ip_fallback() - return ip + import socket + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(5) + s.connect(("google.com", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except: + return None # ####################################################################################################################### ''' Notification list from http://stackoverflow.com/questions/13259179/list-callbacks ''' + def callback_method(func): def notify(self, *args, **kwargs): for _, callback in self._callbacks: @@ -296,7 +311,7 @@ class NotifyList(list): __delitem__ = callback_method(list.__delitem__) __setitem__ = callback_method(list.__setitem__) __iadd__ = callback_method(list.__iadd__) - #__imul__ = callback_method(list.__imul__) + # __imul__ = callback_method(list.__imul__) # Take care to return a new NotifyList if we slice it. if _pyversion < 3: @@ -328,4 +343,4 @@ def unregister_callback(self, cbid): self._callbacks.pop(idx) return cb else: - return None \ No newline at end of file + return None diff --git a/server.sonos/scripts/systemd/sonos-broker.service b/server.sonos/scripts/systemd/sonos-broker.service new file mode 100644 index 0000000..9857873 --- /dev/null +++ b/server.sonos/scripts/systemd/sonos-broker.service @@ -0,0 +1,10 @@ +[Unit] +Description=Sonos Broker +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/sonos-broker start + +[Install] +WantedBy=default.target \ No newline at end of file diff --git a/server.sonos/scripts/upstart/sonos-broker.conf b/server.sonos/scripts/upstart/sonos-broker.conf new file mode 100644 index 0000000..2b7acfe --- /dev/null +++ b/server.sonos/scripts/upstart/sonos-broker.conf @@ -0,0 +1,10 @@ +description "Sonos Broker upstart script" + +#start on runlevel [2345] +stop on shutdown + +exec start-stop-daemon --start --oknodo --name "sonos-broker" --exec /usr/local/bin/sonos-broker -- start + +pre-stop script + exec start-stop-daemon --stop --oknodo --name "sonos-broker" --exec /usr/local/bin/sonos-broker +end script diff --git a/server.sonos/setup.py b/server.sonos/setup.py index 7b36a63..eff7646 100644 --- a/server.sonos/setup.py +++ b/server.sonos/setup.py @@ -1,15 +1,149 @@ -from distutils.core import setup +import os +import subprocess +from setuptools import setup from lib_sonos import definitions +from setuptools.command.install import install +from pkg_resources import resource_filename, Requirement +import shutil + + +class bcolors: + HEADER = '\033[95m' + OKBLUE = '\033[94m' + OKGREEN = '\033[92m' + WARNING = '\033[93m' + FAIL = '\033[91m' + ENDC = '\033[0m' + + def disable(self): + self.HEADER = '' + self.OKBLUE = '' + self.OKGREEN = '' + self.WARNING = '' + self.FAIL = '' + self.ENDC = '' + + +class PostInstallCommand(install): + def run(self): + install.run(self) + + try: + conf_default_path = "/etc/default/" + if not os.path.exists(conf_default_path): + os.makedirs(conf_default_path) + + config_filename = resource_filename(Requirement.parse("sonos-broker"), "config/sonos-broker") + shutil.copy(config_filename, conf_default_path) + self.set_autostart() + except: + pass + + def set_autostart(self): + + upstart_filename = resource_filename(Requirement.parse("sonos-broker"), "scripts/upstart/sonos-broker.conf") + systemd_filename = resource_filename(Requirement.parse("sonos-broker"), "scripts/systemd/sonos-broker.service") + + print("\nChecking systems start method ... ", end="") + + strings_process = subprocess.Popen(["strings", "/sbin/init"], stdout=subprocess.PIPE, stdin=subprocess.PIPE, + stderr=subprocess.PIPE) + s_out, s_err = strings_process.communicate() + + awk_process = subprocess.Popen( + ["awk", "match($0, /(upstart|systemd|sysvinit)/) { print toupper(substr($0, RSTART, RLENGTH));exit; }"], + stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE) + out, error = awk_process.communicate(input=s_out) + start_method = out.decode().strip('\n') + valid_methods = ['UPSTART', 'SYSTEMD', 'SYSVINIT'] + + if start_method not in valid_methods: + print(bcolors.FAIL + 'not ok') + print("Unable to detect the systems start method.") + print( + "Choose a suitable script from /usr/share/sonos for your system and install it manually.\n" + bcolors.ENDC) + exit() + + print(bcolors.OKGREEN + 'ok ' + bcolors.OKBLUE + '[{method}]'.format(method=start_method) + bcolors.ENDC) + + src_file = "" + dest_dir = "" + user_hint = "" + additional_command = [] + autostart_hint = "" + + if start_method == "UPSTART": + src_file = upstart_filename + dest_dir = "/etc/init" + user_hint = "sudo service {file} [start|stop|restart]" + autostart_hint = "For auto-start edit " + bcolors.OKBLUE + os.path.join(dest_dir, + os.path.basename(src_file)) \ + + bcolors.ENDC + " and uncomment line " + bcolors.OKBLUE + "'start on runlevel [2345]'\n" \ + + bcolors.ENDC + + elif start_method == "SYSTEMD": + src_file = systemd_filename + dest_dir = "/etc/systemd/system" + user_hint = "sudo systemctl [start|stop|restart|status] {file}" + + reload_systemctl = subprocess.Popen(["systemctl", "daemon-reload"], stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + stderr=subprocess.PIPE) + + autostart_hint = "For auto-start execute " + bcolors.OKBLUE + "sudo systemctl enable " \ + + os.path.basename(src_file) + bcolors.ENDC + "\n" + + additional_command = [reload_systemctl] + + elif start_method == "SYSVINIT": + print("SYSVINIT detected. No auto-start script defined. You have to create an appropriate script which fits" + " to your Linux distribution.\n") + exit() + else: + exit() + + script_name = os.path.basename(src_file) + user_hint = user_hint.format(file=script_name.split('.')[0]) + + print("Checking permissions ... ", end="") + + if not os.access(dest_dir, os.W_OK): + print(bcolors.FAIL + 'not ok') + print("No 'write' permissions to '{dir}'. Please re-run the script with " + "root permissions.\n".format(dir=dest_dir) + bcolors.ENDC) + exit() + + print(bcolors.OKGREEN + "ok" + bcolors.ENDC) + print("Copying start script to {dir} ... ".format(dir=dest_dir), end="") + + try: + shutil.copy(src_file, os.path.join(dest_dir, script_name)) + except IOError: + print(bcolors.FAIL + "not ok") + print("No 'write' permissions to '{dir}'. Please re-run the script with " + "root permissions.\n".format(dir=dest_dir) + bcolors.ENDC) + + print(bcolors.OKGREEN + "ok " + bcolors.OKBLUE + "[{file}]".format( + file=os.path.join(dest_dir, script_name)) + bcolors.ENDC) + + for command in additional_command: + command.communicate() + + print(bcolors.OKGREEN + "Sonos Broker service successfully installed." + bcolors.ENDC) + print("Type " + bcolors.OKBLUE + "{hint}".format(hint=user_hint) + bcolors.ENDC + " to control the service.") + print(autostart_hint) setup( name='sonos-broker', version='{version}'.format(version=definitions.VERSION), packages=['lib_sonos', 'soco', 'soco.music_services'], - scripts=['sonos_broker', 'sonos_broker.cfg', 'sonos_cmd'], + scripts=['sonos-broker', 'sonos-cmd'], url='https://github.com/pfischi/shSonos', license='', author='pfischi', author_email='pfischi@gmx.de', description='sonos broker', - requires=['requests'] + install_requires=['requests', 'xmltodict'], + cmdclass={'install': PostInstallCommand}, + include_package_data=True ) diff --git a/server.sonos/soco/config.py b/server.sonos/soco/config.py index 4b6f716..64ffc27 100644 --- a/server.sonos/soco/config.py +++ b/server.sonos/soco/config.py @@ -31,6 +31,17 @@ """ +EVENT_LISTENER_IP = None +"""The IP on which the event listener listens. + +The default of None means that the relevant IP address will be detected +automatically. + +See also: + The :mod:`soco.events` module. +""" + + EVENT_LISTENER_PORT = 1400 """The port on which the event listener listens. diff --git a/server.sonos/soco/core.py b/server.sonos/soco/core.py index c78e6ff..31961ac 100755 --- a/server.sonos/soco/core.py +++ b/server.sonos/soco/core.py @@ -12,8 +12,6 @@ import socket from functools import wraps import warnings -from soco.exceptions import SoCoUPnPException - import requests @@ -21,9 +19,12 @@ from .compat import UnicodeType from .data_structures import ( DidlObject, DidlPlaylistContainer, DidlResource, - Queue, from_didl_string, to_didl_string + Queue, to_didl_string +) +from .data_structures_entry import from_didl_string +from .exceptions import ( + SoCoSlaveException, SoCoUPnPException, NotSupportedException, ) -from .exceptions import SoCoSlaveException from .groups import ZoneGroup from .music_library import MusicLibrary from .services import ( @@ -168,6 +169,8 @@ class SoCo(_SocoSingletonBase): bass treble loudness + night_mode + dialog_mode cross_fade status_light player_name @@ -704,6 +707,84 @@ def loudness(self, loudness): ('DesiredLoudness', loudness_value) ]) + @property + def night_mode(self): + """Get the Sonos speaker's night mode. True if on, False if off, + None if not supported. + + :returns bool or None + """ + if not self.speaker_info: + self.get_speaker_info() + if 'PLAYBAR' not in self.speaker_info['model_name']: + return None + + response = self.renderingControl.GetEQ([ + ('InstanceID', 0), + ('EQType', 'NightMode') + ]) + return bool(int(response['CurrentValue'])) + + @night_mode.setter + def night_mode(self, night_mode): + """Switch on/off the speaker's night mode. + + :param night_mode: Enable or disable night mode + :type night_mode: bool + :raises NotSupportedException: If the device does not support + night mode. + """ + if not self.speaker_info: + self.get_speaker_info() + if 'PLAYBAR' not in self.speaker_info['model_name']: + message = 'This device does not support night mode' + raise NotSupportedException(message) + + self.renderingControl.SetEQ([ + ('InstanceID', 0), + ('EQType', 'NightMode'), + ('DesiredValue', int(night_mode)) + ]) + + @property + def dialog_mode(self): + """Get the Sonos speaker's dialog mode. True if on, False if off, + None if not supported. + + :returns bool or None + """ + if not self.speaker_info: + self.get_speaker_info() + if 'PLAYBAR' not in self.speaker_info['model_name']: + return None + + response = self.renderingControl.GetEQ([ + ('InstanceID', 0), + ('EQType', 'DialogLevel') + ]) + return bool(int(response['CurrentValue'])) + + @dialog_mode.setter + def dialog_mode(self, dialog_mode): + """Switch on/off the speaker's dialog mode. + + :param dialog_mode: Enable or disable dialog mode + :type dialog_mode: bool + :raises NotSupportedException: If the device does not support + dialog mode. + """ + if not self.speaker_info: + self.get_speaker_info() + if 'PLAYBAR' not in self.speaker_info['model_name']: + message = 'This device does not support dialog mode' + raise NotSupportedException(message) + + self.renderingControl.SetEQ([ + ('InstanceID', 0), + ('EQType', 'DialogLevel'), + ('DesiredValue', int(dialog_mode)) + ]) + def _parse_zone_group_state(self): """The Zone Group State contains a lot of useful information. @@ -1261,7 +1342,7 @@ def get_sonos_playlists(self, *args, **kwargs): **kwargs) @only_on_master - def add_uri_to_queue(self, uri, title=''): + def add_uri_to_queue(self, uri): """Adds the URI to the queue. :param uri: The URI to be added to the queue @@ -1270,7 +1351,7 @@ def add_uri_to_queue(self, uri, title=''): # FIXME: The res.protocol_info should probably represent the mime type # etc of the uri. But this seems OK. res = [DidlResource(uri=uri, protocol_info="x-rincon-playlist:*:*:*")] - item = DidlObject(resources=res, title=title, parent_id='', item_id='') + item = DidlObject(resources=res, title='', parent_id='', item_id='') return self.add_to_queue(item) @only_on_master diff --git a/server.sonos/soco/data_structures.py b/server.sonos/soco/data_structures.py index 7237b72..e2fdb84 100644 --- a/server.sonos/soco/data_structures.py +++ b/server.sonos/soco/data_structures.py @@ -70,43 +70,6 @@ def to_didl_string(*args): return XML.tostring(didl, encoding='unicode') -def from_didl_string(string): - """Convert a unicode xml string to a list of `DIDLObjects `. - - Args: - string (str): A unicode string containing an XML representation of one - or more DIDL-Lite items (in the form ``' - ...'``) - - Returns: - list: A list of one or more instances of `DidlObject` or a subclass - """ - items = [] - root = XML.fromstring(string.encode('utf-8')) - for elt in root: - if elt.tag.endswith('item') or elt.tag.endswith('container'): - item_class = elt.findtext(ns_tag('upnp', 'class')) - - # In case this class has an # specified unofficial - # subclass, ignore it by stripping it from item_class - if '.#' in item_class: - item_class = item_class[:item_class.find('.#')] - - try: - cls = _DIDL_CLASS_TO_CLASS[item_class] - except KeyError: - raise DIDLMetadataError("Unknown UPnP class: %s" % item_class) - items.append(cls.from_element(elt)) - else: - # elements are allowed as an immediate child of - # according to the spec, but I have not seen one there in Sonos, so - # we treat them as illegal. May need to fix this if this - # causes problems. - raise DIDLMetadataError("Illegal child of DIDL element: <%s>" - % elt.tag) - return items - - ############################################################################### # DIDL RESOURCE # ############################################################################### diff --git a/server.sonos/soco/data_structures_entry.py b/server.sonos/soco/data_structures_entry.py new file mode 100644 index 0000000..cedf07f --- /dev/null +++ b/server.sonos/soco/data_structures_entry.py @@ -0,0 +1,137 @@ + +"""This module is for parsing and conversion functions that needs +objects from both music library and music service data structures + +""" + +from __future__ import absolute_import + +import sys +import logging + +from .xml import ( + XML, ns_tag +) +from .data_structures import _DIDL_CLASS_TO_CLASS +from .exceptions import DIDLMetadataError +from .compat import urlparse +from .music_services.data_structures import get_class +from .music_services.music_service import desc_from_uri + + +_LOG = logging.getLogger(__name__) +if not (sys.version_info[0] == 2 or sys.version_info[1] == 6): + _LOG.addHandler(logging.NullHandler()) +_LOG.debug('%s imported', __name__) + + +def from_didl_string(string): + """Convert a unicode xml string to a list of `DIDLObjects `. + + Args: + string (str): A unicode string containing an XML representation of one + or more DIDL-Lite items (in the form ``' + ...'``) + + Returns: + list: A list of one or more instances of `DidlObject` or a subclass + """ + items = [] + root = XML.fromstring(string.encode('utf-8')) + for elt in root: + if elt.tag.endswith('item') or elt.tag.endswith('container'): + item_class = elt.findtext(ns_tag('upnp', 'class')) + + # In case this class has an # specified unofficial + # subclass, ignore it by stripping it from item_class + if '.#' in item_class: + item_class = item_class[:item_class.find('.#')] + + try: + cls = _DIDL_CLASS_TO_CLASS[item_class] + except KeyError: + raise DIDLMetadataError("Unknown UPnP class: %s" % item_class) + item = cls.from_element(elt) + item = attempt_datastructure_upgrade(item) + items.append(item) + else: + # elements are allowed as an immediate child of + # according to the spec, but I have not seen one there in Sonos, so + # we treat them as illegal. May need to fix this if this + # causes problems. + raise DIDLMetadataError("Illegal child of DIDL element: <%s>" + % elt.tag) + _LOG.error( + 'Created data structures: %.20s (CUT) from Didl string "%.20s" (CUT)', + items, string, + ) + return items + + +# Obviously imcomplete, but missing entries will not result in error, but just +# a logged warning and no upgrade of the data structure +DIDL_NAME_TO_QUALIFIED_MS_NAME = { + 'DidlMusicTrack': 'MediaMetadataTrack' +} + + +def attempt_datastructure_upgrade(didl_item): + """Attempt to upgrade a didl_item to a music services data structure + if it originates from a music services + + """ + try: + resource = didl_item.resources[0] + except IndexError: + _LOG.debug('Upgrade not possible, no resources') + return didl_item + + if resource.uri.startswith('x-sonos-http'): + # Get data + uri = resource.uri + # Now we need to create a DIDL item id. It seems to be based on the uri + path = urlparse(uri).path + # Strip any extensions, eg .mp3, from the end of the path + path = path.rsplit('.', 1)[0] + # The ID has an 8 (hex) digit prefix. But it doesn't seem to + # matter what it is! + item_id = '11111111{0}'.format(path) + + # Ignore other metadata for now, in future ask ms data + # structure to upgrade metadata from the service + metadata = {} + try: + metadata['title'] = didl_item.title + except AttributeError: + pass + + # Get class + try: + cls = get_class(DIDL_NAME_TO_QUALIFIED_MS_NAME[ + didl_item.__class__.__name__ + ]) + except KeyError: + # The data structure should be upgraded, but there is an entry + # missing from DIDL_NAME_TO_QUALIFIED_MS_NAME. Log this as a + # warning. + _LOG.warning( + 'DATA STRUCTURE UPGRADE FAIL. Unable to upgrade music library ' + 'data structure to music service data structure because an ' + 'entry is missing for %s in DIDL_NAME_TO_QUALIFIED_MS_NAME. ' + 'This should be reported as a bug.', + didl_item.__class__.__name__, + ) + return didl_item + + upgraded_item = cls( + item_id=item_id, + desc=desc_from_uri(resource.uri), + resources=didl_item.resources, + uri=uri, + metadata_dict=metadata, + ) + _LOG.debug("Item %s upgraded to %s", didl_item, upgraded_item) + return upgraded_item + + _LOG.debug('Upgrade not necessary') + return didl_item diff --git a/server.sonos/soco/events.py b/server.sonos/soco/events.py index f1c7f57..65973a7 100755 --- a/server.sonos/soco/events.py +++ b/server.sonos/soco/events.py @@ -17,7 +17,7 @@ from .compat import ( Queue, BaseHTTPRequestHandler, URLError, socketserver, urlopen ) -from .data_structures import from_didl_string +from .data_structures_entry import from_didl_string from .exceptions import SoCoException from .utils import camel_to_underscore from .xml import XML @@ -317,11 +317,18 @@ def start(self, any_zone): # Sonos net, see http://stackoverflow.com/q/166506 with self._start_lock: if not self.is_running: - temp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - temp_sock.connect((any_zone.ip_address, - config.EVENT_LISTENER_PORT)) - ip_address = temp_sock.getsockname()[0] - temp_sock.close() + # Use configured IP address if there is one, else detect + # automatically. + if config.EVENT_LISTENER_IP: + ip_address = config.EVENT_LISTENER_IP + else: + temp_sock = socket.socket(socket.AF_INET, + socket.SOCK_DGRAM) + temp_sock.connect((any_zone.ip_address, + config.EVENT_LISTENER_PORT)) + ip_address = temp_sock.getsockname()[0] + temp_sock.close() + # Start the event listener server in a separate thread. self.address = (ip_address, config.EVENT_LISTENER_PORT) self._listener_thread = EventServerThread(self.address) @@ -601,6 +608,7 @@ def time_left(self): time_left = self.timeout - (time.time() - self._timestamp) return time_left if time_left > 0 else 0 + # pylint: disable=C0103 event_listener = EventListener() diff --git a/server.sonos/soco/exceptions.py b/server.sonos/soco/exceptions.py index 70adcf1..703b0d6 100755 --- a/server.sonos/soco/exceptions.py +++ b/server.sonos/soco/exceptions.py @@ -76,3 +76,7 @@ class UnknownXMLStructure(SoCoException): class SoCoSlaveException(SoCoException): """Raised when a master command is called on a slave.""" + + +class NotSupportedException(SoCoException): + """Raised when something is not supported by the device""" diff --git a/server.sonos/soco/ms_data_structures.py b/server.sonos/soco/ms_data_structures.py index 096a136..b7cf730 100644 --- a/server.sonos/soco/ms_data_structures.py +++ b/server.sonos/soco/ms_data_structures.py @@ -542,6 +542,7 @@ def __init__(self, title, item_id, extended_id, service_id, **kwargs): content.update(kwargs) super(MSCollection, self).__init__(**content) + MS_TYPE_TO_CLASS = {'artist': MSArtist, 'album': MSAlbum, 'track': MSTrack, 'albumList': MSAlbumList, 'favorites': MSFavorites, 'collection': MSCollection, 'playlist': MSPlaylist, diff --git a/server.sonos/soco/music_library.py b/server.sonos/soco/music_library.py index cd577a7..4813b02 100644 --- a/server.sonos/soco/music_library.py +++ b/server.sonos/soco/music_library.py @@ -13,11 +13,11 @@ from . import discovery from .data_structures import ( SearchResult, - from_didl_string, DidlResource, DidlObject, DidlMusicAlbum ) +from .data_structures_entry import from_didl_string from .exceptions import SoCoUPnPException from .utils import url_escape_path, really_unicode, camel_to_underscore diff --git a/server.sonos/soco/music_services/data_structures.py b/server.sonos/soco/music_services/data_structures.py new file mode 100644 index 0000000..fd42198 --- /dev/null +++ b/server.sonos/soco/music_services/data_structures.py @@ -0,0 +1,432 @@ +# -*- coding: utf-8 -*- +"""Data structures for music service items + +The basis for this implementation is this page in the Sonos API +documentation: http://musicpartners.sonos.com/node/83 + +A note about naming. The Sonos API uses camel case with starting lower +case. These names have been adapted to match general Python class +naming conventions. + +MediaMetadata: + Track + Stream + Show + Other + +MediaCollection: + Artist + Album + Genre + Playlist + Search + Program + Favorites + Favorite + Collection + Container + AlbumList + TrackList + StreamList + ArtistTrackList + Other + +NOTE: "Other" is allowed under both. + +Class overview: + ++----------------+ +----------------+ +---------------+ +|MetadataDictBase+-->+MusicServiceItem+-->+MediaCollection| ++-----+----------+ +--------+-------+ +---------------+ + | | + | | +------------------+ + | +---->+ MediaMetadata | + | | | + | | +-------------+ | + +------------------------------>+TrackMetadata| | + | | +-------------+ | + | | | + | | +--------------+ | + +------------------------------>+StreamMetadata| | + | +--------------+ | + | | + +------------------+ + + +""" + +from __future__ import print_function, absolute_import +import sys +import logging +try: + from collections import OrderedDict +except ImportError: + from ordereddict import OrderedDict +from ..data_structures import DidlResource, DidlItem, SearchResult +from ..utils import camel_to_underscore +from ..compat import quote_url + + +_LOG = logging.getLogger(__name__) +if not (sys.version_info[0] == 2 and sys.version_info[1] == 6): + _LOG.addHandler(logging.NullHandler()) + + +# For now we generate classes dynamically. This is shorter, but +# provides no custom documentation for all the different types. +CLASSES = {} + + +def get_class(class_key): + """Form a music service data structure class from the class key + + Args: + class_key (str): A concatenation of the base class (e.g. MediaMetadata) + and the class name + + Returns: + class: Subclass of MusicServiceItem + """ + if class_key not in CLASSES: + for basecls in (MediaMetadata, MediaCollection): + if class_key.startswith(basecls.__name__): + # So MediaMetadataTrack turns into MSTrack + class_name = 'MS' + class_key.replace(basecls.__name__, '') + if sys.version_info[0] == 2: + class_name = class_name.encode('ascii') + CLASSES[class_key] = type(class_name, (basecls,), {}) + _LOG.info('Class %s created', CLASSES[class_key]) + return CLASSES[class_key] + + +def parse_response(service, response, search_type): + """Parse the response to a music service query and return a SearchResult + + Args: + service (MusicService): The music service that produced the response + response (OrderedDict): The response from the soap client call + search_type (str): A string that indicates the search type that the + response is from + + Returns: + SearchResult: A SearchResult object + """ + _LOG.debug('Parse response "%s" from service "%s" of type "%s"', response, + service, search_type) + items = [] + # The result to be parsed is in either searchResult or getMetadataResult + if 'searchResult' in response: + response = response['searchResult'] + elif 'getMetadataResult' in response: + response = response['getMetadataResult'] + else: + raise ValueError('"response" should contain either the key ' + '"searchResult" or "getMetadataResult"') + + # Form the search metadata + search_metadata = { + 'number_returned': response['count'], + 'total_matches': None, + 'search_type': search_type, + 'update_id': None, + } + + for result_type in ('mediaCollection', 'mediaMetadata'): + # Upper case the first letter (used for the class_key) + result_type_proper = result_type[0].upper() + result_type[1:] + raw_items = response.get(result_type, []) + # If there is only 1 result, it is not put in an array + if isinstance(raw_items, OrderedDict): + raw_items = [raw_items] + + for raw_item in raw_items: + # Form the class_key, which is a unique string for this type, + # formed by concatenating the result type with the item type. Turns + # into e.g: MediaMetadataTrack + class_key = result_type_proper + raw_item['itemType'].title() + cls = get_class(class_key) + items.append(cls.from_music_service(service, raw_item)) + return SearchResult(items, **search_metadata) + + +def form_uri(item_id, service, is_track): + """Form and return a music service item uri + + Args: + item_id (str): The item id + service (MusicService): The music service that the item originates from + is_track (bool): Whether the item_id is from a track or not + + Returns: + str: The music service item uri + """ + if is_track: + uri = service.sonos_uri_from_id(item_id) + else: + uri = 'x-rincon-cpcontainer:' + item_id + return uri + + +# Type Helper +BOOL_STRS = set(('true', 'false')) + + +def bool_str(string): + """Returns a boolean from a string imput of 'true' or 'false'""" + if string not in BOOL_STRS: + raise ValueError('Invalid boolean string: "{}"'.format(string)) + return True if string == 'true' else False + + +# Music Service item base classes +class MetadataDictBase(object): + """Class used to parse metadata from kwargs""" + + # The following two fields should be overwritten in subclasses + + # _valid_fields is a set of valid fields + _valid_fields = {} + + # _types is a dict of fields with non-string types and their convertion + # callables + _types = {} + + def __init__(self, metadata_dict): + """Initialize local variables""" + _LOG.debug('MetadataDictBase.__init__ with: %s', metadata_dict) + for key in metadata_dict: + # Check for invalid fields + if key not in self._valid_fields: + message = ('%s instantiated with invalid field "%s" and ' + 'value: %s') + # Really wanted to raise exceptions here, but as it + # turns out I have already encountered invalid fields + # from music services. + _LOG.debug(message, self.__class__, key, metadata_dict[key]) + + # Convert names and create metadata dict + self.metadata = {} + for key, value in metadata_dict.items(): + if key in self._types: + convertion_callable = self._types[key] + value = convertion_callable(value) + self.metadata[camel_to_underscore(key)] = value + + def __getattr__(self, key): + """Return item from metadata in case of unknown attribute""" + try: + return self.metadata[key] + except KeyError: + message = 'Class {0} has no attribute "{1}"' + raise AttributeError(message.format(self.__class__.__name__, key)) + + +class MusicServiceItem(MetadataDictBase): + """A base class for all music service items""" + + # See comment in MetadataDictBase for explanation of these two attributes + _valid_fields = {} + _types = {} + + def __init__(self, item_id, desc, # pylint: disable=too-many-arguments + resources, uri, metadata_dict, music_service=None): + """Init music service item + + Args: + item_id (str): This is the Didl compatible id NOT the music item id + desc (str): A DIDL descriptor, default ``'RINCON_AssociatedZPUDN' + resources (list): List of DidlResource + uri (str): The uri for the location of the item + metdata_dict (dict): Mapping of metadata + music_service (MusicService): The MusicService instance the item + originates from + """ + _LOG.debug('%s.__init__ with item_id=%s, desc=%s, resources=%s, ' + 'uri=%s, metadata_dict=..., music_service=%s', + self.__class__.__name__, item_id, desc, resources, uri, + music_service) + super(MusicServiceItem, self).__init__(metadata_dict) + self.item_id = item_id + self.desc = desc + self.resources = resources + self.uri = uri + self.music_service = music_service + + @classmethod + def from_music_service(cls, music_service, content_dict): + """Return an element instantiated from the information that a music + service has (alternative constructor) + + Args: + music_service (MusicService): The music service that content_dict + originated from + content_dict (OrderedDict): The data to instantiate the music + service item from + + Returns: + MusicServiceItem: A MusicServiceItem instance + """ + # Form the item_id + quoted_id = quote_url(content_dict['id'].encode('utf-8')) + # The hex prefix remains a mistery for now + item_id = '0fffffff{0}'.format(quoted_id) + # Form the uri + is_track = cls == get_class('MediaMetadataTrack') + uri = form_uri(item_id, music_service, is_track) + # Form resources and get desc + resources = [DidlResource(uri=uri, protocol_info="DUMMY")] + desc = music_service.desc + return cls(item_id, desc, resources, uri, content_dict, + music_service=music_service) + + def __str__(self): + """Return custom string representation""" + title = self.metadata.get('title') + str_ = '<{0} title="{1}">' + return str_.format(self.__class__.__name__, title) + + def to_element(self, include_namespaces=False): + """Return an ElementTree Element representing this instance. + + Args: + include_namespaces (bool, optional): If True, include xml + namespace attributes on the root element + + Return: + ~xml.etree.ElementTree.Element: The (XML) Element representation of + this object + """ + # We piggy back on the implementation in DidlItem + didl_item = DidlItem( + title="DUMMY", + # This is ignored. Sonos gets the title from the item_id + parent_id="DUMMY", # Ditto + item_id=self.item_id, + desc=self.desc, + resources=self.resources + ) + return didl_item.to_element(include_namespaces=include_namespaces) + + +class TrackMetadata(MetadataDictBase): + """Track metadata class""" + + # _valid_fields is a set of valid fields + _valid_fields = set(( + 'artistId', + 'artist', + 'composerId', + 'composer', + 'albumId', + 'album', + 'albumArtURI', + 'albumArtistId', + 'albumArtist', + 'genreId', + 'genre', + 'duration', + 'canPlay', + 'canSkip', + 'canAddToFavorites', + 'rating', + 'trackNumber', + 'isFavorite', + )) + # _types is a dict of fields with non-string types and their + # convertion callables + _types = { + 'duration': int, + 'canPlay': bool_str, + 'canSkip': bool_str, + 'canAddToFavorites': bool_str, + 'rating': int, + 'trackNumber': int, + 'isFavorite': bool_str, + } + + +class StreamMetadata(MetadataDictBase): + """Stream metadata class""" + + # _valid_fields is a set of valid fields + _valid_fields = set(( + 'currentHost', + 'currentShowId', + 'currentShow', + 'secondsRemaining', + 'secondsToNextShow', + 'bitrate', + 'logo', + 'hasOutOfBandMetadata', + 'description', + 'isEphemeral', + )) + # _types is a dict of fields with non-string types and their + # convertion callables + _types = { + 'secondsRemaining': int, + 'secondsToNextShow': int, + 'bitrate': int, + 'hasOutOfBandMetadata': bool_str, + 'isEphemeral': bool_str, + } + + +class MediaMetadata(MusicServiceItem): + """Base class for all media metadata items""" + + # _valid_fields is a set of valid fields + _valid_fields = set(( + 'id', + 'title', + 'mimeType', + 'itemType', + 'displayType', + 'summary', + 'trackMetadata', + 'streamMetadata', + 'dynamic', + )) + # _types is a dict of fields with non-string types and their + # convertion callables + _types = { + 'trackMetadata': TrackMetadata, + 'streamMetadata': StreamMetadata, + # We ignore types on the dynamic field + # 'dynamic': ???, + } + + +class MediaCollection(MusicServiceItem): + """Base class for all mediaCollection items""" + + # _valid_fields is a set of valid fields + _valid_fields = set(( + 'id', + 'title', + 'itemType', + 'displayType', + 'summary', + 'artistId', + 'artist', + 'albumArtURI', + 'canPlay', + 'canEnumerate', + 'canAddToFavorites', + 'containsFavorite', + 'canScroll', + 'canSkip', + 'isFavorite', + )) + + # _types is a dict of fields with non-string types and their + # convertion callables + _types = { + 'canPlay': bool_str, + 'canEnumerate': bool_str, + 'canAddToFavorites': bool_str, + 'containsFavorite': bool_str, + 'canScroll': bool_str, + 'canSkip': bool_str, + 'isFavorite': bool_str, + } diff --git a/server.sonos/soco/music_services/music_service.py b/server.sonos/soco/music_services/music_service.py index bd0f438..ec85cff 100644 --- a/server.sonos/soco/music_services/music_service.py +++ b/server.sonos/soco/music_services/music_service.py @@ -18,6 +18,7 @@ from ..compat import parse_qs, quote_url, urlparse from ..exceptions import MusicServiceException from ..music_services.accounts import Account +from .data_structures import parse_response, MusicServiceItem from ..soap import SoapFault, SoapMessage from ..xml import XML @@ -655,12 +656,13 @@ def desc(self): # setPlayedSeconds(id id, xs:int seconds) def get_metadata( - self, item_id='root', index=0, count=100, recursive=False): + self, item='root', index=0, count=100, recursive=False): """Get metadata for a container or item. Args: - item_id (str): The container or item to browse. Defaults to the - root item. + item (str or MusicServiceItem): The container or item to browse + given either as a MusicServiceItem instance or as a str. + Defaults to the root item. index (int): The starting index. Default 0. count (int): The maximum number of items to return. Default 100. recursive (bool): Whether the browse should recurse into sub-items @@ -675,13 +677,17 @@ def get_metadata( `_. """ + if isinstance(item, MusicServiceItem): + item_id = item.id # pylint: disable=no-member + else: + item_id = item response = self.soap_client.call( 'getMetadata', [ ('id', item_id), ('index', index), ('count', count), ('recursive', 1 if recursive else 0)] ) - return response.get('getMetadataResult', None) + return parse_response(self, response, 'browse') def search(self, category, term='', index=0, count=100): """Search for an item in a category. @@ -713,7 +719,8 @@ def search(self, category, term='', index=0, count=100): [ ('id', search_category), ('term', term), ('index', index), ('count', count)]) - return response.get('searchResult', None) + + return parse_response(self, response, category) def get_media_metadata(self, item_id): """Get metadata for a media item. diff --git a/server.sonos/sonos-broker b/server.sonos/sonos-broker new file mode 100755 index 0000000..23f0954 --- /dev/null +++ b/server.sonos/sonos-broker @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# #################################################################### +# Imports +# #################################################################### +import os +import argparse +import locale +import logging +import logging.handlers +import configparser +import signal +import time +import pwd +from lib_sonos import utils +from lib_sonos import definitions +from lib_sonos.sonos_service import SonosServerService +import requests + +# #################################################################### +# GLOBALS +# #################################################################### + +homedir = os.path.dirname(os.path.realpath(__file__)) +logger = logging.getLogger('sonos_broker') + + +class SonosBroker(object): + @property + def list_only(self): + return self._list_only + + @list_only.setter + def list_only(self, value): + self._list_only = value + + def __init__(self, debug=False, config=None): + global homedir + global logger + self.stopped = False + self._debug = debug + self._loghandler = None + self._host = '' + self._port = '' + self._tts_local_mode = False + self._quota = None + self._logfile = None + self._port = None + self._host = None + self._sonos_service = None + self._server_active = True + self._list_only = False + self._config = config + self._webservice_path = None + self._webservice_url = None + + # ############################################################ + # Signal Handling + # ############################################################ + + signal.signal(signal.SIGHUP, self.stop) + signal.signal(signal.SIGINT, self.stop) + signal.signal(signal.SIGTERM, self.stop) + + config = configparser.ConfigParser() + + if self._config is None: + config_path = os.path.join(homedir, definitions.DEFAULT_CFG) + else: + config_path = self._config + + config.read(config_path) + + # ############################################################ + # Logging + # ############################################################ + + if config.has_section('logging'): + if config.has_option('logging', 'loglevel'): + loglevel = config.get('logging', 'loglevel') + else: + loglevel = 'warning' + + if self._debug: + loglevel = 'debug' + + if config.has_option('logging', 'logfile'): + self._logfile = config.get('logging', 'logfile').strip("\"").strip("'") + else: + self._logfile = definitions.DEFAULT_LOG + + self._logfile = os.path.expanduser(self._logfile) + self._logfile = os.path.expandvars(self._logfile) + + if not os.path.isabs(self._logfile): + self._logfile = os.path.join(homedir, self._logfile) + + try: + if not os.path.exists(os.path.dirname(self._logfile)): + os.makedirs(os.path.dirname(self._logfile)) + except Exception: + logger.error("Couldn't create logfile path '{path}'. Using default path '{default_path}'!".format( + path=os.path.dirname(self._logfile, default_path=definitions.DEFAULT_LOG))) + + numeric_level = getattr(logging, loglevel.upper(), None) + + if not isinstance(numeric_level, int): + raise ValueError('Invalid log level: %s' % loglevel) + + logdate = "%Y-%m-%d %H:%M:%S" + logformat = "%(asctime)s %(levelname)-8s %(threadName)-12s %(message)s" + + if numeric_level == logging.DEBUG: + logdate = None + logformat = "%(asctime)s %(levelname)-" \ + "8s %(threadName)-12s %(message)s -- %(filename)s:%(funcName)s:%(lineno)d" + + logging.basicConfig(level=numeric_level, format=logformat, datefmt=logdate) + + ############################################################# + # logfile write test + ############################################################# + + if self._logfile: + os.umask(0o002) + try: + with open(self._logfile, 'a') as f: + f.write("Init sonos broker {version}\n".format(version=definitions.VERSION)) + except IOError as e: + print("Error creating logfile {}: {}".format(self._logfile, e)) + + try: + formatter = logging.Formatter(logformat, logdate) + self._loghandler = logging.handlers.TimedRotatingFileHandler(self._logfile, when='midnight', + backupCount=7, encoding='utf-8') + self._loghandler.setLevel(numeric_level) + self._loghandler.setFormatter(formatter) + if numeric_level == logging.DEBUG: # clean log + self._loghandler.doRollover() + logger.addHandler(self._loghandler) + + # set the loglevel for soco framework + logging.getLogger('soco.core').addHandler(self._loghandler) + except IOError as e: + print("Error creating logfile {}: {}".format(self._logfile, e)) + + ############################################################## + # Sonos Broker + ############################################################## + + if config.has_section('sonos_broker'): + self._host = config.get('sonos_broker', 'host', fallback=definitions.DEFAULT_HOST) + self._port = config.getint('sonos_broker', 'port', fallback=definitions.DEFAULT_PORT) + + ############################################################## + # Web Service + ############################################################## + + if config.has_section('webservice'): + # check server root path + # exists ? + webservice_path = config.get('webservice', 'webservice_path', fallback='') + if webservice_path: + logger.debug("Webservice path set to '{path}'.".format(path=webservice_path)) + if os.path.exists(webservice_path): + # check for permissions + if os.access(webservice_path, os.R_OK): + logger.debug( + "Webservice path '{path}' permission ok.".format(path=webservice_path)) + # if all checks passed, then we set the variable + self._webservice_path = webservice_path + else: + user_name = pwd.getpwuid(os.getuid()).pw_name + logger.error( + "User '{user}' has no read permissions for path '{path}'. Webservice functionality for " + "audio files disabled.".format(user=user_name, path=webservice_path)) + else: + logger.warning("Webservice path '{path}' not exists. Webservice functionality for audio " + "files disabled.".format(path=webservice_path)) + else: + logger.warning("No webservice path set. Webservice functionality for audio files disabled.") + + if self._webservice_path: + webservice_ip = config.get('webservice', 'webservice_ip', fallback='') + + if not webservice_ip: + logger.debug("Webservice IP not set, trying to detect the local ip automatically ...") + webservice_ip = utils.get_lan_ip() + if webservice_ip is None: + logger.warning("Could not detect local ip address automatically!") + + if webservice_ip is None: + logger.warning("No webservice IP set. Webservice functionality for audio files disabled.") + else: + logger.debug("Webservice IP set to {ip}".format(ip=webservice_ip)) + + self._webservice_url = "http://{ip}:{port}".format(ip=webservice_ip, port=self._port) + + self._quota = config.getint('webservice', 'quota', fallback=definitions.DEFAULT_QUOTA) + logger.debug("Quota set to {quota} mb".format(quota=self._quota)) + + local_tts = config.getboolean('webservice', 'local_google_tts', fallback=False) + + if local_tts: + # check if webservice is running + if self._webservice_path is None: + logger.warning("Local TTS mode was set to True, but the webservice is not properly " + "configured. TTS will be streamed directly.") + local_tts = False + else: + # check write permissions + if not os.access(self._webservice_path, os.W_OK): + logger.warning("Local TTS mode was set to True, but the webservice path is not writeable for " + "current user. TTS will be streamed directly.") + local_tts = False + else: + logger.debug("Write permissions ok for tts on path {path}".format(path=self._webservice_path)) + + self._tts_local_mode = local_tts + logger.debug('Local Google TTS mode set to {mode}.'.format(mode=self._tts_local_mode)) + + else: + logger.error("Section [webservice] not found in configuration file.") + exit() + + def start(self): + + signal.signal(signal.SIGHUP, self.stop) + signal.signal(signal.SIGINT, self.stop) + signal.signal(signal.SIGTERM, self.stop) + + logger.info("Sonos Broker v{version}".format(version=definitions.VERSION)) + time.sleep(1) + self._sonos_service = SonosServerService(self._host, self._port, self._webservice_url, self._webservice_path, + self._quota, self._tts_local_mode) + + def stop(self, *args): + logger.debug('Shutting down Sonos Broker ...') + logger.debug('unsubscribing from sonos speakers ...') + if self._sonos_service is not None: + self._sonos_service.unsubscribe_speaker_events() + self._sonos_service.terminate_threads() + exit() + + +def scan(): + print('\n\nScanning for Sonos speaker in the network ...') + soco_speakers = SonosServerService._discover() + suffix = '' + + if len(soco_speakers) > 1: + suffix = "s" + + print("Found {} speaker{} in the network.\n".format(len(soco_speakers), suffix)) + + for speaker in soco_speakers: + try: + info = speaker.get_speaker_info(timeout=5) + print("\n{}".format(speaker.uid)) + print("-" * len(speaker.uid)) + print("\tip :\t{}".format(speaker.ip_address)) + print("\tname :\t{}".format(speaker.player_name)) + print("\tmodel:\t{}\n".format(info['model_name'])) + except requests.ConnectionError: + print("Speaker '{uid}' seems to be offline.".format(uid=speaker.uid)) + continue + except Exception as ex: + print('unknown error') + print(ex) + +if __name__ == '__main__': + argparser = argparse.ArgumentParser() + subparsers = argparser.add_subparsers(dest="subparser_name") + start_parser = subparsers.add_parser(name='start', help="Starts the Sonos Broker. ") + start_parser.add_argument('-d', '--debug', help='Debug Mode: Broker starts with verbose output', + action='store_true') + start_parser.add_argument('-c', '--config', help='[Optional] path to a config file.', dest='config') + list_parser = subparsers.add_parser(name='list', help="Lists all Sonos speaker in the network.") + + args = argparser.parse_args() + + if args.subparser_name == "list": + scan() + + elif args.subparser_name == "start": + config_path = definitions.DEFAULT_CFG + if args.config: + config_path = os.path.abspath(args.config) + if not os.path.exists(config_path): + print("Config file not found [** {path} **]".format(path=os.path.abspath(config_path))) + exit() + SonosBroker(args.debug, config=config_path).start() + else: + argparser.print_help() \ No newline at end of file diff --git a/server.sonos/sonos_cmd b/server.sonos/sonos-cmd similarity index 90% rename from server.sonos/sonos_cmd rename to server.sonos/sonos-cmd index 8fe44f6..8a329d4 100755 --- a/server.sonos/sonos_cmd +++ b/server.sonos/sonos-cmd @@ -270,12 +270,14 @@ class SonosSpeakerCmd(cmd.Cmd): self.max_volume = None self.additional_zone_members = None self.bass = None + self.nightmode = None self.treble = None self.loudness = None self.playmode = None self.alarms = None self.is_coordinator = False self.balance = None + self.transport_actions = None def do_EOF(self, *args): """ @@ -348,6 +350,64 @@ class SonosSpeakerCmd(cmd.Cmd): return print("balance: {}".format(self.balance)) + def help_join(self): + print("join: Gets the current join state (with zone members, zone_name and is_coordinator).") + print("join [get]: Gets the current join state (with zone members, zone_name and is_coordinator).") + print("join [set]: Joins the speaker to a group.") + + def do_join(self, line): + line = line.lower() + if not line: + pass + elif line == "get": + pass + elif line == "set": + uid_to_join = input(normalize_output("Sonos uid to join", "rincon_xxxx", None)) + if uid_to_join: + self.commands.join(self.uid, uid_to_join) + sleep(1) + else: + print("unknown argument") + return + print("in zone: {0}".format(self.zone_name)) + print("zone member: {0}".format(self.additional_zone_members)) + print("is coordinator: {0}".format(self.is_coordinator)) + + joined_group = False + if self.additional_zone_members: + joined_group = True + print("joined group: {0}".format(joined_group)) + + def help_unjoin(self): + print("unjoin: Gets the current join state (with zone members, zone_name and is_coordinator).") + print("unjoin [get]: Gets the current join state (with zone members, zone_name and is_coordinator).") + print("unjoin [set]: Unjoins the speaker from a group.") + + def do_unjoin(self, line): + line = line.lower() + if not line: + pass + elif line == "get": + pass + elif line == "set": + default_play = 0 + play = input(normalize_output("Play after unjoin", "0|1", default_play)) + if not play: + play = default_play + self.commands.unjoin(self.uid, play=play) + sleep(1) + else: + print("unknown argument") + return + print("in zone: {0}".format(self.zone_name)) + print("zone member: {0}".format(self.additional_zone_members)) + print("is coordinator: {0}".format(self.is_coordinator)) + + joined_group = False + if self.additional_zone_members: + joined_group = True + print("joined group: {0}".format(joined_group)) + def help_bass(self): print("bass: Gets the current bass level.") print("bass [get]: Forces the Broker to retrieve the speakers bass level.") @@ -373,6 +433,31 @@ class SonosSpeakerCmd(cmd.Cmd): return print("bass: {}".format(self.bass)) + def help_nightmode(self): + print("nighmode: Gets the current nightmode option.") + print("nightmode [get]: Forces the Broker to retrieve the speakers nightmode option.") + print("nightmode [set]: Sets the nightmode option (only for supported Sonos speakers (like Playbar).") + + def do_nightmode(self, line): + line = line.lower() + if not line: + pass + elif line == "get": + self.commands.get_nightmode(self.uid) + elif line == "set": + default_nightmode = self.nightmode + nightmode = input(normalize_output("Nightmode", "0|1", default_nightmode)) + if nightmode is None: + nightmode = default_nightmode + if nightmode not in ['0', '1']: + nightmode = 0 + self.commands.set_nightmode(self.uid, nightmode) + else: + print("unknown argument") + return + print("nightmode: {}".format(self.nightmode)) + + def help_treble(self): print("treble: Gets the current 'treble' value.") print("treble [get]: Forces the Broker to retrieve the speakers 'treble' value.") @@ -678,6 +763,21 @@ class SonosSpeakerCmd(cmd.Cmd): print("volume: {}".format(self.volume)) print("max_volume: {}".format(self.max_volume)) + def help_transport_actions(self): + print("transport_actions: Gets the current transport actions for the current track.") + print("transport_actions [get]: Forces the Broker to retrieve the transport actions for the current track.") + + def do_transport_actions(self, line): + line = line.lower() + if not line: + pass + elif line == "get": + self.commands.get_transport_actions(self.uid) + else: + print("unknown argument") + return + print("transport_actions: {}".format(self.transport_actions)) + def help_track_url(self): print("track_url: Gets the current track url.") print("track_url [get]: Forces the Broker to retrieve the current track url.") @@ -857,7 +957,11 @@ class SonosSpeakerCmd(cmd.Cmd): if not group_command: group_command = 0 - self.commands.play_tts(self.uid, tts, volume, language, fade_in, group_command) + force_stream_mode = input(normalize_output("force_stream_mode", "0|1", "0")) + if not force_stream_mode: + force_stream_mode = 0 + + self.commands.play_tts(self.uid, tts, volume, language, fade_in, group_command, force_stream_mode) def help_is_coordinator(self): print("is_coordinator: Returns the status whether the speaker is a group coordinator or not.") @@ -1121,6 +1225,16 @@ class Commands(): } ) + def get_zone_members(self, uid): + return self.send( + { + 'command': 'zone_members', + 'parameter': { + 'uid': uid.lower() + } + } + ) + def get_balance(self, uid): return self.send( { @@ -1556,7 +1670,7 @@ class Commands(): } ) - def play_tts(self, uid, tts, volume, language, fade_in, group_command): + def play_tts(self, uid, tts, volume, language, fade_in, group_command, force_stream_mode): return self.send( { 'command': 'play_tts', @@ -1566,11 +1680,34 @@ class Commands(): 'volume': volume, 'fade_in': fade_in, 'group_command': group_command, - 'language': language + 'language': language, + 'force_stream_mode': force_stream_mode + } + } + ) + + def set_nightmode(self, uid, nightmode): + return self.send( + { + 'command': 'set_nightmode', + 'parameter': { + 'uid': uid.lower(), + 'nightmode': int(nightmode), + } + } + ) + + def get_nightmode(self, uid): + return self.send( + { + 'command': 'get_nightmode', + 'parameter': { + 'uid': uid.lower(), } } ) + def is_coordinator(self, uid): return self.send( { @@ -1642,6 +1779,39 @@ class Commands(): } ) + def join(self, uid, join_uid): + return self.send( + { + 'command': 'join', + 'parameter': { + 'uid': uid.lower(), + 'join_uid': join_uid + } + } + ) + + def get_transport_actions(self, uid): + return self.send( + { + 'command': 'get_transport_actions', + 'parameter': { + 'uid': uid.lower(), + } + } + ) + + + def unjoin(self, uid, play=0): + return self.send( + { + 'command': 'unjoin', + 'parameter': { + 'uid': uid.lower(), + 'play': play + } + } + ) + def load_sonos_playlist(self, uid, sonos_playlist, play_after_insert, clear_queue): return self.send( diff --git a/server.sonos/sonos_broker b/server.sonos/sonos_broker deleted file mode 100755 index b5b08f7..0000000 --- a/server.sonos/sonos_broker +++ /dev/null @@ -1,394 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# #################################################################### -# Imports -# #################################################################### -import json - -import os -import argparse -from http.server import BaseHTTPRequestHandler, HTTPServer -import locale -import socketserver -import logging -import logging.handlers -import threading -import configparser -import signal -import time -from lib_sonos import utils -from lib_sonos import definitions -from lib_sonos.sonos_service import SonosServerService -from lib_sonos import daemon -from lib_sonos import sonos_commands - -# #################################################################### -# GLOBALS -# #################################################################### -import requests - -command_service = None -homedir = os.path.dirname(os.path.realpath(__file__)) -logger = logging.getLogger('') - -class SonosHttpHandler(BaseHTTPRequestHandler): - def do_GET(self): - try: - global command_service - result, response = command_service.do_work(self.client_address[0], self.path) - if result: - self.send_response(definitions.HTTP_SUCCESS, 'OK') - else: - self.send_response(definitions.HTTP_ERROR, 'Bad request') - self.send_header("Content-type", "text/html") - self.end_headers() - self.wfile.write("{}".format(response).encode('utf-8')) - finally: - self.connection.close() - - def do_POST(self): - try: - size = int(self.headers["Content-length"]) - command = self.rfile.read(size).decode('utf-8') - - try: - cmd_obj = json.loads(command, cls=sonos_commands.MyDecoder) - except AttributeError as err: - err_command = list(filter(None, err.args[0].split("'")))[-1] - self.make_response(False, "No command '{command}' found!".format(command=err_command)) - return - status, response = cmd_obj.run() - self.make_response(status, response) - logger.debug('Server response -- status: {status} -- response: {response}'.format(status=status, - response=response)) - - finally: - self.connection.close() - - def make_response(self, status, response): - if status: - self.send_response(definitions.HTTP_SUCCESS, 'OK') - else: - self.send_response(definitions.HTTP_ERROR, 'Bad request') - self.send_header("Content-type", "text/html") - self.end_headers() - self.wfile.write("Sonos Broker".encode('utf-8')) - self.wfile.write("{response}".format(response=response).encode('utf-8')) - self.wfile.write("".encode('utf-8')) - -class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer): - """Handle requests in a separate thread.""" - - -class SonosBroker(): - @property - def loghandler(self): - return self._loghandler - - @property - def list_only(self): - return self._list_only - - @list_only.setter - def list_only(self, value): - self._list_only = value - - def __init__(self, debug=False, config=None): - global command_service - global homedir - global logger - self._debug = debug - self._loghandler = None - self._http_server = None - self._host = '' - self._port = '' - self._tts_local_mode = False - self._save_path = None - self._server_url = None - self._quota = None - self._server_ip = None - self._logfile = None - self._port = definitions.DEFAULT_PORT - self._host = definitions.DEFAULT_HOST - self._sonos_service = None - self._server_active = True - self._list_only = False - self._config = config - - # ############################################################ - # Signal Handling - # ############################################################ - - signal.signal(signal.SIGHUP, self.stop) - signal.signal(signal.SIGINT, self.stop) - signal.signal(signal.SIGTERM, self.stop) - - config = configparser.ConfigParser() - - if self._config is None: - config_path = os.path.join(homedir, definitions.DEFAULT_CFG) - else: - config_path = self._config - - config.read(config_path) - - # ############################################################ - # Logging - # ############################################################ - - if config.has_section('logging'): - if config.has_option('logging', 'loglevel'): - loglevel = config.get('logging', 'loglevel') - else: - loglevel = 'warning' - - if self._debug: - loglevel = 'debug' - - if config.has_option('logging', 'logfile'): - self._logfile = config.get('logging', 'logfile').strip("\"").strip("'") - else: - self._logfile = definitions.DEFAULT_LOG - - self._logfile = os.path.expanduser(self._logfile) - self._logfile = os.path.expandvars(self._logfile) - - if not os.path.isabs(self._logfile): - self._logfile = os.path.join(homedir, self._logfile) - - try: - if not os.path.exists(os.path.dirname(self._logfile)): - os.makedirs(os.path.dirname(self._logfile)) - except Exception: - logger.error("Couldn't create logfile path '{path}'. Using default path {'def_path}'!".format( - path=os.path.dirname(self._logfile, def_path=definitions.DEFAULT_LOG))) - - numeric_level = getattr(logging, loglevel.upper(), None) - - if not isinstance(numeric_level, int): - raise ValueError('Invalid log level: %s' % loglevel) - - logdate = "%Y-%m-%d %H:%M:%S" - logformat = "%(asctime)s %(levelname)-8s %(threadName)-12s %(message)s" - - if numeric_level == logging.DEBUG: - logdate = None - logformat = "%(asctime)s %(levelname)-" \ - "8s %(threadName)-12s %(message)s -- %(filename)s:%(funcName)s:%(lineno)d" - - logging.basicConfig(level=numeric_level, format=logformat, datefmt=logdate) - - ############################################################# - # logfile write test - ############################################################# - - if self._logfile: - os.umask(0o002) - try: - with open(self._logfile, 'a') as f: - f.write("Init sonos broker {version}\n".format(version=definitions.VERSION)) - except IOError as e: - print("Error creating logfile {}: {}".format(self._logfile, e)) - - try: - formatter = logging.Formatter(logformat, logdate) - self._loghandler = logging.handlers.TimedRotatingFileHandler(self._logfile, when='midnight', - backupCount=7) - self._loghandler.setLevel(numeric_level) - self._loghandler.setFormatter(formatter) - if numeric_level == logging.DEBUG: # clean log - self._loghandler.doRollover() - logger.addHandler(self._loghandler) - - # set the loglevel for soco framework - logging.getLogger('soco.core').addHandler(self._loghandler) - except IOError as e: - print("Error creating logfile {}: {}".format(self._logfile, e)) - - if config.has_section('sonos_broker'): - if config.has_option('sonos_broker', 'server_ip'): - self._server_ip = config.get('sonos_broker', 'server_ip') - - if config.has_option('sonos_broker', 'host'): - self._host = config.get('sonos_broker', 'host') - - if config.has_option('sonos_broker', 'port'): - self._port = config.getint('sonos_broker', 'port') - - if not self._server_ip: - self._server_ip = utils.get_lan_ip() - if not self._server_ip: - raise Exception("Could not detect the internal server ip automatically! Set the ip address " - "manually (see config file)") - - if config.has_section('google_tts'): - if config.has_option('google_tts', 'enabled'): - self._tts_local_mode = config.getboolean('google_tts', 'enabled') - - if self._tts_local_mode: - if config.has_option('google_tts', 'save_path'): - self._save_path = config.get('google_tts', 'save_path') - - if config.has_option('google_tts', 'server_url'): - self._server_url = config.get('google_tts', 'server_url') - - if config.has_option('google_tts', 'quota'): - self._quota = config.getint('google_tts', 'quota') - - if self._tts_local_mode and not self._save_path: - logger.warning('No local save path given!') - self._tts_local_mode = False - - if self._tts_local_mode and not self._server_url: - logger.warning('No local server url given!') - self._tts_local_mode = False - - if self._tts_local_mode and not self._quota: - self._quota = definitions.DEFAULT_QUOTA - - if self._tts_local_mode: - if not utils.check_directory_permissions(self._save_path): - logger.warning('No sufficient folder permissions in \'{}\'!'.format(self._save_path)) - self._tts_local_mode = False - else: - free_diskspace = utils.get_free_space_mb(self._save_path) - logger.info('Free diskspace: {} mb'.format(free_diskspace)) - - if free_diskspace < self._quota: - logger.warning( - 'Not enough disk space left on \'{}\'. At least {} mb of free diskspace required!'.format( - self._save_path, - self._quota)) - logger.warning("\nIgnore this warning, if the snippet directoy is a smb or cifs mounted " - "directory.") - - if not self._tts_local_mode: - logger.debug("Google-TTS disabled!") - else: - logger.debug("Google-TTS 'local mode' enabled!") - logger.debug('server_url: {}'.format(self._server_url)) - logger.debug('save_path: {}'.format(self._save_path)) - - def start(self): - global command_service - logger.info("Sonos Broker v{version}".format(version=definitions.VERSION)) - logger.info( - "Starting server with ip address {ip} ... be sure this is correct.".format(ip=self._server_ip)) - time.sleep(1) - self._sonos_service = SonosServerService(self._server_ip, self._port, self._server_url, self._save_path, - self._quota, self._tts_local_mode) - self._http_server = ThreadedHTTPServer((self._host, self._port), SonosHttpHandler) - logger.info('Starting http server, use to stop') - - while self._server_active: - self._http_server.serve_forever() - - def stop(self): - logger.debug('unsubscribing from sonos speakers ...') - if self._sonos_service is not None: - self._sonos_service.unsubscribe_speaker_events() - if self._http_server: - self._server_active = False - logger.debug('closing http server ...') - self._http_server.socket.close() - for thread in threading.enumerate(): - try: - thread.join(2) - except: - pass - if threading.active_count() > 1: - for thread in threading.enumerate(): - logger.info("Thread: {}, still alive".format(thread.name)) - else: - logger.info("Sonos Broker stopped") - - -def kill(pid, wait=10): - delay = 0.25 - waited = 0 - if pid: - os.kill(pid, signal.SIGTERM) - while waited < wait: - try: - os.kill(pid, 0) - except OSError: - os._exit(0) - waited += delay - time.sleep(delay) - try: - os.kill(pid, signal.SIGKILL) - except OSError: - os._exit(0) - - -def scan(): - print('\n\nScanning for Sonos speaker in the network ...') - soco_speakers = SonosServerService._discover() - suffix = '' - - if len(soco_speakers) > 1: - suffix = "s" - - print("Found {} speaker{} in the network.\n".format(len(soco_speakers), suffix)) - - for speaker in soco_speakers: - try: - info = speaker.get_speaker_info(timeout=5) - print("\n{}".format(speaker.uid)) - print("-" * len(speaker.uid)) - print("\tip :\t{}".format(speaker.ip_address)) - print("\tname :\t{}".format(speaker.player_name)) - print("\tmodel:\t{}\n".format(info['model_name'])) - except requests.ConnectionError: - print("Speaker '{uid}' seems to be offline.".format(uid=speaker.uid)) - continue - except Exception as ex: - print('unknown error') - print(ex) - -if __name__ == '__main__': - argparser = argparse.ArgumentParser() - subparsers = argparser.add_subparsers(dest="subparser_name") - - start_parser = subparsers.add_parser(name='start', help="Starts the Sonos Broker. ") - start_parser.add_argument('-d', '--debug', help='Debug Mode: Broker stays in foreground with verbose output', - action='store_true') - start_parser.add_argument('-c', '--config', help='[Optional] path to a config file.') - - stop_parser = subparsers.add_parser(name='stop', help="Stops the Sonos Broker.") - stop_parser = subparsers.add_parser(name='list', help="Lists all Sonos speaker in the network.") - - args = argparser.parse_args() - - if args.subparser_name == "stop": - print('Shutting down Sonos Broker ...') - kill(daemon.get_pid(__file__)) - elif args.subparser_name == "list": - scan() - elif args.subparser_name == "start": - - config_path = definitions.DEFAULT_CFG - - if args.config: - config_path = os.path.abspath(args.config) - if not os.path.exists(config_path): - print("Config file not found [** {path} **]".format(path=os.path.abspath(config_path))) - exit() - - broker = SonosBroker(args.debug, config=config_path) - - if locale.getdefaultlocale() == (None, None): - locale.setlocale(locale.LC_ALL, 'C') - else: - locale.setlocale(locale.LC_ALL, '') - - if not args.debug: - d = daemon.Daemonize(app=__name__, pid=definitions.DEFAULT_PID, action=broker.start, - keep_fds=[broker.loghandler.stream.fileno()]) - d.start() - else: - broker.start() - else: - print("Unknown command!") - exit() diff --git a/server.sonos/sonos_broker.cfg b/server.sonos/sonos_broker.cfg deleted file mode 100644 index 8d8885e..0000000 --- a/server.sonos/sonos_broker.cfg +++ /dev/null @@ -1,40 +0,0 @@ -#This is the config file for sonos broker -#Adapt and uncomment the lines to your purpose - -######################################################################## -[logging] - -#Sets the log level for the server. WARNING is the default value. -#Possible values are: debug, info, warning, error, critical -#Default logfile path: /tmp/sonos_broker.log - -#loglevel = warning -#logfile = /tmp/log.txt - -######################################################################## -[sonos_broker] - -#Binding host address. Default: 0.0.0.0 -#host = 0.0.0.0 - -#Server port. Default: 12900 -#port = 12900 - -######################################################################## -[google_tts] - -#Enabled Google-Text-To-Speech. Default: false -#enabled = true - -#Select the path where sonos broker will save the converted mp3 files -#Before a web request is made, sonos broker will check, if the requested file already exists. -#Possible paths could be: local webserver, mounted smb share ... - -#save_path = /var/www - -#Specifies the destination url which sonos broker refers to the sonos speakers. This url must point to 'save_path'. -#server_url = http://192.168.0.8:8080 - -#Maximum file size quota in megabytes. Up to this size, sonos broker will save files to 'save_path'. -#Default: 100 -#quota = 200 diff --git a/widget.smartvisu/README.md b/widget.smartvisu/README.md index 8f65c90..88c8bf4 100644 --- a/widget.smartvisu/README.md +++ b/widget.smartvisu/README.md @@ -1,65 +1,65 @@ ##Release -v0.2 2014-12-05 - + +v0.3 (2017-02-14) + + -- complete rewrite of widget for Sonos Broker >= 1.0 + +v0.2 (2014-12-05) + -- fixed issue, that a cover was not shown correctly -v0.1.1 2014-07-09 - - -- if no album cover is given, a transparent png is shown +v0.1.1 (2014-07-09) -v0.1 2014-07-08 + -- if no album cover is given, a transparent png is shown + +v0.1 (2014-07-08) -- first release -##Requirements: +--- +### Requirements - sonos_broker server min. v0.2.3 (https://github.com/pfischi/shSonos) - - sonos plugin for smarthome.py (https://github.com/pfischi/shSonos/tree/master/plugin.sonos) - - smarthome.py (https://github.com/mknx/smarthome) - - smartVISU (http://www.smartvisu.de/) - - -##Integration in smartVISU +Sonos Broker server min. v1.0b7 (https://github.com/pfischi/shSonos) +Sonos Plugin for smarthome.py (https://github.com/pfischi/shSonos/tree/master/plugin.sonos) +SmarthomeNG >=v1.2 (https://github.com/smarthomeNG) +smartVISU >=v2.8 (http://www.smartvisu.de/) -Copy **sonos.html** to the smartVISU widget directory. If you are using the smarthome.py image on a Raspberry Pi, the -default path is: +##### IMPORTANT +It is highly recommended that you use the same Sonos item structure as shown in the +[Sonos plugin example](https://github.com/pfischi/shSonos/blob/develop/plugin.sonos/examples/sonos.conf). This item +structure always matches the requirements for the Sonos widget. You can edit ```sonos.html``` if you have your own +structure. -``` -/var/www/smartvisu/widgets -``` +--- +### Integration in smartVISU -Add following line to the end of the files **widget.js** and **widget.min.js** (also located in the widgets folder) +Copy **sonos.html**, **sonos.js** and **sonos.css** to your smartVISU widget directory, e.g. -```JavaScript -$(document).delegate('[data-widget="sonos.music"]',{update:function(e,r){if (r.toString()){document.getElementById(this.id).src=r.toString()+'?_='+new Date().getTime();}else{document.getElementById(this.id).src="pages/base/pics/trans.png";}}}); +``` +/var/www/smartvisu/widgets ``` -##Integration in smarthome.py - -To make use of the auto-generation feature of smarthome.py and smartVISU, init the widget in your item.conf with the -following syntax: - +Copy **sonos_empty.jpg** to the base pic's folder, e.g. ``` -sv_widget = {% import "sonos.html" as sonos %} {{ sonos.music(id, gad_play, gad_stop, gad_prev, gad_next, gad_vol_up, gad_vol_down, gad_volume, gad_mute, gad_album_art, gad_artist, gad_title) }} +/var/www/smartvisu/pages/base/pics/sonos_empty.jpg ``` -Change the gad items to your needs. Here is an example integration: +Edit your page where you want to display the widget and add the following code snippet: ``` -[floor1] - [[room1]] - name = Lautsprecher - type = foo - sv_page = room - sv_img = audio_audio.png - sv_widget = {% import "sonos.html" as sonos %} {{ sonos.music('Play3_Kueche', 'Kueche.play', 'Kueche.stop', 'Kueche.previous', 'Kueche.next', 'Kueche.volume_up', 'Kueche.volume_down', 'Kueche.volume', 'Kueche.mute', 'Kueche.track_album_art', 'Kueche.track_artist', 'Kueche.track_title') }} -``` +{% import "sonos.html" as sonos %} + +{% block content %} -You can find an example configuration for the item 'Kueche' here: +
+
+
+ {{ sonos.player('sonos_kueche', 'Sonos.Kueche') }} +
+
+
-https://github.com/pfischi/shSonos/blob/develop/plugin.sonos/examples/sonos.conf +{% endblock %} - +``` +Rename ```Sonos.Kueche``` to your Sonos item name in SmarthomeNG. diff --git a/widget.smartvisu/sonos.css b/widget.smartvisu/sonos.css new file mode 100644 index 0000000..d2918d4 --- /dev/null +++ b/widget.smartvisu/sonos.css @@ -0,0 +1,55 @@ +/** + * --- W i d g e t s : SONOS ------------------------------------------ + */ + .pfischi .sonos table{ + width: 100%; + height: 100%; + empty-cells: show; + } + + .pfischi .sonos .td-cover{ + height: 120px; + width: 120px; + } + + .pfischi .sonos .title{ + font-weight: bold; + font-size: 110%; + } + + .pfischi .sonos .artist{ + } + + .pfischi .sonos .album{ + vertical-align: top; + font-style: italic; + } + + .pfischi .sonos .control{ + } + + .pfischi .sonos .cover{ + max-width:100%; + max-height:100%; + margin: 10px; + } + + .pfischi .sonos .control td { + min-width: 20px + } + + .pfischi .sonos .volume td { + min-width: 20px + } + + .pfischi .sonos .volume .slider { + width: 75%; + } + + .pfischi .sonos .control .previous { + text-align: right; + } + + .pfischi .sonos .control .next { + text-align: left; + } diff --git a/widget.smartvisu/sonos.html b/widget.smartvisu/sonos.html index e886bc6..578caf0 100644 --- a/widget.smartvisu/sonos.html +++ b/widget.smartvisu/sonos.html @@ -1,104 +1,158 @@ -/** -* ----------------------------------------------------------------------------- -* @package smartVISU -* @author pfischi@gmx.de -* @copyright 2014 -* @license GPL [http://www.gnu.de] -* ----------------------------------------------------------------------------- -*/ - -/** -* Standard Multimedia Player -* -* @param unique id for this widget -* @param the gad/item for play/pause -* @param the gad/item for stopping the music (optional) -* @param the gad/item for previous playlist title -* @param the gad/item for next playlist title -* @param the gad/item for volume_up (optional) -* @param the gad/item for volume_down (optional) -* @param the gad/item for the volume (optional) -* @param the gad/item to mute the music (optional) -* @param the gad/item for album cover -* @param the gad/item for the song title (optional) -* @param the gad/item for the song artist (optional) - -* -* @author Axel Otterstätter -*/ -{% macro music(id, gad_play, gad_stop, gad_prev, gad_next, gad_vol_up, gad_vol_down, gad_volume, gad_mute, gad_cover, gad_artist, gad_title) %} - {% import "basic.html" as basic %} - {% import "sonos.html" as sonos %} - - - -
- - - - - - - - - - - - - - - - - -
- {{ basic.button(id~'vol_up', gad_vol_up, gad_cover, icon0~'audio_volume_high.png', 0) }} - -
{% if gad_artist %}{{ basic.value(id~'artist', gad_artist) }}  -  {% endif %} - {% if gad_title %}{{ basic.value(id~'title', gad_title) }}{% endif %}
-
- -
- {{ basic.tank(id~'vol_tank', gad_volume, 0, 100, 2, 'cylinder', '#f90' ) }} - - - {{ sonos.cover(id~'cover', gad_cover) }} - - -
- {{ basic.button(id~'vol_down', gad_vol_down, '', icon0~'audio_volume_low.png', 0) }} - - {{ basic.dual(id~'mute', gad_mute, icon1~'audio_volume_mute.png', icon0~'audio_volume_mute.png', 1, 0) }} - -
- {{ basic.button(id~'prev', gad_prev, 'back', icon0~'control_arrow_left.png', 0) }} - {% if gad_rew %} {{ basic.button(id~'rew', gad_rew, 'rew', icon0~'audio_rew.png', 0) }} {% endif %} - {{ basic.dual(id~'play', gad_play, icon1~'audio_pause.png', icon0~'audio_play.png') }} - {% if gad_stop %} {{ basic.button(id~'stop', gad_stop, 'stop', icon0~'audio_stop.png', 1) }} {% endif %} - {% if gad_ff %} {{ basic.button(id~'ff', gad_ff, 'ff', icon0~'audio_ff.png', 1) }} {% endif %} - {{ basic.button(id~'next', gad_next, 'next', icon0~'control_arrow_right.png', 1) }} -
-
-
- -{% endmacro %} - -/** -* Displays the cover-art -* -* @param unique id for this widget -* @param the path/url to the image -* -* @author Roland Unterholzer -*/ -{% macro cover(id, src) %} - - - -{% endmacro %} \ No newline at end of file +{% macro player(id, gad) %} + {% set uid = uid(page, id) %} + {% set cover = '.track_album_art' %} + {% set artist = '.track_artist' %} + {% set title = '.track_title' %} + {% set radio_station = '.radio_station' %} + {% set album = '.track_album' %} + {% set play = '.play' %} + {% set previous = '.previous' %} + {% set next = '.next' %} + {% set volume = '.volume' %} + {% set mute = '.mute' %} + {% set playlist_position = '.playlist_position' %} + {% set transport_actions = '.transport_actions' %} + {% set track_uri = '.track_uri' %} + {% set streamtype = '.streamtype' %} + {% set cover_default = 'pages/base/pics/sonos_empty.jpg' %} + {% import "sonos.html" as pfischi_sonos %} + {% import "basic.html" as basic %} + + {% block my_javascripts %} + + + {% endblock %} + +
+
+ + + + + + + + + + + +
+
+ {{ pfischi_sonos.cover(uid~'cover', gad~cover, cover_default) }} +
+
+ + + + + + + + + + + + +
 
+ {{ pfischi_sonos.title(uid~'title', gad~title, gad~radio_station, gad~track_uri, gad~streamtype) }} +
+ {{ pfischi_sonos.artist(uid~'artist', gad~artist, gad~title, gad~streamtype) }} +
+ {{ pfischi_sonos.album(uid~'album', gad~album) }} +
 
+
 
+ + + + + + +
+ + + + +
+ {{ pfischi_sonos.music_control(uid~'play', gad~transport_actions, gad~play, 'Play', 'audio_pause.svg', 'audio_play.svg', 'audio_play.svg', 1, 0, 'icon1', 'icon0', '#555555') }} +
+
+ + + +
+
+ + + + + + +
+
+ {{ basic.switch(uid~'mute', gad~mute, 'audio_volume_mute.svg', 'audio_volume_low.svg', "1", "0") }} +
+
+ {{ basic.slider(uid~'volume', gad~volume, 0, 100, 1) }} + +
+ + + +
+
+
+
+
+ + + +{% endmacro %} + +{% macro music_control(id, possible_actions, item, action, pic_on, pic_off, pic_inactive, val_on, val_off, color_on, color_off, color_inactive) %} + {% set uid = uid(page, id) %} + {% import "basic.html" as basic %} + + + + + {{ basic._icon(pic_off|deficon('control_on_off.svg'), color_off|default('icon1'), uid ~ '-active-off') }} + {{ basic._icon(pic_on|deficon('control_on_off.svg'), color_on|default('icon1'), uid ~ '-active-on', 'hide') }} + {{ basic._icon(pic_inactive|deficon('control_on_off.svg'), color_inactive|default('icon0'), uid ~ '-inactive', 'hide') }} + + + +{% endmacro %} + +{% macro artist(uid, artist, title, streamtype) %} +
+{% endmacro %} + +{% macro title(uid, title, radio_station, track_uri, streamtype) %} +
+{% endmacro %} + +{% macro cover(uid, cover, cover_default) %} + +{% endmacro %} + +{% macro album(uid, album) %} +
+{% endmacro %} diff --git a/widget.smartvisu/sonos.js b/widget.smartvisu/sonos.js new file mode 100644 index 0000000..cee49b0 --- /dev/null +++ b/widget.smartvisu/sonos.js @@ -0,0 +1,123 @@ +$(document).on('pagecreate', function (bevent, bdata) { + + // ----- pfischi.music_control --------------------------------------------------------- + $(bevent.target).find('span[data-widget="pfischi.music_control"]').on({ + 'update': function (event, response) { + event.stopPropagation(); + $(this).val(response); + var action = $(this).attr('data-action') + var val_on = $(this).attr('data-val-on') + var val_off = $(this).attr('data-val-off') + var active = 'false'; + if (response[0].toLowerCase().indexOf(action.toLowerCase()) >= 0){ + active = "true"; + } + if(active == "true") { + if(val_on == response[1]) { + $(this).find('#' + this.id + '-inactive').hide(); + $(this).find('#' + this.id + '-active-on').show(); + $(this).find('#' + this.id + '-active-off').hide(); + } + else { + $(this).find('#' + this.id + '-inactive').hide(); + $(this).find('#' + this.id + '-active-on').hide(); + $(this).find('#' + this.id + '-active-off').show(); + } + } + else { + $(this).find('#' + this.id + '-active-on').hide(); + $(this).find('#' + this.id + '-active-off').hide(); + $(this).find('#' + this.id + '-inactive').show(); + } + $(this).attr('data-active', active); + }, + 'click': function (event) { + if ($(this).attr('data-active') == 'true') { + io.write($(this).attr('data-send'), ($(this).val()[1] == $(this).attr('data-val-off') ? $(this).attr('data-val-on') : $(this).attr('data-val-off')) ); + } + }, + 'touchstart mousedown': function (event, response) { + event.stopPropagation(); + if ($(this).attr('data-active') == 'true') { + $(this).css({ transform: 'scale(.8)' }); + } + }, + 'touchend mouseup': function (event, response) { + event.stopPropagation(); + $(this).css({ transform: 'scale(1)' }); + } + }); + + $(bevent.target).find('img[data-widget="pfischi_sonos.cover"]').on({ + 'update': function (event, response) { + event.stopPropagation(); + if (!response[0].trim()) { + $(this).attr('src', $(this).attr('data-cover')); + } + else { + $(this).attr('src', response[0]); + } + } + }); + + $(bevent.target).find('div[data-widget="pfischi_sonos.artist"]').on({ + 'update': function (event, response) { + event.stopPropagation(); + if (response[2] == 'radio') { + $(this).html(response[1]); // show radio track title + } + else { + $(this).html(response[0]); // show track artist + } + } + }); + + $(bevent.target).find('div[data-widget="pfischi_sonos.title"]').on({ + 'update': function (event, response) { + event.stopPropagation(); + if (!response[2].trim()) { + $(this).html('No music'); + } + else { + if (response[3] == 'radio') { + $(this).html(response[1]); // show radio station + } + else { + $(this).html(response[0]); // show track title + } + } + } + }); + + $(bevent.target).find('div[data-widget="pfischi_sonos.album"]').on({ + 'update': function (event, response) { + event.stopPropagation(); + $(this).html(response[0]); + } + }); + + $(bevent.target).find('div[class="play"]').on({ + 'touchstart mousedown': function (event, response) { + event.stopPropagation(); + $(this).css({ transform: 'scale(.8)' }); + }, + + 'touchend mouseup': function (event, response) { + event.stopPropagation(); + $(this).css({ transform: 'scale(1)' }); + } + }); + + $(bevent.target).find('div[class="next"]').on({ + + 'touchstart mousedown': function (event, response) { + event.stopPropagation(); + $(this).css({ transform: 'scale(.8)' }); + }, + + 'touchend mouseup': function (event, response) { + event.stopPropagation(); + $(this).css({ transform: 'scale(1)' }); + } + }); +}) diff --git a/widget.smartvisu/sonos_empty.jpg b/widget.smartvisu/sonos_empty.jpg new file mode 100644 index 0000000..01f550d Binary files /dev/null and b/widget.smartvisu/sonos_empty.jpg differ diff --git a/widget.smartvisu/widget.js b/widget.smartvisu/widget.js deleted file mode 100644 index 0d41a29..0000000 --- a/widget.smartvisu/widget.js +++ /dev/null @@ -1,7 +0,0 @@ -/* ------------------------------------------------ -ADD this line to widget.js ------------------------------------------------ - */ - -$(document).delegate('[data-widget="sonos.cover"]',{update:function(d, a){$(this).attr('src', a);}}); \ No newline at end of file diff --git a/widget.smartvisu/widget.min.js b/widget.smartvisu/widget.min.js deleted file mode 100644 index 74f6dd3..0000000 --- a/widget.smartvisu/widget.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/* ------------------------------------------------ -ADD this line to widget.js ------------------------------------------------ - */ - -$(document).delegate('[data-widget="sonos.cover"]',{update:function(d, a){$(this).attr('src', a);}});