├── src └── main │ ├── resource │ └── demo │ │ └── player │ │ └── icons │ │ ├── back.png │ │ ├── mute.png │ │ ├── next.png │ │ ├── play.png │ │ ├── stop.png │ │ ├── pause.png │ │ ├── replay.png │ │ ├── shuffle.png │ │ ├── speaker.png │ │ ├── backHoover.png │ │ ├── nextHoover.png │ │ ├── pauseHoover.png │ │ ├── playHoover.png │ │ └── stopHoover.png │ └── java │ └── com │ └── github │ └── kilianB │ ├── example │ ├── localFilePlayer │ │ ├── icons │ │ │ ├── back.png │ │ │ ├── next.png │ │ │ ├── play.png │ │ │ ├── stop.png │ │ │ ├── folder.png │ │ │ ├── pause.png │ │ │ ├── reload.png │ │ │ ├── volume.png │ │ │ ├── SonosIcon.png │ │ │ ├── backHoover.png │ │ │ ├── nextHoover.png │ │ │ ├── pauseHoover.png │ │ │ ├── placeholder.png │ │ │ ├── playHoover.png │ │ │ └── stopHoover.png │ │ ├── util │ │ │ ├── PlainAutoCloseable.java │ │ │ └── StringUtil.java │ │ ├── fileHandling │ │ │ ├── exception │ │ │ │ ├── NoSuitableSongFound.java │ │ │ │ ├── NoSuitableAlbumFound.java │ │ │ │ ├── MusicProviderException.java │ │ │ │ └── MusicProviderInternalException.java │ │ │ ├── model │ │ │ │ ├── IndexedFolderData.java │ │ │ │ ├── Song.java │ │ │ │ ├── Interpret.java │ │ │ │ └── Album.java │ │ │ ├── MusicProvider.java │ │ │ ├── NetworkFileProvider.java │ │ │ └── MusicFileIndexer.java │ │ ├── DemoPlayer.css │ │ ├── IndexDirectoryModel.java │ │ ├── TrackMetadataModel.java │ │ ├── LocalTrackModel.java │ │ ├── DemoPlayer.java │ │ └── DemoPlayer.fxml │ ├── voiceToTextPlayback │ │ ├── SonosIcon.png │ │ ├── VoiceToText.fxml │ │ ├── VoiceToTextLauncher.java │ │ ├── VoiceToTextController.java │ │ └── NetworkFileProvider.java │ ├── SonosControlExample.java │ └── SonosEventListenerExample.java │ ├── StringUtil.java │ ├── sonos │ ├── model │ │ ├── VolumeEvent.java │ │ ├── PlayState.java │ │ ├── PlayMode.java │ │ ├── QueueEvent.java │ │ ├── SonosZoneInfo.java │ │ ├── TrackInfo.java │ │ ├── AVTransportEvent.java │ │ ├── TrackMetadata.java │ │ └── SonosSpeakerInfo.java │ ├── listener │ │ ├── SonosEventAdapter.java │ │ ├── MediaRendererQueueListener.java │ │ ├── RenderingControlListener.java │ │ ├── SonosEventListener.java │ │ ├── ZoneTopologyListener.java │ │ └── AVTTransportListener.java │ ├── ParserHelper.java │ ├── SonosDiscovery.java │ └── CommandBuilder.java │ ├── exception │ ├── SonosControllerException.java │ └── UPnPSonosControllerException.java │ ├── DaemonThread.java │ ├── uPnPClient │ ├── UPnPEventAdapter.java │ ├── UPnPEventListener.java │ ├── UPnPEventAdapterVerbose.java │ ├── Subscription.java │ ├── UPnPEvent.java │ ├── SimpleDeviceDiscovery.java │ └── UPnPDevice.java │ ├── DaemonThreadFactory.java │ └── NetworkUtil.java ├── .editorconfig ├── .gitignore ├── LICENCE.md ├── CHANGELOG ├── README.md └── pom.xml /src/main/resource/demo/player/icons/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/resource/demo/player/icons/back.png -------------------------------------------------------------------------------- /src/main/resource/demo/player/icons/mute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/resource/demo/player/icons/mute.png -------------------------------------------------------------------------------- /src/main/resource/demo/player/icons/next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/resource/demo/player/icons/next.png -------------------------------------------------------------------------------- /src/main/resource/demo/player/icons/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/resource/demo/player/icons/play.png -------------------------------------------------------------------------------- /src/main/resource/demo/player/icons/stop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/resource/demo/player/icons/stop.png -------------------------------------------------------------------------------- /src/main/resource/demo/player/icons/pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/resource/demo/player/icons/pause.png -------------------------------------------------------------------------------- /src/main/resource/demo/player/icons/replay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/resource/demo/player/icons/replay.png -------------------------------------------------------------------------------- /src/main/resource/demo/player/icons/shuffle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/resource/demo/player/icons/shuffle.png -------------------------------------------------------------------------------- /src/main/resource/demo/player/icons/speaker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/resource/demo/player/icons/speaker.png -------------------------------------------------------------------------------- /src/main/resource/demo/player/icons/backHoover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/resource/demo/player/icons/backHoover.png -------------------------------------------------------------------------------- /src/main/resource/demo/player/icons/nextHoover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/resource/demo/player/icons/nextHoover.png -------------------------------------------------------------------------------- /src/main/resource/demo/player/icons/pauseHoover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/resource/demo/player/icons/pauseHoover.png -------------------------------------------------------------------------------- /src/main/resource/demo/player/icons/playHoover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/resource/demo/player/icons/playHoover.png -------------------------------------------------------------------------------- /src/main/resource/demo/player/icons/stopHoover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/resource/demo/player/icons/stopHoover.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/back.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/next.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/play.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/stop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/stop.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/folder.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/pause.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/reload.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/volume.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/volume.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/voiceToTextPlayback/SonosIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/voiceToTextPlayback/SonosIcon.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/SonosIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/SonosIcon.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/backHoover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/backHoover.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/nextHoover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/nextHoover.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/pauseHoover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/pauseHoover.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/placeholder.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/playHoover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/playHoover.png -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/icons/stopHoover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KilianB/Java-Sonos-Controller/HEAD/src/main/java/com/github/kilianB/example/localFilePlayer/icons/stopHoover.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | 7 | [*.java] 8 | indent_style = tab 9 | indent_size = 4 10 | 11 | [*.md] 12 | indent_style = spaces 13 | indent_size = 2 14 | 15 | [.*] 16 | indent_style = tab 17 | indent_size = 4 18 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/StringUtil.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB; 2 | 3 | public class StringUtil { 4 | public static boolean isEscaped(String s) { 5 | return s.contains("&") || s.contains("<") || s.contains(">") || s.contains(""") 6 | || s.contains("'"); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/model/VolumeEvent.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.model; 2 | 3 | public class VolumeEvent { 4 | 5 | int master,lf,rf; 6 | 7 | @Override 8 | public String toString() { 9 | return "VolumeEvent [master=" + master + ", lf=" + lf + ", rf=" + rf + "]"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/util/PlainAutoCloseable.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer.util; 2 | 3 | /** 4 | * AutoCloseable interface without exception 5 | * @author Kilian 6 | * 7 | */ 8 | public interface PlainAutoCloseable extends AutoCloseable{ 9 | 10 | void close(); 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/exception/SonosControllerException.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.exception; 2 | 3 | /** 4 | * @author vmichalak 5 | * 6 | */ 7 | public class SonosControllerException extends Exception { 8 | 9 | private static final long serialVersionUID = 1L; 10 | 11 | public SonosControllerException(String message) { 12 | super(message); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/fileHandling/exception/NoSuitableSongFound.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer.fileHandling.exception; 2 | 3 | public class NoSuitableSongFound extends MusicProviderException{ 4 | 5 | private static final long serialVersionUID = 1L; 6 | 7 | public NoSuitableSongFound(String string) { 8 | super(string); 9 | } 10 | public NoSuitableSongFound(){ 11 | super(); 12 | }; 13 | } -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/fileHandling/exception/NoSuitableAlbumFound.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer.fileHandling.exception; 2 | 3 | public class NoSuitableAlbumFound extends MusicProviderException { 4 | 5 | private static final long serialVersionUID = 1L; 6 | 7 | public NoSuitableAlbumFound(String string) { 8 | super(string); 9 | } 10 | 11 | public NoSuitableAlbumFound(){ 12 | super(); 13 | }; 14 | 15 | } -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/fileHandling/exception/MusicProviderException.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer.fileHandling.exception; 2 | 3 | public abstract class MusicProviderException extends Exception{ 4 | 5 | private static final long serialVersionUID = 1L; 6 | 7 | public MusicProviderException(String string) { 8 | super(string); 9 | } 10 | 11 | public MusicProviderException(){ 12 | super(); 13 | }; 14 | 15 | } -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/model/PlayState.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.model; 2 | 3 | /** 4 | * 5 | * @author vmichalak 6 | */ 7 | public enum PlayState { 8 | /** 9 | * Player has an error. 10 | */ 11 | ERROR, 12 | 13 | /** 14 | * Player is stopped. 15 | */ 16 | STOPPED, 17 | 18 | /** 19 | * Player is playing. 20 | */ 21 | PLAYING, 22 | 23 | /** 24 | * Player is paused. 25 | */ 26 | PAUSED_PLAYBACK, 27 | 28 | /** 29 | * Player is loading. 30 | */ 31 | TRANSITIONING 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/DaemonThread.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB; 2 | 3 | public class DaemonThread extends Thread{ 4 | 5 | public DaemonThread() { 6 | this.setDaemon(true); 7 | } 8 | 9 | public DaemonThread(Runnable r) { 10 | super(r); 11 | this.setDaemon(true); 12 | } 13 | 14 | public DaemonThread(String name) { 15 | super(name); 16 | this.setDaemon(true); 17 | } 18 | 19 | public DaemonThread(Runnable r,String name) { 20 | super(r,name); 21 | this.setDaemon(true); 22 | } 23 | 24 | 25 | 26 | 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/model/PlayMode.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.model; 2 | 3 | /** 4 | * @author vmichalak 5 | */ 6 | public enum PlayMode { 7 | /** 8 | * Turns off shuffle and repeat. 9 | */ 10 | NORMAL, 11 | 12 | /** 13 | * Turns on repeat the current title and turns off shuffle. 14 | */ 15 | REPEAT_ONE, 16 | 17 | /** 18 | * Turns on repeat the queue and turns off shuffle. 19 | */ 20 | REPEAT_ALL, 21 | 22 | /** 23 | * Turns on shuffle and repeat. 24 | */ 25 | SHUFFLE, 26 | 27 | /** 28 | * Turns on shuffle and turns off repeat. 29 | */ 30 | SHUFFLE_NOREPEAT 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/fileHandling/exception/MusicProviderInternalException.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer.fileHandling.exception; 2 | 3 | /** 4 | * Exception to be thrown if the music provider engine failed to make a request which is unlikely to 5 | * be repaired. The Smart house should actively try to avoid this engine from now on and use a fallback if possible 6 | * @author Kilian 7 | * 8 | */ 9 | public class MusicProviderInternalException extends MusicProviderException{ 10 | 11 | private static final long serialVersionUID = 1L; 12 | 13 | public MusicProviderInternalException(String string) { 14 | super(string); 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/uPnPClient/UPnPEventAdapter.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.uPnPClient; 2 | 3 | /** 4 | * Adapter class of {@link UPnPEventListener} used as callback for 5 | * UPnPEvents emitted from subscriptions. 6 | * @author Kilian 7 | * 8 | */ 9 | public class UPnPEventAdapter implements UPnPEventListener{ 10 | 11 | @Override 12 | public void initialEventReceived(UPnPEvent event) {} 13 | 14 | @Override 15 | public void eventReceived(UPnPEvent event) {} 16 | 17 | @Override 18 | public void eventSubscriptionExpired() {} 19 | 20 | @Override 21 | public void renewalFailed(Exception e) {} 22 | 23 | @Override 24 | public void unsubscribed() {} 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/DemoPlayer.css: -------------------------------------------------------------------------------- 1 | .zoneToken { 2 | -fx-background-color: white; 3 | -fx-background-radius: 5px; 4 | -fx-border-radius: 5px; 5 | -fx-border-width: 2px; 6 | -fx-border-color: black; 7 | -fx-pref-width: 140; 8 | -fx-pref-height: 100; 9 | -fx-effect: dropshadow(three-pass-box, rgba(0, 0, 0, 0.8), 7, 0, 0, 0); 10 | } 11 | 12 | .zoneToken.selected{ 13 | -fx-background-color: #ccc; 14 | } 15 | 16 | .zoneToken:hover{ 17 | -fx-background-color: #ddd; 18 | } 19 | 20 | .zoneTokenImageView{ 21 | -fx-border-radius: 5px; 22 | -fx-border-width: 1px; 23 | -fx-border-color: black; 24 | } 25 | 26 | .playing{ 27 | -fx-background-color: #9bc1ff; 28 | } 29 | 30 | 31 | .zoneNameTitle{ 32 | -fx-font-size: 14pt ; 33 | } 34 | 35 | .scroll-pane>.viewport { 36 | -fx-background-color: transparent; 37 | } -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/exception/UPnPSonosControllerException.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.exception; 2 | 3 | /** 4 | * @author vmichalak 5 | */ 6 | public class UPnPSonosControllerException extends SonosControllerException { 7 | 8 | private static final long serialVersionUID = 1L; 9 | private final int errorCode; 10 | private final String errorDescription; 11 | private final String response; 12 | 13 | public UPnPSonosControllerException(String message, int errorCode, String errorDescription, String response) { 14 | super(message); 15 | this.errorCode = errorCode; 16 | this.errorDescription = errorDescription; 17 | this.response = response; 18 | } 19 | 20 | public int getErrorCode() { 21 | return errorCode; 22 | } 23 | 24 | public String getErrorDescription() { 25 | return errorDescription; 26 | } 27 | 28 | public String getResponse() { 29 | return response; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/fileHandling/model/IndexedFolderData.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer.fileHandling.model; 2 | 3 | import java.sql.Timestamp; 4 | 5 | /** 6 | * @author Kilian 7 | * 8 | */ 9 | public class IndexedFolderData { 10 | 11 | String folderPath; 12 | Timestamp lastIndexed; 13 | int tracksIndexed; 14 | /** 15 | * @param folderPath 16 | * @param lastIndexed 17 | * @param tracksIndexed 18 | */ 19 | public IndexedFolderData(String folderPath, Timestamp lastIndexed, int tracksIndexed) { 20 | super(); 21 | this.folderPath = folderPath; 22 | this.lastIndexed = lastIndexed; 23 | this.tracksIndexed = tracksIndexed; 24 | } 25 | /** 26 | * @return the folderPath 27 | */ 28 | public String getFolderPath() { 29 | return folderPath; 30 | } 31 | /** 32 | * @return the lastIndexed 33 | */ 34 | public Timestamp getLastIndexed() { 35 | return lastIndexed; 36 | } 37 | /** 38 | * @return the tracksIndexed 39 | */ 40 | public int getTracksIndexed() { 41 | return tracksIndexed; 42 | } 43 | 44 | 45 | } 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #Mac 2 | .DS_Store 3 | ._* 4 | 5 | # User-specific stuff: 6 | .idea/* 7 | .idea/workspace.xml 8 | .idea/tasks.xml 9 | 10 | # Sensitive or high-churn files: 11 | .idea/dataSources.ids 12 | .idea/dataSources.xml 13 | .idea/dataSources.local.xml 14 | .idea/sqlDataSources.xml 15 | .idea/dynamic.xml 16 | .idea/uiDesigner.xml 17 | 18 | # Gradle: 19 | .idea/gradle.xml 20 | .idea/libraries 21 | 22 | # Mongo Explorer plugin: 23 | .idea/mongoSettings.xml 24 | 25 | ## File-based project format: 26 | *.iws 27 | 28 | ## Plugin-specific files: 29 | 30 | # IntelliJ 31 | /out/ 32 | 33 | # mpeltonen/sbt-idea plugin 34 | .idea_modules/ 35 | 36 | # JIRA plugin 37 | atlassian-ide-plugin.xml 38 | 39 | # Crashlytics plugin (for Android Studio and IntelliJ) 40 | com_crashlytics_export_strings.xml 41 | crashlytics.properties 42 | crashlytics-build.properties 43 | fabric.properties 44 | 45 | #gradle 46 | .gradle 47 | /build/ 48 | 49 | # Ignore Gradle GUI config 50 | gradle-app.setting 51 | 52 | # Cache of project 53 | .gradletasknamecache 54 | 55 | # Sphinx 56 | doc/_build 57 | 58 | # Eclipse 59 | /.classpath 60 | /.project 61 | /.settings/ 62 | /bin/ 63 | /target/ 64 | 65 | #Music files 66 | .mp3 -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Kilian Brachtendorf 2 | Originally licensed under: 3 | Copyright (c) 2017 Valentin Michalak (MIT) 4 | 5 | Copyright 2022 Kilian Brachtendorf 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/DaemonThreadFactory.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB; 2 | 3 | import java.util.concurrent.ThreadFactory; 4 | import java.util.concurrent.atomic.AtomicInteger; 5 | 6 | public class DaemonThreadFactory implements ThreadFactory { 7 | 8 | private static final AtomicInteger poolNumber = new AtomicInteger(1); 9 | private final ThreadGroup group; 10 | private final AtomicInteger threadNumber = new AtomicInteger(1); 11 | private final String namePrefix; 12 | 13 | public DaemonThreadFactory() { 14 | this(""); 15 | } 16 | 17 | public DaemonThreadFactory(String namePrefix) { 18 | SecurityManager s = System.getSecurityManager(); 19 | group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup(); 20 | if(namePrefix == null || namePrefix.isEmpty()) { 21 | this.namePrefix = "pool-" + poolNumber.getAndIncrement() + "-thread-"; 22 | }else { 23 | this.namePrefix = namePrefix+"-"; 24 | } 25 | } 26 | 27 | public Thread newThread(Runnable r) { 28 | Thread t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement(), 0); 29 | t.setDaemon(true); 30 | if (t.getPriority() != Thread.NORM_PRIORITY) 31 | t.setPriority(Thread.NORM_PRIORITY); 32 | return t; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/model/QueueEvent.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.model; 2 | 3 | import java.util.Optional; 4 | 5 | /** 6 | * Property of a UPnP Queue Changed Event 7 | * @author Kilian 8 | * @see com.github.kilianB.sonos.listener.MediaRendererQueueListener MediaRendererQueueListener 9 | * 10 | */ 11 | public class QueueEvent { 12 | 13 | private int queueId; 14 | private int updateId; 15 | private Optional curated; 16 | 17 | public QueueEvent(int queueId, int updateId) { 18 | super(); 19 | this.queueId = queueId; 20 | this.updateId = updateId; 21 | this.curated = Optional.empty(); 22 | } 23 | 24 | public Optional getCurated() { 25 | return curated; 26 | } 27 | 28 | public void setCurated(Optional curated) { 29 | this.curated = curated; 30 | } 31 | 32 | public int getQueueId() { 33 | return queueId; 34 | } 35 | public void setQueueId(int queueId) { 36 | this.queueId = queueId; 37 | } 38 | public int getUpdateId() { 39 | return updateId; 40 | } 41 | public void setUpdateId(int updateId) { 42 | this.updateId = updateId; 43 | } 44 | 45 | @Override 46 | public String toString() { 47 | return "QueueEvent [queueId=" + queueId + ", updateId=" + updateId + ", curated=" + curated + "]"; 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/voiceToTextPlayback/VoiceToText.fxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/fileHandling/model/Song.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer.fileHandling.model; 2 | 3 | import java.net.URI; 4 | 5 | public class Song { 6 | String title; 7 | String description; 8 | int trackLength; 9 | URI musicFile; 10 | Album album; 11 | 12 | public Song(String title, int trackLength, String description, URI url) { 13 | super(); 14 | this.title = title; 15 | this.trackLength = trackLength; 16 | this.description = description; 17 | this.musicFile = url; 18 | } 19 | 20 | public String getTitle() { 21 | return title; 22 | } 23 | public void setTitle(String title) { 24 | this.title = title; 25 | } 26 | public String getDescription() { 27 | return description; 28 | } 29 | public void setDescription(String description) { 30 | this.description = description; 31 | } 32 | public URI getMusicFile() { 33 | return musicFile; 34 | } 35 | public void setMusicFile(URI url) { 36 | this.musicFile = url; 37 | } 38 | 39 | /** 40 | * @return the trackLength 41 | */ 42 | public int getTrackLength() { 43 | return trackLength; 44 | } 45 | 46 | /** 47 | * @param trackLength the trackLength to set 48 | */ 49 | public void setTrackLength(int trackLength) { 50 | this.trackLength = trackLength; 51 | } 52 | 53 | /** 54 | * @return the album 55 | */ 56 | public Album getAlbum() { 57 | return album; 58 | } 59 | 60 | 61 | 62 | } -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | 8 | ## [2.0.1] Unreleased 9 | 10 | ### Changed 11 | - Bumped undertow version for demo . Vulnerability fix. 12 | 13 | ## [2.0.0] 6.10.2018 14 | 15 | ### Fixed 16 | - Preliminary fix for a race condition when parsing upnp events. Change to a small timeout before closing the socket after reading data from socket. 17 | - using a timeout for the content header and reading the expected bytecount is a more stable solution and should be implemented in future releases. 18 | - clip method throws exception if queue is empty 19 | 20 | ### Changed 21 | - Use silent upnp event adapters for default upnp event listener propagation. The use of the verbose option 22 | during development was used to debug purposes but now we don't want to spam the logger with useless information 23 | every time we append a listener. 24 | - bumped major version to 2.0.0 25 | - bumed required java version 8 -> 10 26 | 27 | ### Added 28 | - Enqueue audio at a given position in the queue instead of just the end 29 | - Asynch device disovery with callback. 30 | - Changelog 31 | - Demo example 32 | - deprecated method to get the underlying upnp device 33 | - utility method in sonos devices to convert a getAlbumUri to a usable URL. 34 | 35 | ### Removed 36 | - Obsolete System debug messages 37 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/model/SonosZoneInfo.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.model; 2 | 3 | import java.io.IOException; 4 | import java.util.ArrayList; 5 | import java.util.Collections; 6 | import java.util.List; 7 | 8 | import com.github.kilianB.sonos.SonosDevice; 9 | import com.github.kilianB.sonos.SonosDiscovery; 10 | 11 | /** 12 | * @author vmichalak 13 | * 14 | */ 15 | public class SonosZoneInfo { 16 | private final String name; 17 | private final String id; 18 | private final List zonePlayerUIDInGroup; 19 | 20 | public SonosZoneInfo(String name, String id, List zonePlayerUIDInGroup) { 21 | this.name = name; 22 | this.id = id; 23 | this.zonePlayerUIDInGroup = zonePlayerUIDInGroup; 24 | } 25 | 26 | public String getName() { 27 | return name; 28 | } 29 | 30 | public String getId() { 31 | return id; 32 | } 33 | 34 | public List getZonePlayerUIDInGroup() { 35 | return Collections.unmodifiableList(zonePlayerUIDInGroup); 36 | } 37 | 38 | public List getSonosDevicesInGroup() { 39 | ArrayList devices = new ArrayList(); 40 | for (String uid : zonePlayerUIDInGroup) { 41 | try { 42 | SonosDevice device = SonosDiscovery.discoverByUID(uid); 43 | if(device != null) { 44 | devices.add(device); 45 | } 46 | } 47 | catch (IOException e) { /* ignored */ } 48 | } 49 | return devices; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/listener/SonosEventAdapter.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.listener; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | 6 | import com.github.kilianB.sonos.model.AVTransportEvent; 7 | import com.github.kilianB.sonos.model.PlayMode; 8 | import com.github.kilianB.sonos.model.PlayState; 9 | import com.github.kilianB.sonos.model.QueueEvent; 10 | import com.github.kilianB.sonos.model.TrackInfo; 11 | 12 | /** 13 | * Adapter implementation of the {@link SonosEventListener} 14 | * @author Kilian 15 | * 16 | */ 17 | public class SonosEventAdapter implements SonosEventListener { 18 | 19 | @Override 20 | public void volumeChanged(int newVolume) {} 21 | 22 | @Override 23 | public void playStateChanged(PlayState newPlayState) {} 24 | 25 | @Override 26 | public void playModeChanged(PlayMode newPlayMode) {} 27 | 28 | @Override 29 | public void queueChanged(List queuesAffected) {} 30 | 31 | @Override 32 | public void trackChanged(TrackInfo currentTrack) {} 33 | 34 | @Override 35 | public void trebleChanged(int treble) {} 36 | 37 | @Override 38 | public void bassChanged(int bass) {} 39 | 40 | @Override 41 | public void loudenessChanged(boolean loudness) {} 42 | 43 | @Override 44 | public void avtTransportEvent(AVTransportEvent avtTransportEvent) {} 45 | 46 | @Override 47 | public void sonosDeviceConnected(String deviceName) {} 48 | 49 | @Override 50 | public void sonosDeviceDisconnected(String deviceName) {} 51 | 52 | @Override 53 | public void groupChanged(ArrayList allDevicesInZone) {} 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/uPnPClient/UPnPEventListener.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.uPnPClient; 2 | 3 | /** 4 | * Event listener used to listen to UPnP Event emitted by subscriptions 5 | * of services. 6 | * 7 | * 8 | * UPnPDevice.subscribe(UPnPEventListener,ServicePath); 9 | * 10 | * 11 | * @author Kilian 12 | * @see UPnPEventAdapter UPnPEventAdapter for an adapter version 13 | * @see UPnPEventAdapterVerbose UPnPEventAdapterVerbose as a default event listener implementation 14 | * 15 | */ 16 | public interface UPnPEventListener { 17 | 18 | /** 19 | * Notifies about the arrival of the first UPnPEvent made after subscription to the service. 20 | * The first event usually contains information about the current state of the service/device. 21 | * @param event The event send by the server 22 | */ 23 | public void initialEventReceived(UPnPEvent event); 24 | 25 | 26 | /** 27 | * Notifies about the arrival of all but the very first event. 28 | * @param event The event send by the server 29 | */ 30 | public void eventReceived(UPnPEvent event); 31 | 32 | /** 33 | * Called once the event subscription period expired and no renewal was issued 34 | */ 35 | public void eventSubscriptionExpired(); 36 | 37 | 38 | /** 39 | * Method will be called if the re-subscription to the event failed. 40 | * From this point on no events will be received.. 41 | * @param e Exception why the renewal failed 42 | */ 43 | public void renewalFailed(Exception e); 44 | 45 | /** 46 | * Notifies about a successful unsubscription of the subscribed event. 47 | */ 48 | public void unsubscribed(); 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/SonosControlExample.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example; 2 | 3 | import java.io.IOException; 4 | 5 | import com.github.kilianB.exception.SonosControllerException; 6 | import com.github.kilianB.sonos.SonosDevice; 7 | import com.github.kilianB.sonos.SonosDiscovery; 8 | import com.github.kilianB.sonos.model.TrackMetadata; 9 | 10 | /** 11 | * This example demonstrates basic functions like discovering a sonos speaker, playing back a radio station 12 | * grabbing information from the device and pausing the playback after 10 seconds 13 | * @author Kilian 14 | * 15 | */ 16 | public class SonosControlExample { 17 | 18 | private static final String bbcURI = "x-rincon-mp3radio://http://bbcmedia.ic.llnwd.net/stream/bbcmedia_lrldn_mf_p"; 19 | 20 | public static void main(String[] args){ 21 | 22 | try { 23 | //Discover a random sonos device 24 | SonosDevice sonos = SonosDiscovery.discoverOne(); 25 | 26 | //Alternatively discover a device with specific name 27 | //SonosDevice sonos = SonosDiscovery.discoverByName("ZoneName"); 28 | 29 | //Radio channels do not feature album or artists. We don't supply an image therefore null 30 | TrackMetadata trackMeta = new TrackMetadata("BBC Set By Java",null,null,null,null); 31 | 32 | //Start playback 33 | sonos.playUri(bbcURI,trackMeta); 34 | 35 | //Retrieve some information about the current track 36 | System.out.println(sonos.getCurrentTrackInfo()); 37 | 38 | //Sleep for 10 seconds 39 | Thread.sleep(10000); 40 | 41 | sonos.pause(); 42 | }catch(IOException | SonosControllerException | InterruptedException io) { 43 | io.printStackTrace(); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/uPnPClient/UPnPEventAdapterVerbose.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.uPnPClient; 2 | 3 | import org.jdom2.Element; 4 | 5 | import java.text.MessageFormat; 6 | import java.util.logging.Logger; 7 | 8 | /** 9 | * A default {@link UPnPEventListener} implementation logging the content of events 10 | * allowing the first inspection of the received content. 11 | * 12 | * @author Kilian 13 | * 14 | */ 15 | public class UPnPEventAdapterVerbose implements UPnPEventListener{ 16 | 17 | private final static Logger LOGGER = Logger.getLogger(UPnPEventAdapterVerbose.class.getName()); 18 | 19 | private final String servicePath; 20 | 21 | public UPnPEventAdapterVerbose(String servicePath) { 22 | this.servicePath = servicePath; 23 | } 24 | 25 | @Override 26 | public void initialEventReceived(UPnPEvent event) { 27 | LOGGER.fine("inital event"); 28 | LOGGER.info(event.toString()); 29 | LOGGER.info(event.getBodyAsString()); 30 | } 31 | 32 | @Override 33 | public void eventReceived(UPnPEvent event) { 34 | LOGGER.fine("value changed event"); 35 | LOGGER.info(event.toString()); 36 | LOGGER.info(event.getBodyAsString()); 37 | for(Element e : event.getProperties()) { 38 | LOGGER.info(e.toString()); 39 | } 40 | } 41 | 42 | @Override 43 | public void eventSubscriptionExpired() { 44 | LOGGER.severe(MessageFormat.format("Event subscription for: {0} expired", servicePath)); 45 | } 46 | 47 | @Override 48 | public void renewalFailed(Exception e) { 49 | LOGGER.severe(MessageFormat.format("Renewal subscroption for: {0} failed", servicePath)); 50 | } 51 | 52 | @Override 53 | public void unsubscribed() { 54 | LOGGER.info(MessageFormat.format("Unsubscribed from {0}", servicePath)); 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/IndexDirectoryModel.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer; 2 | 3 | import java.time.LocalDateTime; 4 | 5 | import com.jfoenix.controls.datamodels.treetable.RecursiveTreeObject; 6 | 7 | import javafx.beans.property.ReadOnlyIntegerProperty; 8 | import javafx.beans.property.ReadOnlyIntegerWrapper; 9 | import javafx.beans.property.ReadOnlyObjectProperty; 10 | import javafx.beans.property.ReadOnlyObjectWrapper; 11 | import javafx.beans.property.ReadOnlyStringProperty; 12 | import javafx.beans.property.ReadOnlyStringWrapper; 13 | 14 | public class IndexDirectoryModel extends RecursiveTreeObject { 15 | 16 | private static int id = 0; 17 | 18 | private ReadOnlyStringWrapper filePath; 19 | private ReadOnlyIntegerWrapper songsFound; 20 | private ReadOnlyObjectWrapper lastIndexed; 21 | private ReadOnlyIntegerWrapper directoryId = new ReadOnlyIntegerWrapper (id++); 22 | 23 | public IndexDirectoryModel(String filePath, int songsFound, LocalDateTime lastIndexed) { 24 | this.filePath = new ReadOnlyStringWrapper(filePath); 25 | this.songsFound = new ReadOnlyIntegerWrapper(songsFound); 26 | this.lastIndexed = new ReadOnlyObjectWrapper<>(lastIndexed); 27 | } 28 | 29 | 30 | public ReadOnlyStringProperty getFilePath() { 31 | return filePath.getReadOnlyProperty(); 32 | } 33 | 34 | public ReadOnlyIntegerProperty getSongsFound() { 35 | return songsFound.getReadOnlyProperty(); 36 | } 37 | 38 | public ReadOnlyIntegerProperty getDirectoryId() { 39 | return directoryId.getReadOnlyProperty(); 40 | } 41 | 42 | public ReadOnlyObjectProperty getLastIndexed() { 43 | return lastIndexed.getReadOnlyProperty(); 44 | } 45 | 46 | 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/TrackMetadataModel.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer; 2 | 3 | import com.github.kilianB.sonos.SonosDevice; 4 | import com.github.kilianB.sonos.model.TrackMetadata; 5 | import com.jfoenix.controls.datamodels.treetable.RecursiveTreeObject; 6 | 7 | import javafx.beans.property.SimpleBooleanProperty; 8 | import javafx.beans.property.SimpleStringProperty; 9 | 10 | class TrackMetadataModel extends RecursiveTreeObject { 11 | 12 | SimpleStringProperty title; 13 | SimpleStringProperty album; 14 | SimpleStringProperty albumArtist; 15 | SimpleStringProperty albumUri; 16 | SimpleStringProperty creator; 17 | SimpleBooleanProperty currentlyPlaying = new SimpleBooleanProperty(false); 18 | 19 | public TrackMetadataModel(TrackMetadata metadata, SonosDevice device) { 20 | 21 | title = new SimpleStringProperty(metadata.getTitle()); 22 | album = new SimpleStringProperty(metadata.getAlbum()); 23 | albumArtist = new SimpleStringProperty(metadata.getAlbumArtist()); 24 | albumUri = new SimpleStringProperty(metadata.getAlbumArtURI()); 25 | creator = new SimpleStringProperty(metadata.getCreator()); 26 | } 27 | 28 | public SimpleStringProperty getTitle() { 29 | return title; 30 | } 31 | 32 | public void setTitle(SimpleStringProperty title) { 33 | this.title = title; 34 | } 35 | 36 | public SimpleStringProperty getAlbum() { 37 | return album; 38 | } 39 | 40 | public void setAlbum(SimpleStringProperty album) { 41 | this.album = album; 42 | } 43 | 44 | public SimpleStringProperty getAlbumArtist() { 45 | return albumArtist; 46 | } 47 | 48 | public void setAlbumArtist(SimpleStringProperty albumArtist) { 49 | this.albumArtist = albumArtist; 50 | } 51 | 52 | public SimpleStringProperty getAlbumUri() { 53 | return albumUri; 54 | } 55 | 56 | public void setAlbumUri(SimpleStringProperty albumUri) { 57 | this.albumUri = albumUri; 58 | } 59 | 60 | public SimpleStringProperty getCreator() { 61 | return creator; 62 | } 63 | 64 | public void setCreator(SimpleStringProperty creator) { 65 | this.creator = creator; 66 | } 67 | 68 | public SimpleBooleanProperty getCurrentlyPlayingProperty() { 69 | return currentlyPlaying; 70 | } 71 | } -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/LocalTrackModel.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer; 2 | 3 | import java.io.FileInputStream; 4 | import java.io.FileOutputStream; 5 | import java.io.IOException; 6 | import java.io.ObjectInputStream; 7 | import java.io.ObjectOutputStream; 8 | import java.io.Serializable; 9 | 10 | import com.jfoenix.controls.datamodels.treetable.RecursiveTreeObject; 11 | 12 | import javafx.beans.property.SimpleIntegerProperty; 13 | import javafx.beans.property.SimpleStringProperty; 14 | 15 | /** 16 | * @author Kilian 17 | * 18 | */ 19 | public class LocalTrackModel extends RecursiveTreeObject implements Serializable { 20 | 21 | transient SimpleStringProperty title; 22 | transient SimpleStringProperty path; 23 | transient SimpleStringProperty album; 24 | transient SimpleIntegerProperty trackLength; 25 | 26 | /** 27 | * @param title 28 | * @param path 29 | * @param album 30 | */ 31 | public LocalTrackModel(String title, String path, String album, int trackLength) { 32 | super(); 33 | this.title = new SimpleStringProperty(title); 34 | this.path = new SimpleStringProperty(path); 35 | this.album = new SimpleStringProperty(album); 36 | this.trackLength = new SimpleIntegerProperty(trackLength); 37 | } 38 | 39 | /** 40 | * @return the title 41 | */ 42 | public SimpleStringProperty getTitle() { 43 | return title; 44 | } 45 | 46 | /** 47 | * @return the path 48 | */ 49 | public SimpleStringProperty getPath() { 50 | return path; 51 | } 52 | 53 | /** 54 | * @return the album 55 | */ 56 | public SimpleStringProperty getAlbum() { 57 | return album; 58 | } 59 | 60 | 61 | /** 62 | * @return the trackLength 63 | */ 64 | public SimpleIntegerProperty getTrackLength() { 65 | return trackLength; 66 | } 67 | 68 | private void writeObject(ObjectOutputStream s) throws IOException { 69 | System.out.println("Write object: " + title.get()); 70 | s.defaultWriteObject(); 71 | s.writeUTF(title.get()); 72 | s.writeUTF(path.get()); 73 | s.writeUTF(album.get()); 74 | s.writeInt(trackLength.get()); 75 | } 76 | 77 | private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException { 78 | title = new SimpleStringProperty(s.readUTF()); 79 | path = new SimpleStringProperty(s.readUTF()); 80 | album = new SimpleStringProperty(s.readUTF()); 81 | trackLength = new SimpleIntegerProperty(s.readInt()); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/voiceToTextPlayback/VoiceToTextLauncher.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.voiceToTextPlayback; 2 | 3 | import java.io.File; 4 | import java.io.IOException; 5 | import java.net.InetAddress; 6 | import java.net.UnknownHostException; 7 | import java.util.logging.Logger; 8 | 9 | import com.github.kilianB.NetworkUtil; 10 | 11 | import javafx.application.Application; 12 | import javafx.fxml.FXMLLoader; 13 | import javafx.scene.Parent; 14 | import javafx.scene.Scene; 15 | import javafx.scene.image.Image; 16 | import javafx.stage.Stage; 17 | 18 | /** 19 | * @author Kilian 20 | * 21 | */ 22 | public class VoiceToTextLauncher extends Application { 23 | 24 | private final static Logger LOGGER = Logger.getLogger(VoiceToTextLauncher.class.getName()); 25 | 26 | /* 27 | * We create our own mp3 file. In order for sonos to play it back 28 | */ 29 | private InetAddress hostAddress; 30 | private NetworkFileProvider fileProvider; 31 | //Ephemeral ports 49152 to 65535 32 | private int port = 63258; 33 | 34 | 35 | public VoiceToTextLauncher() { 36 | 37 | //Local file hosting. 38 | try { 39 | hostAddress = NetworkUtil.resolveSiteLocalAddress(); 40 | } catch (IOException e) { 41 | 42 | try { 43 | hostAddress = InetAddress.getLocalHost(); 44 | }catch(UnknownHostException e1) { 45 | e1.printStackTrace(); 46 | } 47 | LOGGER.severe("Could not resolve a valid ip adress. fallback to localhost " + e); 48 | } 49 | 50 | fileProvider = new NetworkFileProvider(hostAddress.getHostAddress(),port,new String[] {"mp3"}); 51 | 52 | File hostedFolder = new File("mp3TempFiles"); 53 | hostedFolder.mkdirs(); 54 | fileProvider.mapFolder(hostedFolder); 55 | 56 | } 57 | 58 | @Override 59 | public void start(Stage primaryStage) throws Exception { 60 | 61 | FXMLLoader loader = new FXMLLoader(); 62 | 63 | loader.setController(new VoiceToTextController(fileProvider)); 64 | loader.setLocation(getClass().getResource("VoiceToText.fxml")); 65 | 66 | Parent root = loader.load(); 67 | 68 | Scene scene = new Scene(root); 69 | primaryStage.setScene(scene); 70 | 71 | primaryStage.getIcons().add(new Image(getClass().getResourceAsStream("SonosIcon.png"))); 72 | primaryStage.setTitle("Sonos Text To Speech"); 73 | primaryStage.show(); 74 | 75 | } 76 | 77 | @Override 78 | public void stop() { 79 | fileProvider.deinit(); 80 | } 81 | 82 | /** 83 | * @param args 84 | */ 85 | public static void main(String[] args) { 86 | launch(args); 87 | } 88 | 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/model/TrackInfo.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.model; 2 | 3 | import com.github.kilianB.sonos.ParserHelper; 4 | 5 | /** 6 | * 7 | * @author vmichalak 8 | * @author Kilian 9 | */ 10 | public class TrackInfo { 11 | private final int queueIndex; 12 | private final int duration; 13 | private final int position; 14 | private final String uri; 15 | private final TrackMetadata metadata; 16 | 17 | public TrackInfo(int queueIndex, int duration, int position, String uri, TrackMetadata metadata) { 18 | this.queueIndex = queueIndex; 19 | this.duration = duration; 20 | this.position = position; 21 | this.uri = uri; 22 | this.metadata = metadata; 23 | } 24 | 25 | public int getQueueIndex() { 26 | return queueIndex; 27 | } 28 | 29 | /** 30 | * The song lenght in seconds 31 | * @return the song duration in seconds 32 | */ 33 | public int getDuration() { 34 | return duration; 35 | } 36 | 37 | public String getDurationAsString() { 38 | return ParserHelper.secondsToFormatedTimestamp(duration); 39 | } 40 | 41 | /** 42 | * Return the current song position in seconds 43 | * @return the position of the song in seconds 44 | */ 45 | public int getPosition() { 46 | return position; 47 | } 48 | 49 | /** 50 | * @return the current position of the song in the format HH:MM:SS 51 | */ 52 | public String getPositionAsString() { 53 | return ParserHelper.secondsToFormatedTimestamp(position); 54 | } 55 | 56 | public String getUri() { 57 | return uri; 58 | } 59 | 60 | public TrackMetadata getMetadata() { 61 | return metadata; 62 | } 63 | 64 | @Override 65 | public String toString() { 66 | return "TrackInfo{" + "queueIndex=" + queueIndex + ", duration='" + duration + '\'' + ", position='" + position 67 | + '\'' + ", uri='" + uri + '\'' + ", metadata=" + metadata + '}'; 68 | } 69 | 70 | /** 71 | * Compare if two track infos point to the same song 72 | * 73 | * This method is used instead of equals due to some fields (e.g. position) not being taken account of. 74 | * 75 | * @param infoToCompareTo The trackInfo this object gets compared to 76 | * @return true if both tracks point to the same track. 77 | */ 78 | public boolean sameBaseTrack(TrackInfo infoToCompareTo) { 79 | return (this.uri.equals(infoToCompareTo.uri) && this.metadata.equals(infoToCompareTo.metadata)); 80 | } 81 | 82 | /** 83 | * Return true if the track info object points at a non present track. 84 | * this.getCurrentTrackInfo() will return an object like this if the queue is empty. 85 | * @return true if no field of the track info is set, false otherwise 86 | */ 87 | public boolean isEmpty() { 88 | return (this.queueIndex == 0 && this.duration == 0 && this.position == 0 && this.uri.isEmpty()); 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/fileHandling/MusicProvider.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer.fileHandling; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | 6 | import com.github.kilianB.example.localFilePlayer.fileHandling.exception.MusicProviderInternalException; 7 | import com.github.kilianB.example.localFilePlayer.fileHandling.exception.NoSuitableAlbumFound; 8 | import com.github.kilianB.example.localFilePlayer.fileHandling.exception.NoSuitableSongFound; 9 | import com.github.kilianB.example.localFilePlayer.fileHandling.model.Album; 10 | import com.github.kilianB.example.localFilePlayer.fileHandling.model.Song; 11 | 12 | 13 | /** 14 | * A music provider supplies modules and devices with links to music resources. It does not matter if the files are 15 | * located on the network, a local device or the internet.

16 | * 17 | * Note: 18 | * Per convention resources should be implementation independently of the devices on the smart server and not discriminate 19 | * certain services. If certain resources are only available for certain devices (e.g. Sonos) the provider shall ensure a lower 20 | * priority when registering to the music manager module. 21 | * @author Kilian 22 | * 23 | */ 24 | public interface MusicProvider{ 25 | 26 | 27 | /** 28 | * 29 | * @return 30 | * @throws MusicProviderInternalException 31 | */ 32 | 33 | List getAllAlbums() throws MusicProviderInternalException; 34 | /** 35 | * 36 | * @param trackName 37 | * @return 38 | * @throws NoSuitableSongFound 39 | * @throws MusicProviderInternalException 40 | */ 41 | List getSongsByName(String trackName) throws NoSuitableSongFound, MusicProviderInternalException; 42 | 43 | List getSongsByInterpret(String interpretName) 44 | throws NoSuitableSongFound, MusicProviderInternalException; 45 | 46 | List getSongsByGenre(String genre) throws NoSuitableSongFound, MusicProviderInternalException; 47 | 48 | List getAlbumsByName(String albumName) throws NoSuitableAlbumFound, MusicProviderInternalException; 49 | 50 | List getAlbumsByInterpret(String interpretName) 51 | throws NoSuitableAlbumFound, MusicProviderInternalException; 52 | 53 | List getAlbumsByGenre(String genre) throws NoSuitableAlbumFound, MusicProviderInternalException; 54 | 55 | /** 56 | * Return all names of interprets who are within edit distance of the supplied name. 57 | * The LevenshteinDistance is used to determine how far individual names are apart 58 | * @param name 59 | * @param editDistance 60 | * @return 61 | */ 62 | ArrayList getInterpretNameAprox(String name, int editDistance) throws MusicProviderInternalException; 63 | 64 | } -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/listener/MediaRendererQueueListener.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.listener; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | import java.util.Optional; 6 | 7 | import org.jdom2.Element; 8 | import org.jdom2.Namespace; 9 | 10 | import com.github.kilianB.sonos.SonosDevice; 11 | import com.github.kilianB.sonos.model.QueueEvent; 12 | import com.github.kilianB.uPnPClient.UPnPEvent; 13 | import com.github.kilianB.uPnPClient.UPnPEventAdapter; 14 | import com.github.kilianB.uPnPClient.UPnPEventAdapterVerbose; 15 | 16 | /** 17 | * Event listener used to parse UPnPEvents received from the Queue service 18 | * relating to queue management, saving queues etc 19 | * @author Kilian 20 | * 21 | */ 22 | public class MediaRendererQueueListener extends UPnPEventAdapter { 23 | 24 | private static final Namespace upnpQueueNamespace= Namespace.getNamespace("urn:schemas-sonos-com:metadata-1-0/Queue/"); 25 | 26 | /** 27 | * Event listeners to be notified in case of noteworthy events 28 | */ 29 | private final List listeners; 30 | 31 | public MediaRendererQueueListener(String servicePath,SonosDevice device) { 32 | //super(servicePath); 33 | this.listeners = device.getEventListener(); 34 | } 35 | 36 | @Override 37 | public void initialEventReceived(UPnPEvent event) { 38 | //System.out.println("Initial event: "); 39 | //System.out.println(event.getBodyAsString()); 40 | } 41 | 42 | @Override 43 | public void eventReceived(UPnPEvent event) { 44 | //System.out.println("Value changed event: "); 45 | //System.out.println(event.getBodyAsString()); 46 | 47 | for(Element e : event.getProperties()) { 48 | 49 | List modifiedQueues = e.getChild("Event",upnpQueueNamespace).getChildren("QueueID",upnpQueueNamespace); 50 | 51 | List queuesAffected= new ArrayList(); 52 | 53 | for(Element ee : modifiedQueues) { 54 | 55 | int queueId = Integer.parseInt(ee.getAttributeValue("val")); 56 | int updatedId = -1; 57 | 58 | Element updateID = ee.getChild("UpdateID",upnpQueueNamespace); 59 | 60 | if(updateID != null) { 61 | updatedId = Integer.parseInt(updateID.getAttributeValue("val")); 62 | } 63 | 64 | 65 | QueueEvent queue = new QueueEvent(queueId,updatedId); 66 | 67 | 68 | Element curated = ee.getChild("Curated",upnpQueueNamespace); 69 | if(curated != null) { 70 | queue.setCurated(Optional.of(Integer.parseInt(curated.getAttributeValue("val")) != 0)); 71 | } 72 | 73 | queuesAffected.add(queue); 74 | } 75 | for(SonosEventListener listener : this.listeners) { 76 | listener.queueChanged(queuesAffected); 77 | } 78 | } 79 | 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/fileHandling/model/Interpret.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer.fileHandling.model; 2 | 3 | import java.util.Date; 4 | 5 | public class Interpret { 6 | String name; 7 | String description; 8 | Date birthyear; 9 | 10 | public Interpret(String name, String description, Date birthyear) { 11 | super(); 12 | this.name = name; 13 | this.description = description; 14 | this.birthyear = birthyear; 15 | } 16 | 17 | /** 18 | * @return the name 19 | */ 20 | public String getName() { 21 | return name; 22 | } 23 | 24 | /** 25 | * @param name the name to set 26 | */ 27 | public void setName(String name) { 28 | this.name = name; 29 | } 30 | 31 | /** 32 | * @return the description 33 | */ 34 | public String getDescription() { 35 | return description; 36 | } 37 | 38 | /** 39 | * @param description the description to set 40 | */ 41 | public void setDescription(String description) { 42 | this.description = description; 43 | } 44 | 45 | /** 46 | * @return the birthyear 47 | */ 48 | public Date getBirthyear() { 49 | return birthyear; 50 | } 51 | 52 | /** 53 | * @param birthyear the birthyear to set 54 | */ 55 | public void setBirthyear(Date birthyear) { 56 | this.birthyear = birthyear; 57 | } 58 | 59 | 60 | @Override 61 | public int hashCode() { 62 | final int prime = 31; 63 | int result = 1; 64 | result = prime * result + ((birthyear == null) ? 0 : birthyear.hashCode()); 65 | result = prime * result + ((description == null) ? 0 : description.hashCode()); 66 | result = prime * result + ((name == null) ? 0 : name.hashCode()); 67 | return result; 68 | } 69 | 70 | @Override 71 | public boolean equals(Object obj) { 72 | if (this == obj) 73 | return true; 74 | if (obj == null) 75 | return false; 76 | if (getClass() != obj.getClass()) 77 | return false; 78 | Interpret other = (Interpret) obj; 79 | if (birthyear == null) { 80 | if (other.birthyear != null) 81 | return false; 82 | } else if (!birthyear.equals(other.birthyear)) 83 | return false; 84 | if (description == null) { 85 | if (other.description != null) 86 | return false; 87 | } else if (!description.equals(other.description)) 88 | return false; 89 | if (name == null) { 90 | if (other.name != null) 91 | return false; 92 | } else if (!name.equals(other.name)) 93 | return false; 94 | return true; 95 | } 96 | 97 | //TODO Maybe create a proper Factory class for this? 98 | public static Interpret createUnknownInterpret() { 99 | return new Interpret("Unknown","",null); 100 | } 101 | 102 | 103 | 104 | 105 | } -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/listener/RenderingControlListener.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.listener; 2 | 3 | import java.util.List; 4 | 5 | import org.jdom2.Element; 6 | import org.jdom2.Namespace; 7 | 8 | import com.github.kilianB.sonos.ParserHelper; 9 | import com.github.kilianB.sonos.SonosDevice; 10 | import com.github.kilianB.uPnPClient.UPnPEvent; 11 | import com.github.kilianB.uPnPClient.UPnPEventAdapter; 12 | import com.github.kilianB.uPnPClient.UPnPEventAdapterVerbose; 13 | 14 | /** 15 | * Event listener used to parse UPnPEvents received from the Queue service 16 | * relating to playback rendering, eg bass, treble, volume and EQ 17 | * @author Kilian 18 | * 19 | */ 20 | public class RenderingControlListener extends UPnPEventAdapter{ 21 | private static final Namespace upnpRCNamespace = Namespace.getNamespace("urn:schemas-upnp-org:metadata-1-0/RCS/"); 22 | 23 | /** 24 | * Event listeners to be notified in case of noteworthy events 25 | */ 26 | private final List listeners; 27 | 28 | public RenderingControlListener(String servicePath, SonosDevice device) { 29 | //super(servicePath); 30 | listeners = device.getEventListener(); 31 | } 32 | 33 | @Override 34 | public void eventReceived(UPnPEvent event) { 35 | for(Element e : event.getProperties()) { 36 | //UPnP media renderer events are wrapped in multiple xml elements 37 | 38 | //Unwrap xml event 39 | Element properties = ParserHelper.unwrapSonosEvent(e,upnpRCNamespace);//e.getChild("Event", upnpRCSNamespace).getChild("InstanceID",upnpRCSNamespace); 40 | 41 | for(Element ele : properties.getChildren()) { 42 | 43 | switch(ele.getName()) { 44 | 45 | case "Volume": 46 | //Master LF and RF 47 | if(ele.getAttributeValue("channel").equals("Master")){ 48 | int volume = Integer.parseInt(ele.getAttributeValue("val")); 49 | for(SonosEventListener listener : listeners) { 50 | listener.volumeChanged(volume); 51 | } 52 | } 53 | break; 54 | 55 | case "Treble": 56 | int treble = Integer.parseInt(ele.getAttributeValue("val")); 57 | for(SonosEventListener listener : listeners) { 58 | listener.trebleChanged(treble); 59 | } 60 | break; 61 | 62 | case "Bass": 63 | int bass = Integer.parseInt(ele.getAttributeValue("val")); 64 | for(SonosEventListener listener : listeners) { 65 | listener.bassChanged(bass); 66 | } 67 | break; 68 | 69 | case "Loudness": 70 | boolean loudness = Integer.parseInt(ele.getAttributeValue("val"))!= 0; 71 | for(SonosEventListener listener : listeners) { 72 | listener.loudenessChanged(loudness); 73 | } 74 | break; 75 | } 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/DemoPlayer.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer; 2 | 3 | import java.io.IOException; 4 | import java.net.InetAddress; 5 | import java.util.logging.Logger; 6 | 7 | import com.github.kilianB.NetworkUtil; 8 | import com.github.kilianB.example.localFilePlayer.fileHandling.DatabaseManager; 9 | import com.github.kilianB.example.localFilePlayer.fileHandling.MusicFileIndexer; 10 | import com.github.kilianB.example.localFilePlayer.fileHandling.NetworkFileProvider; 11 | 12 | import javafx.application.Application; 13 | import javafx.application.Platform; 14 | import javafx.fxml.FXMLLoader; 15 | import javafx.scene.Parent; 16 | import javafx.scene.Scene; 17 | import javafx.scene.image.Image; 18 | import javafx.stage.Stage; 19 | 20 | public class DemoPlayer extends Application { 21 | 22 | private static Logger LOGGER = Logger.getLogger(DemoPlayer.class.getName()); 23 | 24 | private InetAddress hostAddress; 25 | 26 | private NetworkFileProvider fileProvider; 27 | private MusicFileIndexer fileIndexer; 28 | private DatabaseManager db = new DatabaseManager(); 29 | 30 | private String[] allowedFileExtensions = { "flac", "mp3", "wav" }; 31 | 32 | DemoPlayerController controller; 33 | 34 | public DemoPlayer() { 35 | 36 | // String defaultIPv4Stack = System.getProperty("java.net.preferIPv4Stack"); 37 | 38 | try { 39 | hostAddress = NetworkUtil.resolveSiteLocalAddress(); 40 | } catch (IOException e) { 41 | LOGGER.severe("Could not resolve a valid ip adress. fallback to localhost " + e); 42 | } 43 | 44 | 45 | //db.resetDB(); 46 | 47 | fileIndexer = new MusicFileIndexer(allowedFileExtensions, db); 48 | fileProvider = new NetworkFileProvider(hostAddress.getHostAddress(), 7001, allowedFileExtensions); 49 | 50 | controller = new DemoPlayerController(fileProvider, fileIndexer,db); 51 | 52 | 53 | } 54 | 55 | @Override 56 | public void start(Stage primaryStage) throws IOException { 57 | 58 | // Shutdown on window close! 59 | Platform.setImplicitExit(true); 60 | 61 | FXMLLoader loader = new FXMLLoader(); 62 | loader.setController(controller); 63 | 64 | loader.setLocation(getClass().getResource("DemoPlayer.fxml")); 65 | 66 | Parent root = loader.load(); 67 | 68 | Scene scene = new Scene(root, 1000, 800); 69 | scene.getStylesheets().add(getClass().getResource("DemoPlayer.css").toExternalForm()); 70 | 71 | primaryStage.setScene(scene); 72 | 73 | primaryStage.getIcons().add(new Image(getClass().getResourceAsStream("icons/SonosIcon.png"))); 74 | 75 | primaryStage.show(); 76 | 77 | } 78 | 79 | @Override 80 | public void stop() { 81 | fileProvider.deinit(); 82 | controller.deinit(); 83 | db.close(); 84 | } 85 | 86 | public static void main(String[] args) { 87 | launch(args); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/ParserHelper.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Collections; 5 | import java.util.List; 6 | import java.util.regex.Matcher; 7 | import java.util.regex.Pattern; 8 | 9 | import org.jdom2.Element; 10 | import org.jdom2.Namespace; 11 | 12 | /** 13 | * Utility functions to extract information returned from UPnP events 14 | * @author Kilian 15 | * @author vmichalak 16 | */ 17 | public class ParserHelper { 18 | 19 | // Hide the implicit public constructor. 20 | private ParserHelper() { 21 | } 22 | 23 | /** 24 | * Return the first find occurrence of a regex match. 25 | * 26 | * @param regex pattern regex 27 | * @param content data 28 | * @return an empty string if it doesn't found pattern 29 | */ 30 | public static String findOne(String regex, String content) { 31 | Pattern pattern = Pattern.compile(regex); 32 | Matcher matcher = pattern.matcher(content); 33 | boolean haveResult = matcher.find(); 34 | if (!haveResult) { 35 | return ""; 36 | } 37 | return matcher.group(1); 38 | } 39 | 40 | /** 41 | * Return all occurrences of a regex match. 42 | * 43 | * @param regex pattern regex 44 | * @param content data 45 | * @return an empty list if it doesn't found pattern 46 | */ 47 | public static List findAll(String regex, String content) { 48 | Pattern pattern = Pattern.compile(regex); 49 | Matcher matcher = pattern.matcher(content); 50 | List r = new ArrayList(); 51 | while (matcher.find()) { 52 | r.add(matcher.group(1)); 53 | } 54 | return Collections.unmodifiableList(r); 55 | } 56 | 57 | /** 58 | * Converts the sonos upnp timestamp HH:MM:SS to 59 | * a duration in seconds 60 | * @param durationAsString the duration 61 | * @return timestamp as seconds 62 | */ 63 | public static int formatedTimestampToSeconds(String durationAsString) { 64 | 65 | if(!durationAsString.isEmpty()) { 66 | String[] parts = durationAsString.split(":"); 67 | return Integer.parseInt(parts[0]) * 3600 + Integer.parseInt(parts[1]) * 60 + Integer.parseInt(parts[2]); 68 | }else { 69 | return 0; 70 | } 71 | 72 | } 73 | 74 | /** 75 | * Converts seconds to a sonos upnp duration timestamp in the format 76 | * of HH:MM:SS 77 | * @param seconds the seconds 78 | * @return converted timestamp in HH:MM:SS 79 | */ 80 | public static String secondsToFormatedTimestamp(int seconds) { 81 | int hours = seconds / 3600; 82 | int minutes = (seconds % 3600) / 60; 83 | int secs = seconds % 60; 84 | return String.format("%02d:%02d:%02d", hours,minutes,secs); 85 | } 86 | 87 | public static Element unwrapSonosEvent(Element e,Namespace namespace) { 88 | return e.getChild("Event",namespace).getChild("InstanceID",namespace); 89 | } 90 | 91 | public static String extractEventValue(Element e, String childName) { 92 | return e.getChild(childName).getAttributeValue("val"); 93 | } 94 | 95 | public static String extractEventValue(Element e,String childName, Namespace namespace) { 96 | return e.getChild(childName,namespace).getAttributeValue("val"); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/voiceToTextPlayback/VoiceToTextController.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.voiceToTextPlayback; 2 | 3 | import java.io.File; 4 | import java.io.IOException; 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | import java.util.Objects; 8 | 9 | import com.github.kilianB.apis.googleTextToSpeech.GLanguage; 10 | import com.github.kilianB.apis.googleTextToSpeech.GoogleTextToSpeech; 11 | import com.github.kilianB.apis.googleTextToSpeech.GoogleTextToSpeechAdapter; 12 | import com.github.kilianB.exception.SonosControllerException; 13 | import com.github.kilianB.sonos.SonosDevice; 14 | import com.github.kilianB.sonos.SonosDiscovery; 15 | import com.jfoenix.controls.JFXButton; 16 | import com.jfoenix.controls.JFXComboBox; 17 | import com.jfoenix.controls.JFXTextField; 18 | 19 | import javafx.collections.FXCollections; 20 | import javafx.collections.ObservableList; 21 | import javafx.fxml.FXML; 22 | import javafx.util.StringConverter; 23 | 24 | /** 25 | * @author Kilian 26 | * 27 | */ 28 | public class VoiceToTextController { 29 | 30 | @FXML 31 | private JFXTextField textToConvertField; 32 | 33 | @FXML 34 | private JFXButton announceBtn; 35 | 36 | @FXML 37 | private JFXComboBox speakerCbox; 38 | 39 | private ObservableList devices = FXCollections.observableList(new ArrayList<>()); 40 | private NetworkFileProvider fileProvider; 41 | 42 | 43 | /** 44 | * @param fileProvider 45 | */ 46 | public VoiceToTextController(NetworkFileProvider fileProvider) { 47 | this.fileProvider = Objects.requireNonNull(fileProvider); 48 | } 49 | 50 | 51 | @FXML 52 | public void initialize() { 53 | //Initialize combo box 54 | speakerCbox.itemsProperty().set(devices); 55 | speakerCbox.setConverter(new SonosDeviceStringConverter()); 56 | 57 | SonosDiscovery.discoverAsynch(3,device->{ 58 | devices.add(device); 59 | }); 60 | 61 | 62 | //Convert and play back 63 | announceBtn.setOnAction( e ->{ 64 | 65 | String text = textToConvertField.getText(); 66 | SonosDevice currentDevice = speakerCbox.getSelectionModel().getSelectedItem(); 67 | 68 | if(currentDevice != null && text != null && !text.isEmpty()) { 69 | 70 | System.out.println("Start request"); 71 | 72 | GoogleTextToSpeech gtts = new GoogleTextToSpeech("mp3TempFiles/"); 73 | gtts.convertTextAsynch(text,GLanguage.English_GB,"Anounce",true,new GoogleTextToSpeechAdapter() { 74 | @Override 75 | public void mergeCompleted(File f,int id) { 76 | 77 | System.out.println("Merge completed"); 78 | 79 | //We got the file we want to play back. 80 | String uri = "http://"+fileProvider.toMappedPath(f.getAbsolutePath()); 81 | try { 82 | currentDevice.clip(uri,null); 83 | } catch (IOException | SonosControllerException | InterruptedException e) { 84 | e.printStackTrace(); 85 | } 86 | 87 | } 88 | }); 89 | } 90 | 91 | }); 92 | 93 | } 94 | 95 | 96 | class SonosDeviceStringConverter extends StringConverter{ 97 | 98 | @Override 99 | public String toString(SonosDevice object) { 100 | return object.getRoomNameCached(); 101 | } 102 | 103 | @Override 104 | public SonosDevice fromString(String string) { 105 | throw new UnsupportedOperationException("Can't construct device from string"); 106 | } 107 | 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/uPnPClient/Subscription.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.uPnPClient; 2 | 3 | import java.util.concurrent.ScheduledFuture; 4 | 5 | /** 6 | * Represents a successfully established subscription to a service of an UPnP device 7 | * @author Kilian 8 | * 9 | */ 10 | public class Subscription { 11 | 12 | /** 13 | * Serivce identifier of the event 14 | */ 15 | private String token; 16 | /** 17 | * Path of the service used to subscribe to 18 | */ 19 | private String servicePath; 20 | /** 21 | * Listener which will be notified in case an event takes place 22 | */ 23 | private UPnPEventListener eventListener; 24 | /** 25 | * Future handling re subscription 26 | */ 27 | private ScheduledFuture renewalFuture; 28 | /** 29 | * Interval in seconds between re subscriptions 30 | */ 31 | private int renewalInterval; 32 | /** 33 | * Current sequence identifier of the subscription. 34 | */ 35 | private int sequenceCount = -1; 36 | 37 | public Subscription(UPnPEventListener eventListener, String servicePath, int renewalInterval) { 38 | this.eventListener = eventListener; 39 | this.servicePath = servicePath; 40 | this.renewalInterval = renewalInterval; 41 | } 42 | 43 | /** 44 | * @return service identifier of the event subscription 45 | */ 46 | public String getToken() { 47 | return token; 48 | } 49 | 50 | /** 51 | * @param token service identifier of the event subscription emitted by the device 52 | */ 53 | public void setToken(String token) { 54 | this.token = token; 55 | } 56 | 57 | /** 58 | * @return the relative path used to subscribe to the service 59 | */ 60 | public String getServicePath() { 61 | return servicePath; 62 | } 63 | 64 | /** 65 | * @param servicePath the relative path of the service 66 | */ 67 | public void setServicePath(String servicePath) { 68 | this.servicePath = servicePath; 69 | } 70 | 71 | /** 72 | * The renewal future is the currently scheduled future to issue a renewal request for the described 73 | * event subscription 74 | * @return the renewal future used to cancel the renewal request 75 | */ 76 | public ScheduledFuture getRenewalFuture() { 77 | return renewalFuture; 78 | } 79 | 80 | /** 81 | * Sets the subscriptions renewal future used to renew the event subscription 82 | * @param renewalFuture the future in charge of resubscribing 83 | */ 84 | public void setRenewalFuture(ScheduledFuture renewalFuture) { 85 | this.renewalFuture = renewalFuture; 86 | } 87 | 88 | /** 89 | * The current sequence count of the subscription event. The sequence count increases 90 | * with every received event and can be used to track the sequential order as well as if 91 | * events were lost due to UDP unreliability 92 | * @return the last submitted sequence count submitted by the device 93 | */ 94 | public int getSequenceCount() { 95 | return sequenceCount; 96 | } 97 | 98 | public void setSequenceCount(int sequenceCount) { 99 | this.sequenceCount = sequenceCount; 100 | } 101 | 102 | public UPnPEventListener getEventListener() { 103 | return eventListener; 104 | } 105 | 106 | public void setEventListener(UPnPEventListener eventListener) { 107 | this.eventListener = eventListener; 108 | } 109 | 110 | public int getRenewalInterval() { 111 | return renewalInterval; 112 | } 113 | 114 | public void setRenewalInterval(int renewalInterval) { 115 | this.renewalInterval = renewalInterval; 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/NetworkUtil.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB; 2 | 3 | import java.io.BufferedReader; 4 | import java.io.IOException; 5 | import java.io.InputStream; 6 | import java.io.InputStreamReader; 7 | import java.io.Writer; 8 | import java.net.InetAddress; 9 | import java.net.NetworkInterface; 10 | import java.net.Socket; 11 | import java.util.Enumeration; 12 | 13 | public class NetworkUtil { 14 | /** 15 | * Resolves the first found link local address of the current machine 192.168.xxx.xxx. 16 | * This is a fix for InetAddress.getLocalHost().getHostAddress(); which 17 | * in some cases will resolve to a loopback address (127.0.01). 18 | *

19 | * Site local addresses 192.168.xxx.xxx are available inside the same network. 20 | * Same counts for 10.xxx.xxx.xxx addresses, and 172.16.xxx.xxx through 172.31.xxx.xxx 21 | *

22 | * Link local addresses 169.254.xxx.xxx are for a single network segment 23 | *

24 | * Addresses in the range 224.xxx.xxx.xxx through 239.xxx.xxx.xxx are multicast addresses. 25 | *

26 | * Broadcast address 255.255.255.255. 27 | *

28 | * Loopback addresses 127.xxx.xxx.xxx 29 | * 30 | * @return link local address of the machine or InetAddress.getLocalHost() if no address can be found. 31 | * @throws IOException if address can not be resolved 32 | */ 33 | public static InetAddress resolveSiteLocalAddress() throws IOException { 34 | Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); 35 | 36 | while(interfaces.hasMoreElements()) { 37 | NetworkInterface curInterface = interfaces.nextElement(); 38 | Enumeration addresses = curInterface.getInetAddresses(); 39 | while(addresses.hasMoreElements()) { 40 | InetAddress curInetAddress = addresses.nextElement(); 41 | if(curInetAddress.isSiteLocalAddress()) { 42 | return curInetAddress; 43 | } 44 | } 45 | } 46 | return InetAddress.getLocalHost(); 47 | } 48 | 49 | 50 | /** 51 | * Collect all content available in the reader and return it as a string 52 | * @param br Buffered Reader input source 53 | * @return Content of the reader as string 54 | * @throws IOException Exception thrown during read operation. 55 | */ 56 | public static String dumpReader(BufferedReader br) throws IOException { 57 | StringBuilder response = new StringBuilder(); 58 | String temp; 59 | while ((temp = br.readLine()) != null) { 60 | response.append(temp).append(System.lineSeparator()); 61 | } 62 | return response.toString(); 63 | } 64 | 65 | 66 | public static String collectSocketAndClose(Socket s) throws IOException { 67 | String result = collectSocket(s); 68 | s.close(); 69 | return result; 70 | } 71 | 72 | public static String collectSocket(Socket s) throws IOException { 73 | 74 | InputStream is = s.getInputStream(); 75 | 76 | StringBuilder sb = new StringBuilder(); 77 | 78 | int b; 79 | 80 | while(is.available() > 0) { 81 | b = is.read(); 82 | sb.append((char)b); 83 | } 84 | return sb.toString(); 85 | } 86 | 87 | public static String collectSocketWithTimeout(Socket s, int msTimeout) throws IOException { 88 | 89 | int oldTimeout = s.getSoTimeout(); 90 | 91 | s.setSoTimeout(msTimeout); 92 | 93 | InputStream is = s.getInputStream(); 94 | 95 | StringBuilder sb = new StringBuilder(); 96 | 97 | try { 98 | while(!s.isClosed()) { 99 | sb.append((char)is.read()); 100 | } 101 | //TODO expensive each read an exception is thrown 102 | }catch(java.net.SocketTimeoutException io) {} 103 | 104 | s.setSoTimeout(oldTimeout); 105 | 106 | return sb.toString(); 107 | 108 | 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/model/AVTransportEvent.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.model; 2 | 3 | /** 4 | * Fully parsed UPnP Transport event 5 | * @author Kilian 6 | * 7 | */ 8 | public class AVTransportEvent { 9 | 10 | 11 | private PlayState transportState; 12 | private PlayMode currentPlayMode; 13 | private boolean crossFade; 14 | private int numberOfTracks; 15 | private int currentSection; 16 | private TrackInfo currentTrack; 17 | private TrackInfo nextTrack; 18 | private String enqueuedTransportURI; 19 | private TrackMetadata enqueuedTransportURIMetaData; 20 | 21 | public AVTransportEvent(PlayState transportState, PlayMode currentPlayMode, boolean crossFade, int numberOfTracks, 22 | int currentSection, TrackInfo currentTrack, TrackInfo nextTrack, String enqueuedTransportURI, 23 | TrackMetadata enqueuedTransportURIMetaData) { 24 | super(); 25 | this.transportState = transportState; 26 | this.currentPlayMode = currentPlayMode; 27 | this.crossFade = crossFade; 28 | this.numberOfTracks = numberOfTracks; 29 | this.currentSection = currentSection; 30 | this.currentTrack = currentTrack; 31 | this.nextTrack = nextTrack; 32 | this.enqueuedTransportURI = enqueuedTransportURI; 33 | this.enqueuedTransportURIMetaData = enqueuedTransportURIMetaData; 34 | } 35 | 36 | public PlayState getTransportState() { 37 | return transportState; 38 | } 39 | 40 | public void setTransportState(PlayState transportState) { 41 | this.transportState = transportState; 42 | } 43 | 44 | public PlayMode getCurrentPlayMode() { 45 | return currentPlayMode; 46 | } 47 | 48 | public void setCurrentPlayMode(PlayMode currentPlayMode) { 49 | this.currentPlayMode = currentPlayMode; 50 | } 51 | 52 | public boolean isCrossFade() { 53 | return crossFade; 54 | } 55 | 56 | public void setCrossFade(boolean crossFade) { 57 | this.crossFade = crossFade; 58 | } 59 | 60 | public int getNumberOfTracks() { 61 | return numberOfTracks; 62 | } 63 | 64 | public void setNumberOfTracks(int numberOfTracks) { 65 | this.numberOfTracks = numberOfTracks; 66 | } 67 | 68 | public int getCurrentSection() { 69 | return currentSection; 70 | } 71 | 72 | public void setCurrentSection(int currentSection) { 73 | this.currentSection = currentSection; 74 | } 75 | 76 | public TrackInfo getCurrentTrack() { 77 | return currentTrack; 78 | } 79 | 80 | public void setCurrentTrack(TrackInfo currentTrack) { 81 | this.currentTrack = currentTrack; 82 | } 83 | 84 | public TrackInfo getNextTrack() { 85 | return nextTrack; 86 | } 87 | 88 | public void setNextTrack(TrackInfo nextTrack) { 89 | this.nextTrack = nextTrack; 90 | } 91 | 92 | public String getEnqueuedTransportURI() { 93 | return enqueuedTransportURI; 94 | } 95 | 96 | public void setEnqueuedTransportURI(String enqueuedTransportURI) { 97 | this.enqueuedTransportURI = enqueuedTransportURI; 98 | } 99 | 100 | public TrackMetadata getEnqueuedTransportURIMetaData() { 101 | return enqueuedTransportURIMetaData; 102 | } 103 | 104 | public void setEnqueuedTransportURIMetaData(TrackMetadata enqueuedTransportURIMetaData) { 105 | this.enqueuedTransportURIMetaData = enqueuedTransportURIMetaData; 106 | } 107 | 108 | @Override 109 | public String toString() { 110 | return "AVTransportEvent [transportState=" + transportState + ", currentPlayMode=" + currentPlayMode 111 | + ", crossFade=" + crossFade + ", numberOfTracks=" + numberOfTracks + ", currentSection=" 112 | + currentSection + ", currentTrack=" + currentTrack + ", nextTrack=" + nextTrack 113 | + ", enqueuedTransportURI=" + enqueuedTransportURI + ", enqueuedTransportURIMetaData=" 114 | + enqueuedTransportURIMetaData + "]"; 115 | } 116 | 117 | 118 | } 119 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/fileHandling/model/Album.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer.fileHandling.model; 2 | 3 | import java.io.File; 4 | import java.util.ArrayList; 5 | import java.util.List; 6 | 7 | /** 8 | * An album represents a collection of songs. 9 | * 10 | * @author Kilian 11 | * 12 | */ 13 | public class Album { 14 | 15 | String title; 16 | String description; 17 | File albumCovert; 18 | Interpret interpret; 19 | 20 | // TODO maybe extend the audio file class by our own class to add addditional 21 | // information besides the tags. 22 | List tracks; 23 | 24 | public Album(String title, String description, File albumCovert, Interpret interpret, List tracks) { 25 | super(); 26 | this.title = title; 27 | this.description = description; 28 | this.albumCovert = albumCovert; 29 | this.interpret = interpret; 30 | this.tracks = tracks; 31 | } 32 | 33 | public void addTrack(Song audioFile) { 34 | tracks.add(audioFile); 35 | } 36 | 37 | public String getTitle() { 38 | return title; 39 | } 40 | 41 | public void setTitle(String title) { 42 | this.title = title; 43 | } 44 | 45 | public String getDescription() { 46 | return description; 47 | } 48 | 49 | public void setDescription(String description) { 50 | this.description = description; 51 | } 52 | 53 | public File getAlbumCovert() { 54 | return albumCovert; 55 | } 56 | 57 | public void setAlbumCovert(File albumCovert) { 58 | this.albumCovert = albumCovert; 59 | } 60 | 61 | public Interpret getInterpret() { 62 | return interpret; 63 | } 64 | 65 | public void setInterpret(Interpret interpret) { 66 | this.interpret = interpret; 67 | } 68 | 69 | public List getTracks() { 70 | return tracks; 71 | } 72 | 73 | public void setTracks(ArrayList tracks) { 74 | this.tracks = tracks; 75 | } 76 | 77 | /* 78 | * (non-Javadoc) 79 | * 80 | * @see java.lang.Object#hashCode() 81 | */ 82 | @Override 83 | public int hashCode() { 84 | final int prime = 31; 85 | int result = 1; 86 | result = prime * result + ((albumCovert == null) ? 0 : albumCovert.hashCode()); 87 | result = prime * result + ((description == null) ? 0 : description.hashCode()); 88 | result = prime * result + ((interpret == null) ? 0 : interpret.hashCode()); 89 | result = prime * result + ((title == null) ? 0 : title.hashCode()); 90 | result = prime * result + ((tracks == null) ? 0 : tracks.hashCode()); 91 | return result; 92 | } 93 | 94 | /* 95 | * (non-Javadoc) 96 | * 97 | * @see java.lang.Object#equals(java.lang.Object) 98 | */ 99 | @Override 100 | public boolean equals(Object obj) { 101 | if (this == obj) 102 | return true; 103 | if (obj == null) 104 | return false; 105 | if (getClass() != obj.getClass()) 106 | return false; 107 | Album other = (Album) obj; 108 | if (albumCovert == null) { 109 | if (other.albumCovert != null) 110 | return false; 111 | } else if (!albumCovert.equals(other.albumCovert)) 112 | return false; 113 | if (description == null) { 114 | if (other.description != null) 115 | return false; 116 | } else if (!description.equals(other.description)) 117 | return false; 118 | if (interpret == null) { 119 | if (other.interpret != null) 120 | return false; 121 | } else if (!interpret.equals(other.interpret)) 122 | return false; 123 | if (title == null) { 124 | if (other.title != null) 125 | return false; 126 | } else if (!title.equals(other.title)) 127 | return false; 128 | if (tracks == null) { 129 | if (other.tracks != null) 130 | return false; 131 | } else if (!tracks.equals(other.tracks)) 132 | return false; 133 | return true; 134 | } 135 | 136 | } -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/fileHandling/NetworkFileProvider.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer.fileHandling; 2 | 3 | import java.io.File; 4 | import java.util.HashSet; 5 | import java.util.logging.Level; 6 | import java.util.logging.Logger; 7 | 8 | import io.undertow.Undertow; 9 | import io.undertow.server.handlers.PathHandler; 10 | import io.undertow.server.handlers.resource.FileResourceManager; 11 | import io.undertow.server.handlers.resource.ResourceHandler; 12 | 13 | 14 | /** 15 | * Start a file server which maps a local directory to a fixed ip address allowing network devices 16 | * to query data from this place. 17 | * @author Kilian 18 | * 19 | */ 20 | public class NetworkFileProvider { 21 | 22 | //Settings 23 | private String host; 24 | private int port; 25 | private String mapPrefix; 26 | 27 | //Internal 28 | private final Undertow server; 29 | private final PathHandler pathHandler = new PathHandler(); 30 | private HashSet mappedFolders = new HashSet<>(); 31 | 32 | 33 | public NetworkFileProvider(String host, int port, String[] allowedFileExtensions){ 34 | 35 | this.host = host; 36 | this.port = port; 37 | this.mapPrefix = host+":"+port + "/"; 38 | 39 | server = Undertow.builder().addHttpListener(port, host, pathHandler).build(); 40 | 41 | //Undertow wraps it's exception in a runtime exception. 42 | try { 43 | server.start(); 44 | }catch(RuntimeException e) { 45 | Throwable t = e.getCause(); 46 | if(t instanceof java.net.BindException) { 47 | LOGGER.severe("Port already bound by another program. Please check if the smart server is running " 48 | + " twice or swap to a free port."); 49 | }else { 50 | LOGGER.severe(t.toString()); 51 | } 52 | } 53 | } 54 | 55 | 56 | /** 57 | * Maps the given folder to host:port/folderPath allowing network access by quering this address 58 | * @param directory 59 | * @return true if folder was successfully mapped. False otherwise 60 | */ 61 | public synchronized boolean mapFolder(File directory) { 62 | 63 | if(directory.isDirectory()) { 64 | 65 | //Escape windows path 66 | String prefixPath = directory.getAbsolutePath().replace("\\", "/"); 67 | 68 | if(mappedFolders.contains(prefixPath)) { 69 | LOGGER.warning("Folder " + prefixPath + " already mapped. Skip request"); 70 | return false; 71 | } 72 | 73 | FileResourceManager sharedFolderRessourceManager = new FileResourceManager(directory, 0); 74 | 75 | ResourceHandler sharedRessourceManager = new ResourceHandler(sharedFolderRessourceManager); 76 | //TODO MIME Mapping and index directoy listing disabled for more protection 77 | sharedRessourceManager.setDirectoryListingEnabled(true); 78 | 79 | pathHandler.addPrefixPath("/"+prefixPath, sharedRessourceManager); 80 | 81 | mappedFolders.add(prefixPath); 82 | LOGGER.log(Level.INFO,"map "+directory.getAbsolutePath()+" to " + mapPrefix + prefixPath); 83 | }else { 84 | throw new IllegalArgumentException("Please provide a folder and not a file"); 85 | } 86 | 87 | return true; 88 | } 89 | 90 | 91 | public String toMappedPath(String originalLocation) { 92 | return mapPrefix + originalLocation; 93 | } 94 | 95 | public String toUnmappedPath(String mappedLocation) { 96 | if(mappedLocation.startsWith(mapPrefix)) { 97 | return mappedLocation.substring(mapPrefix.length()); 98 | } 99 | return mappedLocation; 100 | } 101 | 102 | public void deinit(){ 103 | server.stop(); 104 | } 105 | 106 | 107 | private static final Logger LOGGER = Logger.getLogger(NetworkFileProvider.class.getName()); 108 | 109 | 110 | /** 111 | * @return 112 | */ 113 | public String getMapPrefix() { 114 | return mapPrefix; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/SonosEventListenerExample.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example; 2 | 3 | import java.io.IOException; 4 | import java.util.ArrayList; 5 | import java.util.Arrays; 6 | import java.util.List; 7 | 8 | import com.github.kilianB.exception.SonosControllerException; 9 | import com.github.kilianB.sonos.SonosDevice; 10 | import com.github.kilianB.sonos.SonosDiscovery; 11 | import com.github.kilianB.sonos.listener.SonosEventListener; 12 | import com.github.kilianB.sonos.model.AVTransportEvent; 13 | import com.github.kilianB.sonos.model.PlayMode; 14 | import com.github.kilianB.sonos.model.PlayState; 15 | import com.github.kilianB.sonos.model.QueueEvent; 16 | import com.github.kilianB.sonos.model.TrackInfo; 17 | 18 | /** 19 | * This example demonstrates how to use upnp event callbacks to react to changes 20 | * of a sonos speaker 21 | * @author Kilian 22 | * 23 | */ 24 | public class SonosEventListenerExample { 25 | 26 | public static void main(String[] args) { 27 | 28 | try { 29 | SonosDevice sonos = SonosDiscovery.discoverOne(); 30 | 31 | System.out.println("Sonos device found: " + sonos.getDeviceName()); 32 | 33 | sonos.registerSonosEventListener(new SonosEventListener() { 34 | 35 | @Override 36 | public void volumeChanged(int newVolume) { 37 | System.out.println("Volume changed: " + newVolume); 38 | } 39 | 40 | @Override 41 | public void playStateChanged(PlayState newPlayState) { 42 | System.out.println("Playstate changed: " + newPlayState); 43 | } 44 | 45 | @Override 46 | public void playModeChanged(PlayMode newPlayMode) { 47 | System.out.println("Playmode changed: " + newPlayMode); 48 | } 49 | 50 | @Override 51 | public void trackChanged(TrackInfo currentTrack) { 52 | System.out.println("Track changed: " + currentTrack); 53 | } 54 | 55 | @Override 56 | public void trebleChanged(int treble) { 57 | System.out.println("Treble changed: " + treble); 58 | } 59 | 60 | @Override 61 | public void bassChanged(int bass) { 62 | System.out.println("Bass changed: " + bass); 63 | } 64 | 65 | @Override 66 | public void loudenessChanged(boolean loudness) { 67 | System.out.println("Loudness changed: " + loudness); 68 | } 69 | 70 | @Override 71 | public void avtTransportEvent(AVTransportEvent avtTransportEvent) { 72 | System.out.println("AVTTransportEvent: " + avtTransportEvent); 73 | } 74 | 75 | @Override 76 | public void queueChanged(List queuesAffected) { 77 | System.out.println(Arrays.toString(queuesAffected.toArray(new QueueEvent[0]))); 78 | } 79 | 80 | @Override 81 | public void sonosDeviceConnected(String deviceName) { 82 | System.out.println("New sonos device connected: " + deviceName); 83 | } 84 | 85 | @Override 86 | public void sonosDeviceDisconnected(String deviceName) { 87 | System.out.println("New sonos device disconnected: " + deviceName); 88 | } 89 | 90 | @Override 91 | public void groupChanged(ArrayList allDevicesInZone) { 92 | System.out.println("Group changed. " + (allDevicesInZone.size() > 1 ? "grouped" : "solo")); 93 | } 94 | }); 95 | 96 | 97 | //Listen to events for 15 seconds 98 | try { 99 | Thread.sleep(15000); 100 | } catch (InterruptedException e) { 101 | e.printStackTrace(); 102 | } 103 | 104 | /* 105 | * Release all resources and let the jvm shutdown. 106 | * Without this we will listen to events indefinitely. Has to be called on each 107 | * sonos devices we ever registered event handlers to. 108 | */ 109 | sonos.deinit(); 110 | 111 | 112 | 113 | } catch (IOException | SonosControllerException e) { 114 | e.printStackTrace(); 115 | } 116 | 117 | 118 | 119 | 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/listener/SonosEventListener.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.listener; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | 6 | import com.github.kilianB.sonos.model.AVTransportEvent; 7 | import com.github.kilianB.sonos.model.PlayMode; 8 | import com.github.kilianB.sonos.model.PlayState; 9 | import com.github.kilianB.sonos.model.QueueEvent; 10 | import com.github.kilianB.sonos.model.TrackInfo; 11 | 12 | /** 13 | * Event listener used to listen to specific UPnP Events emitted by sonos speakers 14 | * indicating state changes like volume or bass. 15 | * 16 | * @author Kilian 17 | * 18 | */ 19 | public interface SonosEventListener { 20 | 21 | /** 22 | * Fired once the volume of the speaker changes 23 | * @param newVolume the new volume of the speaker 24 | */ 25 | public void volumeChanged(int newVolume); 26 | 27 | /** 28 | * Fired once the play state of the speaker changes. 29 | * Additionally a {@link avtTransportEvent} will be fired. 30 | * @param playState the new play state of the speaker 31 | */ 32 | public void playStateChanged(PlayState playState); 33 | 34 | /** 35 | * Fired once the play mode of the speaker changes. 36 | * Additionally a {@link avtTransportEvent} will be fired. 37 | * @param playMode the new play mode of the speaker 38 | */ 39 | public void playModeChanged(PlayMode playMode); 40 | 41 | /** 42 | * Fired once a queue was manipulated. 43 | * @param queuesAffected The ID and value of the affected queues 44 | */ 45 | public void queueChanged(List queuesAffected); 46 | 47 | /** 48 | * Fired once a new track starts to play or is skipped to 49 | * @param currentTrack information about the new track 50 | */ 51 | public void trackChanged(TrackInfo currentTrack); 52 | 53 | /** 54 | * Fired once the treble of the speaker changes 55 | * @param treble the new treble of the speaker 56 | */ 57 | public void trebleChanged(int treble); 58 | 59 | /** 60 | * Fired once the bass of the speaker changes 61 | * @param bass the new volume of the speaker 62 | */ 63 | public void bassChanged(int bass); 64 | 65 | /** 66 | * Fired once the loudness of the speaker changes 67 | * @param loudness the new loudness of the speaker 68 | */ 69 | public void loudenessChanged(boolean loudness); 70 | 71 | /** 72 | * AVT Transport events are fired when playmode or playstates change and carry more 73 | * information that the specific event handler. Using this method allows more insight 74 | * into what is currently happening at the speaker but does not clearly state which variable 75 | * change emitted this event. 76 | * @param avtTransportEvent UPnPEventInformation 77 | */ 78 | public void avtTransportEvent(AVTransportEvent avtTransportEvent); 79 | 80 | /** 81 | * Event fired once a sonos device is newly reachable via the network, 82 | * Be aware that this event gets emitted rather unreliably and potentially severely lags. 83 | * It also should be noted that this method is not invoked if the speaker subscribed 84 | * to itself is the device connecting. 85 | * @param deviceName The name of the newly connected sonos speaker. 86 | */ 87 | public void sonosDeviceConnected(String deviceName); 88 | 89 | /** 90 | * Event fired once a sonos device is no longer reachable via the network, 91 | * Be aware that this event gets emitted rather unreliably and potentially severely lags. 92 | * It also should be noted that this method is not invoked if the speaker subscribed 93 | * to itself is the device disconnecting. 94 | * @param deviceName The name of the sonos speaker disconnecting. 95 | */ 96 | public void sonosDeviceDisconnected(String deviceName); 97 | 98 | /** 99 | * Event fired once the grouping status of the device changes. 100 | * @param allDevicesInZone The zone name of the devices the current device 101 | * is in a group with including itself 102 | */ 103 | public void groupChanged(ArrayList allDevicesInZone); 104 | } 105 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/listener/ZoneTopologyListener.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.listener; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Collection; 5 | import java.util.HashSet; 6 | import java.util.List; 7 | 8 | import org.jdom2.Element; 9 | 10 | import com.github.kilianB.sonos.SonosDevice; 11 | import com.github.kilianB.uPnPClient.UPnPEvent; 12 | import com.github.kilianB.uPnPClient.UPnPEventAdapter; 13 | import com.github.kilianB.uPnPClient.UPnPEventAdapterVerbose; 14 | 15 | /** 16 | * Sample event listener for the sonos topology events. Topology events 17 | * take care how speakers are grouped together and which devices are present on the 18 | * network. 19 | * 20 | * TODO ZoneTopology really should be a singleton or only one device should be subscribed to. 21 | * This will need refactoring in the future 22 | * 23 | * @author Kilian 24 | * 25 | */ 26 | public class ZoneTopologyListener extends UPnPEventAdapter { 27 | 28 | 29 | /** 30 | * Devices currently available in the network 31 | */ 32 | private HashSet presentDevices = new HashSet(); 33 | 34 | /** 35 | * Internal state of the devices current grouping state 36 | */ 37 | private HashSet groupState = new HashSet(); 38 | 39 | /** 40 | * Event listeners to be notified in case of noteworthy events 41 | */ 42 | private List listeners; 43 | 44 | private SonosDevice device; 45 | 46 | public ZoneTopologyListener(String servicePath, SonosDevice device) { 47 | //super(servicePath); 48 | this.device = device; 49 | this.listeners = device.getEventListener(); 50 | } 51 | 52 | @Override 53 | public void initialEventReceived(UPnPEvent event) { 54 | presentDevices.addAll(parseConnectedDeviceNames(event)); 55 | } 56 | 57 | @Override 58 | public void eventReceived(UPnPEvent event) { 59 | 60 | HashSet currentConnectedDevices = parseConnectedDeviceNames(event); 61 | 62 | currentConnectedDevices.stream().forEach(deviceName -> { 63 | if (!presentDevices.contains(deviceName)) { 64 | for (SonosEventListener listener : listeners) { 65 | listener.sonosDeviceConnected(deviceName); 66 | } 67 | } 68 | }); 69 | 70 | if (presentDevices.removeAll(currentConnectedDevices)) { 71 | presentDevices.stream().forEach(deviceName -> { 72 | for (SonosEventListener listener : listeners) { 73 | listener.sonosDeviceDisconnected(deviceName); 74 | } 75 | }); 76 | } 77 | presentDevices = currentConnectedDevices; 78 | 79 | //We could also emit events when devices get grouped 80 | } 81 | 82 | private HashSet parseConnectedDeviceNames(UPnPEvent event) { 83 | String ownName = device.getDeviceNameCached(); 84 | HashSet devices = new HashSet(); 85 | // Are we interested about the household ids? 86 | for (Element property : event.getProperties()) { 87 | 88 | if (property.getName().equals("ZoneGroupState")) { 89 | List zoneGroups = property.getChild("ZoneGroups").getChildren("ZoneGroup"); 90 | 91 | 92 | for (Element zoneGroup : zoneGroups) { 93 | ArrayList allDevicesInZone = new ArrayList(); 94 | for (Element device : zoneGroup.getChildren("ZoneGroupMember")) { 95 | String deviceName = device.getAttributeValue("ZoneName"); 96 | allDevicesInZone.add(deviceName); 97 | } 98 | if(allDevicesInZone.contains(ownName)) { 99 | //Check if we have a group change event 100 | if(!groupState.isEmpty()) { 101 | 102 | if( !groupState.containsAll(allDevicesInZone) || groupState.size() != allDevicesInZone.size()) { 103 | groupState.clear(); 104 | for (SonosEventListener listener : listeners) { 105 | listener.groupChanged(allDevicesInZone); 106 | } 107 | } 108 | } 109 | groupState.addAll(allDevicesInZone); 110 | 111 | } 112 | devices.addAll(allDevicesInZone); 113 | } 114 | } 115 | } 116 | return devices; 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/voiceToTextPlayback/NetworkFileProvider.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.voiceToTextPlayback; 2 | 3 | import java.io.File; 4 | import java.util.HashSet; 5 | import java.util.logging.Level; 6 | import java.util.logging.Logger; 7 | 8 | import io.undertow.Undertow; 9 | import io.undertow.server.handlers.PathHandler; 10 | import io.undertow.server.handlers.resource.FileResourceManager; 11 | import io.undertow.server.handlers.resource.ResourceHandler; 12 | 13 | 14 | /** 15 | * Start a file server which maps a local directory to a fixed ip address allowing network devices 16 | * to query data from this place. 17 | * @author Kilian 18 | * 19 | */ 20 | public class NetworkFileProvider { 21 | 22 | //Settings 23 | private String host; 24 | private int port; 25 | private String mapPrefix; 26 | 27 | //Internal 28 | private final Undertow server; 29 | private final PathHandler pathHandler = new PathHandler(); 30 | private HashSet mappedFolders = new HashSet<>(); 31 | 32 | 33 | public NetworkFileProvider(String host, int port, String[] allowedFileExtensions){ 34 | 35 | this.host = host; 36 | this.port = port; 37 | this.mapPrefix = host+":"+port + "/"; 38 | 39 | //TODO set safe path so the user can not navigate wherever he likes 40 | 41 | //FileResourceManager sharedFolderRessourceManager = new FileResourceManager(globalSharedFolder, 0); 42 | 43 | 44 | //final ResourceHandler textToSpeechRessourceHandler = new ResourceHandler(textToSpeechRessourceManager); 45 | 46 | //TODO MIME Mapping 47 | 48 | 49 | server = Undertow.builder().addHttpListener(port, host, pathHandler).build(); 50 | 51 | 52 | //Undertow wraps it's exception in a runtime exception. 53 | try { 54 | server.start(); 55 | }catch(RuntimeException e) { 56 | Throwable t = e.getCause(); 57 | if(t instanceof java.net.BindException) { 58 | LOGGER.severe("Port already bound by another program. Please check if the smart server is running " 59 | + " twice or swap to a free port."); 60 | }else { 61 | LOGGER.severe(t.toString()); 62 | } 63 | } 64 | } 65 | 66 | 67 | /** 68 | * Maps the given folder to host:port/folderPath allowing network access by quering this address 69 | * @param directory 70 | * @return true if folder was successfully mapped. False otherwise 71 | */ 72 | public synchronized boolean mapFolder(File directory) { 73 | 74 | if(directory.isDirectory()) { 75 | 76 | //Escape windows path 77 | String prefixPath = directory.getAbsolutePath().replace("\\", "/"); 78 | 79 | if(mappedFolders.contains(prefixPath)) { 80 | LOGGER.warning("Folder " + prefixPath + " already mapped. Skip request"); 81 | return false; 82 | } 83 | 84 | FileResourceManager sharedFolderRessourceManager = new FileResourceManager(directory, 0); 85 | 86 | ResourceHandler sharedRessourceManager = new ResourceHandler(sharedFolderRessourceManager); 87 | sharedRessourceManager.setDirectoryListingEnabled(true); 88 | 89 | 90 | pathHandler.addPrefixPath("/"+prefixPath, sharedRessourceManager); 91 | 92 | mappedFolders.add(prefixPath); 93 | LOGGER.log(Level.INFO,"map "+directory.getAbsolutePath()+" to " + mapPrefix + prefixPath); 94 | }else { 95 | throw new IllegalArgumentException("Please provide a folder and not a file"); 96 | } 97 | 98 | return true; 99 | } 100 | 101 | 102 | public String toMappedPath(String originalLocation) { 103 | return mapPrefix + originalLocation.replace("\\","/"); 104 | } 105 | 106 | public String toUnmappedPath(String mappedLocation) { 107 | if(mappedLocation.startsWith(mapPrefix)) { 108 | return mappedLocation.substring(mapPrefix.length()); 109 | } 110 | return mappedLocation; 111 | } 112 | 113 | public void deinit(){ 114 | server.stop(); 115 | } 116 | 117 | 118 | private static final Logger LOGGER = Logger.getLogger(NetworkFileProvider.class.getName()); 119 | 120 | 121 | /** 122 | * @return 123 | */ 124 | public String getMapPrefix() { 125 | return mapPrefix; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/model/TrackMetadata.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.model; 2 | 3 | import com.github.kilianB.sonos.ParserHelper; 4 | 5 | public class TrackMetadata { 6 | private final String title; 7 | private final String creator; 8 | private final String albumArtist; 9 | private final String album; 10 | private final String albumArtURI; 11 | 12 | public TrackMetadata(String title, String creator, String albumArtist, String album, String albumArtURI) { 13 | this.title = title; 14 | this.creator = creator; 15 | this.albumArtist = albumArtist; 16 | this.album = album; 17 | this.albumArtURI = albumArtURI; 18 | } 19 | 20 | public static TrackMetadata parse(String metadata) { 21 | return new TrackMetadata( 22 | ParserHelper.findOne("(.*)", metadata), 23 | ParserHelper.findOne("(.*)", metadata), 24 | ParserHelper.findOne("(.*)", metadata), 25 | ParserHelper.findOne("(.*)", metadata), 26 | ParserHelper.findOne("(.*)", metadata) 27 | ); 28 | } 29 | 30 | public String getTitle() { 31 | return title; 32 | } 33 | 34 | public String getCreator() { 35 | return creator; 36 | } 37 | 38 | public String getAlbumArtist() { 39 | return albumArtist; 40 | } 41 | 42 | public String getAlbum() { 43 | return album; 44 | } 45 | 46 | public String getAlbumArtURI() { 47 | return albumArtURI; 48 | } 49 | 50 | @Override 51 | public String toString() { 52 | return "TrackMetadata{" + 53 | "title='" + title + '\'' + 54 | ", creator='" + creator + '\'' + 55 | ", albumArtist='" + albumArtist + '\'' + 56 | ", album='" + album + '\'' + 57 | ", albumArtURI='" + albumArtURI + '\'' + 58 | '}'; 59 | } 60 | 61 | public String toDIDL() { 62 | return "" + 63 | "" + 64 | "" + title + "" + 65 | "" + creator + "" + 66 | "" + albumArtist + "" + 67 | "" + album + "" + 68 | "" + albumArtURI + "" + 69 | "" + 70 | ""; 71 | } 72 | 73 | @Override 74 | public int hashCode() { 75 | final int prime = 31; 76 | int result = 1; 77 | result = prime * result + ((album == null) ? 0 : album.hashCode()); 78 | result = prime * result + ((albumArtURI == null) ? 0 : albumArtURI.hashCode()); 79 | result = prime * result + ((albumArtist == null) ? 0 : albumArtist.hashCode()); 80 | result = prime * result + ((creator == null) ? 0 : creator.hashCode()); 81 | result = prime * result + ((title == null) ? 0 : title.hashCode()); 82 | return result; 83 | } 84 | 85 | @Override 86 | public boolean equals(Object obj) { 87 | if (this == obj) 88 | return true; 89 | if (obj == null) 90 | return false; 91 | if (getClass() != obj.getClass()) 92 | return false; 93 | TrackMetadata other = (TrackMetadata) obj; 94 | if (album == null) { 95 | if (other.album != null) 96 | return false; 97 | } else if (!album.equals(other.album)) 98 | return false; 99 | if (albumArtURI == null) { 100 | if (other.albumArtURI != null) 101 | return false; 102 | } else if (!albumArtURI.equals(other.albumArtURI)) 103 | return false; 104 | if (albumArtist == null) { 105 | if (other.albumArtist != null) 106 | return false; 107 | } else if (!albumArtist.equals(other.albumArtist)) 108 | return false; 109 | if (creator == null) { 110 | if (other.creator != null) 111 | return false; 112 | } else if (!creator.equals(other.creator)) 113 | return false; 114 | if (title == null) { 115 | if (other.title != null) 116 | return false; 117 | } else if (!title.equals(other.title)) 118 | return false; 119 | return true; 120 | } 121 | 122 | 123 | } 124 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/uPnPClient/UPnPEvent.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.uPnPClient; 2 | 3 | import java.util.Collections; 4 | import java.util.HashSet; 5 | import java.util.List; 6 | import java.util.Set; 7 | 8 | import org.jdom2.Document; 9 | import org.jdom2.Element; 10 | import org.jdom2.Namespace; 11 | import org.jdom2.output.Format; 12 | import org.jdom2.output.XMLOutputter; 13 | 14 | /** 15 | * A UPnP 1.1 unicast event emitted by devices after subscribing to it's services. 16 | * 17 | * @author Kilian 18 | * @see UPnP-arch-DeviceArchitecture-v1.1 19 | * 20 | */ 21 | public class UPnPEvent{ 22 | 23 | private static final Namespace upnpNamespace = Namespace.getNamespace("e","urn:schemas-upnp-org:event-1-0"); 24 | 25 | /** 26 | * HTTP header of the event 27 | * 200 OK 28 | */ 29 | private String httpHeader; 30 | /** 31 | * IP Address of the device 32 | */ 33 | private String host; 34 | /** 35 | * HTTP 1.1 header connection:close 36 | */ 37 | private String connection; 38 | /** 39 | * length of the payload in bytes 40 | */ 41 | private int contentLength; 42 | /** 43 | * Notification type has to be upnp:event 44 | */ 45 | private String nt; 46 | /** 47 | * Notification sub type has to be upnp:propchange 48 | */ 49 | private String nts; 50 | /** 51 | * subscription identifier. allows mapping of events to the subscription requests 52 | */ 53 | private String sid; 54 | /** 55 | * sequence identifier. incremental to order events 56 | */ 57 | private int seq; 58 | 59 | /** 60 | * Properties represent the values submitted during a upnp event 61 | */ 62 | private HashSet properties = new HashSet(); 63 | 64 | 65 | //XML 66 | /** 67 | * Raw xml body of the event 68 | */ 69 | private Document body; 70 | 71 | /** 72 | * A UPnP 1.1 event 73 | * @param httpHeader http response code of the event (200 OK) 74 | * @param host domain name or IP address and optional port components of delivery URL 75 | * @param connection HTTP 1.1 connection header 76 | * @param contentLength package content in bytes 77 | * @param nt Notification type has to be upnp:event 78 | * @param nts Notification sub type has to be upnp:propchange 79 | * @param sid subscription identifier 80 | * @param seq sequence identifier 81 | * @param body body of the upnp event 82 | * @see UPnP-arch-DeviceArchitecture-v1.1 83 | */ 84 | public UPnPEvent(String httpHeader, String host, String connection, int contentLength, String nt, 85 | String nts, String sid, int seq, Document body) { 86 | this.httpHeader = httpHeader; 87 | this.host = host; 88 | this.connection = connection; 89 | this.contentLength = contentLength; 90 | this.nt = nt; 91 | this.nts = nts; 92 | this.sid = sid; 93 | this.seq = seq; 94 | this.body = body; 95 | 96 | List propertyElements = body.getRootElement().getChildren("property", upnpNamespace); 97 | 98 | for(Element property : propertyElements) { 99 | properties.addAll(property.getChildren()); 100 | } 101 | } 102 | 103 | 104 | public String getHttpHeader() { 105 | return httpHeader; 106 | } 107 | 108 | public String getHost() { 109 | return host; 110 | } 111 | 112 | public String getConnection() { 113 | return connection; 114 | } 115 | 116 | public int getContentLength() { 117 | return contentLength; 118 | } 119 | 120 | public String getNt() { 121 | return nt; 122 | } 123 | 124 | public String getNts() { 125 | return nts; 126 | } 127 | 128 | public String getSid() { 129 | return sid; 130 | } 131 | 132 | public int getSeq() { 133 | return seq; 134 | } 135 | 136 | public Document getBody() { 137 | return body; 138 | } 139 | 140 | /** 141 | * @return The body of the upnp event as well formated and idented xml 142 | */ 143 | public String getBodyAsString() { 144 | XMLOutputter xmlOut = new XMLOutputter(); 145 | xmlOut.setFormat(Format.getPrettyFormat()); 146 | return xmlOut.outputString(body); 147 | } 148 | 149 | @Override 150 | public String toString() { 151 | return "UPnPEvent [httpHeader=" + httpHeader + ", host=" + host + ", connection=" + connection 152 | + ", contentLength=" + contentLength + ", nt=" + nt + ", nts=" + nts + ", sid=" + sid + ", seq=" + seq 153 | + ", body=" + body + "]"; 154 | } 155 | 156 | /** 157 | * Get the properties of the upnp event. 158 | * Properties represent the payload of an event used to transmit information. 159 | * @return the properties of the xml body of the event. 160 | */ 161 | public Set getProperties (){ 162 | return Collections.unmodifiableSet(properties); 163 | } 164 | 165 | } 166 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/DemoPlayer.fxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |

32 | 33 | 34 | 35 | 36 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 71 | 72 |
81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [ ![Download](https://api.bintray.com/packages/kilianb/maven/Java-Sonos-Controller/images/download.svg) ](https://bintray.com/kilianb/maven/Java-Sonos-Controller/_latestVersion) 2 | 3 | # Sonos-controller 4 | Java API for controlling [SONOS](http://www.sonos.com/) players. 5 | 6 |

7 | 8 |

9 | 10 | 11 | ## Available via Bintray and JCenter 12 | 13 | Starting with version 2.0.0 at least Java 10 is required. 14 | 15 | ``` 16 | 17 | 18 | jcenter 19 | https://jcenter.bintray.com/ 20 | 21 | 22 | 23 | 24 | github.com.kilianB 25 | sonos-controller 26 | 2.0.0 27 | 28 | ``` 29 | 30 | ## Basic Usage 31 | 32 | Discovery all Sonos Devices on your network. 33 | 34 | ```java 35 | List devices = SonosDiscovery.discover(); 36 | 37 | //Asynchronous 38 | SonosDiscovery.discoverAsynch(3, device ->{ 39 | System.out.println("Device: " + device + " found"); 40 | }); 41 | 42 | ``` 43 | 44 | Connect to a known Sonos and pause currently playing music. 45 | 46 | ```java 47 | SonosDevice sonos = new SonosDevice("10.0.0.102"); 48 | sonos.pause(); 49 | ``` 50 | 51 | ## UPnP Event Handling 52 | 53 | Register event handlers to gain immediate access to update events 54 | 55 | ```java 56 | sonos.registerSonosEventListener(new SonosEventAdapter() { 57 | 58 | @Override 59 | public void volumeChanged(int newVolume) { 60 | System.out.println("Volume changed: " + newVolume); 61 | } 62 | 63 | @Override 64 | public void playStateChanged(PlayState newPlayState) { 65 | System.out.println("Playstate changed: " + newPlayState); 66 | } 67 | 68 | @Override 69 | public void trackChanged(TrackInfo currentTrack) { 70 | System.out.println("Track changed: " + currentTrack); 71 | } 72 | } 73 | ``` 74 | 75 | Gain full access by utilizing the entire range of callback methods found in the [SonosEventListener.java](https://github.com/KilianB/Java-Sonos-Controller/blob/master/src/main/java/com/github/kilianB/sonos/listener/SonosEventListener.java). 76 | 77 | 78 | ## More examples 79 | 80 | ### 1. Text to speech playback on any sonos speakers 81 | 82 | A small example utilizing my prototyping text to speech library. 83 | The generated mp3 file is hosted on the current machine to make it accessible to the sonos speakers on the network. 84 | 85 | Source 86 | 87 |

88 | 89 |

90 | 91 | ### 2. Simple Sonos Desktop Player With Local File Playback 92 | 93 | A basic player allowing to playback local music files. Index any folder on your computer, create a track index in a SQL 94 | table and make the folders accessible to the network. While you are able to start stop, playback change volume etc, 95 | this is just intended to lay out the steps needed to implement local music file playback and not function as a standalone application. 96 | 97 |
    98 |
  1. Directory Crawler & File Indexer
  2. 99 |
  3. SQL Database
  4. 100 |
  5. File Hosting
  6. 101 |
102 | 103 | ![sonosspeaker](https://user-images.githubusercontent.com/9025925/46569592-b8871b80-c957-11e8-9095-d4310b4c977b.jpg) 104 | 105 | 106 | 107 | ## Original contribution 108 | 109 | A fork of the tremendous sonos controller library originally created by Valentin Michalak. 110 | 111 | Based upon this changes include: 112 |
    113 |
  1. Implementing UPnP event subscriptions
  2. 114 |
  3. Swapping out gradle
  4. 115 |
  5. License change to GPLv3
  6. 116 |
117 | 118 | This repository allows to subscribe to the UPnP 1.1 Event endpoints enabling to receive continious updates of the devices state. I decided to fork the project instead of issuing a pull request due to the need of it being hosted via maven central within a (short) period of time. A huge portion of the code was being rewritten resulting in breaking changes and no backward compatibility If you look for a MIT version or high test coverage either contact me or take a look at the original repository. 119 | 120 | ## Up next 121 | 122 | Investigate the UPnP event endpoints. 123 | 124 |
    125 |
  • /MediaServer/ConnectionManager/Event
  • 126 |
  • /MediaRenderer/ConnectionManager/Event
  • 127 |
  • /MediaServer/ContentDirectory/Event
  • 128 |
  • /AlarmClock/Event
  • 129 |
  • /MusicServices/Event
  • 130 |
  • /SystemProperties/Event
  • 131 |
132 | 133 | 134 | How are topology changes best are tracked? (New device found / device disconnected) 135 | Currently the library utilizes the `/ZoneGroupTopology/Event` as does the official client. 136 | But updates are delayed as much as two minutes. 137 | A different approach would be to either: 138 | 139 |
    140 |
  • Timeout if no upnp advertisement is send within a certain timeframe?
  • 141 |
  • Parse topology page of a sonos controler e.g. http://192.168.178.26:1400/status/topology - polling ....
  • 142 |
143 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/util/StringUtil.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer.util; 2 | 3 | import java.io.File; 4 | 5 | import com.google.common.net.UrlEscapers; 6 | 7 | public class StringUtil { 8 | 9 | private static final char EXTENSION_SEPARATOR = '.'; 10 | private static final char UNIX_SEPARATOR = '/'; 11 | private static final char WINDOWS_SEPARATOR = '\\'; 12 | 13 | 14 | /** 15 | * Appache commons text filename utils. 16 | * @param f 17 | * @return 18 | */ 19 | public static String getFileExtension(File f) { 20 | 21 | String fileName = f.getAbsolutePath(); 22 | 23 | if (fileName == null) 24 | return null; 25 | 26 | int lastUnixPos = fileName.lastIndexOf(UNIX_SEPARATOR); 27 | int lastWindowsPos = fileName.lastIndexOf(WINDOWS_SEPARATOR); 28 | int lastSeperatorIndex = Math.max(lastUnixPos, lastWindowsPos); 29 | int extensionPos = fileName.lastIndexOf(EXTENSION_SEPARATOR); 30 | int indexOfExtension = (lastSeperatorIndex > extensionPos ? -1 : extensionPos); 31 | 32 | if (indexOfExtension == -1) { 33 | return ""; 34 | } else { 35 | return fileName.substring(indexOfExtension + 1); 36 | } 37 | } 38 | 39 | /** 40 | * Convert camel case to space separated individual words. 41 | * 42 | * @param input "ThisIsATestString" 43 | * @return output "This Is A Test String" 44 | */ 45 | public static String addSpacesBetweenCapitalLetters(String input) { 46 | return input.replaceAll("(?<=\\p{L})(?=\\p{Lu})", " "); 47 | } 48 | 49 | 50 | 51 | /** 52 | * Replaces all line breaks in a string by the given replacement. And empty string will simply return breaks. 53 | * @param input 54 | * @param replace 55 | * @return 56 | */ 57 | public static String replaceLineBreaks(String input, String replace) { 58 | return input.replaceAll("\\r\\n|\\r|\\n", replace); 59 | } 60 | 61 | public static String enumToLowercase(Enum e) { 62 | return e.name().toLowerCase(); 63 | } 64 | 65 | /** 66 | * Expected input default CAPITALIZED_WITH_UNDERSCORES naming convention 67 | * 68 | * Valid enum names: 69 | *
    70 | *
  • ENUMNAME
  • 71 | *
  • ENUM_NAME
  • 72 | *
  • ENUM_.._NAME
  • 73 | *
74 | * 75 | * 76 | * @param e 77 | * @return name formated in lower camel case notation 78 | */ 79 | public static String enumNameToLowerCamelCase(Enum e){ 80 | //TODO fancy regex 81 | String[] exploded = e.name().toLowerCase().split("_"); 82 | String returnValue = exploded[0]; 83 | for(int i = 1; i < exploded.length;i++){ 84 | returnValue += Character.toUpperCase(exploded[i].charAt(0)) + exploded[i].substring(1); 85 | } 86 | return returnValue; 87 | } 88 | 89 | /** 90 | * Return a new string which consists out of count times the supplied string appended 91 | * together. 92 | * @param s 93 | * @param count 94 | * @return 95 | */ 96 | public static String constructRepetativeString(String s, int count) { 97 | //s.length()*count 98 | StringBuilder sb = new StringBuilder(); 99 | for(int i = 0; i < count; i++) { 100 | sb.append(s); 101 | } 102 | return sb.toString(); 103 | } 104 | 105 | 106 | public static String decodeURLToURI(String url) { 107 | //backward slashes will get escaped therefore change it 108 | url = url.replace("\\", "/"); 109 | return UrlEscapers.urlFragmentEscaper().escape(url); 110 | } 111 | 112 | 113 | 114 | 115 | private final static long MS_IN_SEC = 1000; 116 | private final static long MS_IN_MIN = MS_IN_SEC * 60; 117 | private final static long MS_IN_HOUR = MS_IN_MIN * 60; 118 | private final static long MS_IN_DAY = MS_IN_HOUR * 24; 119 | 120 | /** 121 | * Return a formated string representation of the time until the given moment in time. 122 | * 123 | * 124 | * 125 | * @param milis 126 | * @return 127 | */ 128 | public static String milisToTimeFormated(long milis) { 129 | 130 | int days = (int) (milis / MS_IN_DAY); 131 | int hours = (int) (milis / MS_IN_HOUR % 24); 132 | int minutes = (int) (milis / MS_IN_MIN % 60); 133 | int seconds = (int) (milis / MS_IN_SEC % 60); 134 | 135 | StringBuilder sb = new StringBuilder(); 136 | boolean set = false; 137 | if(days > 0) { 138 | sb.append(days); 139 | sb.append(" d "); 140 | set = true; 141 | } 142 | if(hours > 0 || set) { 143 | if(set) { 144 | sb.append(String.format(":%02d", hours)); 145 | }else { 146 | sb.append(String.format("%02d", hours)); 147 | } 148 | set = true; 149 | } 150 | if(minutes > 0 || set ) { 151 | if(set) { 152 | sb.append(String.format(":%02d", minutes)); 153 | }else { 154 | sb.append(String.format("%02d", minutes)); 155 | } 156 | set = true; 157 | } 158 | if(seconds > 0 ||set ) { 159 | if(set) { 160 | sb.append(String.format(":%02d", seconds)); 161 | }else { 162 | sb.append(String.format("%02d", seconds)); 163 | } 164 | set = true; 165 | } 166 | return sb.toString(); 167 | } 168 | 169 | public static String milisToTimeFormatedVerbose(long milis) { 170 | 171 | int days = (int) (milis / MS_IN_DAY); 172 | int hours = (int) (milis / MS_IN_HOUR % 24); 173 | int minutes = (int) (milis / MS_IN_MIN % 60); 174 | int seconds = (int) (milis / MS_IN_SEC % 60); 175 | 176 | StringBuilder sb = new StringBuilder(); 177 | boolean set = false; 178 | if(days > 0) { 179 | sb.append(days); 180 | sb.append(" days "); 181 | set = true; 182 | } 183 | if(hours > 0 || set) { 184 | sb.append(hours); 185 | sb.append(" hrs "); 186 | set = true; 187 | } 188 | if(minutes > 0 || set) { 189 | sb.append(minutes); 190 | sb.append(" min "); 191 | set = true; 192 | } 193 | if(seconds > 0 || set) { 194 | sb.append(seconds); 195 | sb.append(" sec"); 196 | set = true; 197 | } 198 | return sb.toString(); 199 | } 200 | 201 | } 202 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | github.com.kilianB 7 | sonos-controller 8 | 2.0.1 9 | 10 | A java library allowing to control sonos speakers via UPnP. 11 | https://github.com/KilianB/sonosControllerPrivate 12 | ${project.groupId}:${project.artifactId} 13 | 14 | 15 | maven 16 | Java-Sonos-Controller 17 | UTF-8 18 | 19 | 20 | 21 | 22 | GPL-3.0 23 | https://www.gnu.org/licenses/gpl-3.0.en.html 24 | 25 | 26 | 27 | 28 | 29 | Kilian Brachtendorf 30 | Kilian.Brachtendorf@t-online.de 31 | https://github.com/KilianB 32 | 33 | Developer 34 | 35 | 36 | 37 | Valentin Michalak 38 | https://github.com/vmichalak 39 | 40 | Original Release 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | maven-compiler-plugin 50 | 3.7.0 51 | 52 | 10 53 | 10 54 | 59 | 60 | 61 | 62 | org.apache.maven.plugins 63 | maven-surefire-plugin 64 | 2.21.0 65 | 66 | 67 | org.junit.platform 68 | junit-platform-surefire-provider 69 | 1.2.0-M1 70 | 71 | 72 | org.junit.jupiter 73 | junit-jupiter-engine 74 | 5.2.0-M1 75 | 76 | 77 | 78 | 79 | maven-source-plugin 80 | 81 | *.example 82 | 83 | 84 | 85 | attach-sources 86 | 87 | jar 88 | 89 | 90 | 91 | 92 | 93 | maven-javadoc-plugin 94 | 95 | *.example 96 | 97 | 98 | 99 | attach-javadocs 100 | 101 | jar 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | jcenter 112 | https://jcenter.bintray.com/ 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | org.apache.commons 122 | commons-text 123 | 1.3 124 | 125 | 126 | com.squareup.okhttp3 127 | okhttp 128 | 3.10.0 129 | 130 | 131 | org.jdom 132 | jdom2 133 | 2.0.6 134 | 135 | 136 | 137 | 138 | 139 | com.jfoenix 140 | jfoenix 141 | 9.0.4 142 | 143 | 144 | 145 | net.jthink 146 | jaudiotagger 147 | 2.2.6-PATHRIK 148 | 149 | 150 | 151 | io.undertow 152 | undertow-core 153 | 1.4.17 154 | 155 | 156 | 157 | com.google.guava 158 | guava 159 | 23.5-jre 160 | 161 | 162 | 163 | com.h2database 164 | h2 165 | 1.4.197 166 | 167 | 168 | 169 | 170 | 171 | 173 | 174 | github.com.kilianB 175 | GoogleTranslatorTTS 176 | 1.0.1 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | bintray-kilianb-maven 186 | kilianb-maven 187 | https://api.bintray.com/maven/kilianb/${bintrayRepository}//${bintrayPackage}/ 188 | 189 | 190 | 191 | 192 | 193 | scm:git:git://github.com/KilianB/Java-Sonos-Controller.git 194 | scm:git:ssh://github.com/KilianB/Java-Sonos-Controller.git 195 | https://github.com/KilianB/Java-Sonos-Controller/tree/master 196 | 197 | 198 | 199 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/SonosDiscovery.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos; 2 | 3 | import com.github.kilianB.exception.SonosControllerException; 4 | import com.github.kilianB.uPnPClient.SimpleDeviceDiscovery; 5 | import com.github.kilianB.uPnPClient.UPnPDevice; 6 | 7 | import java.io.IOException; 8 | import java.util.ArrayList; 9 | import java.util.Collections; 10 | import java.util.List; 11 | 12 | /** 13 | * Simple Device Discovery Protocol (SSDP) for sons speakers 14 | * 15 | * @author Kilian 16 | * @author vmichalak 17 | */ 18 | public class SonosDiscovery { 19 | 20 | /** 21 | * Scan duration in seconds 22 | */ 23 | private static final int DEFAULT_SCAN_DURATION = 2; 24 | private static final String SONOS_URN = "urn:schemas-upnp-org:device:ZonePlayer:1"; 25 | 26 | // Hide the implicit public constructor. 27 | private SonosDiscovery() { 28 | } 29 | 30 | /** 31 | * Discover all SONOS speakers on network using SSDP (Simple Service Discovery 32 | * Protocol). 33 | * 34 | * @return List of SONOS speakers 35 | * @throws IOException network exception during 36 | */ 37 | public static List discover() throws IOException { 38 | return discover(DEFAULT_SCAN_DURATION); 39 | } 40 | 41 | /** 42 | * Discover all SONOS speakers on network using SSDP (Simple Service Discovery 43 | * Protocol). 44 | * 45 | * @param scanDuration The number of seconds to wait while scanning for devices. 46 | * @return List of SONOS speakers 47 | * @throws IOException network exception during device discovery 48 | */ 49 | public static List discover(int scanDuration) throws IOException { 50 | List source = SimpleDeviceDiscovery.discoverDevices(1, scanDuration, SONOS_URN); 51 | ArrayList output = new ArrayList(); 52 | for (UPnPDevice device : source) { 53 | output.add(new SonosDevice(device)); 54 | } 55 | return Collections.unmodifiableList(output); 56 | } 57 | 58 | /** 59 | * Discover all SONOS speakers on network using SSDP (Simple Service Discovery 60 | * Protocol) in an asynch manner. This method does not throw an IO Error!. 61 | * 62 | * @param scanDuration The number of seconds to wait while scanning for devices. 63 | * @param callback listener to be notified about found devices 64 | */ 65 | public static void discoverAsynch(int scanDuration, SonosDeviceFoundListener callback){ 66 | new Thread(()-> { 67 | ArrayList output = new ArrayList(); 68 | try { 69 | SimpleDeviceDiscovery.discoverDevices(1, scanDuration, SONOS_URN, (upnpDevice) -> { 70 | SonosDevice sonosDevice = new SonosDevice(upnpDevice); 71 | output.add(sonosDevice); 72 | callback.deviceFound(sonosDevice); 73 | }); 74 | } catch (IOException e) { 75 | e.printStackTrace(); 76 | } 77 | }).start(); 78 | } 79 | 80 | /** 81 | * Discover one SONOS speakers on network using SSDP (Simple Service Discovery 82 | * Protocol). 83 | * 84 | * @return SONOS speaker 85 | * @throws IOException network exception during device discovery 86 | */ 87 | public static SonosDevice discoverOne() throws IOException { 88 | return discoverOne(DEFAULT_SCAN_DURATION); 89 | } 90 | 91 | /** 92 | * Discover one SONOS speakers on network using SSDP (Simple Service Discovery 93 | * Protocol). 94 | * 95 | * @param scanDuration The number of seconds to wait while scanning for devices. 96 | * @return SONOS speaker 97 | * @throws IOException network exception during device discovery 98 | */ 99 | public static SonosDevice discoverOne(int scanDuration) throws IOException { 100 | UPnPDevice source = new SimpleDeviceDiscovery().discoverDevice(scanDuration, SONOS_URN); 101 | if (source == null) { 102 | return null; 103 | } 104 | return new SonosDevice(source); 105 | } 106 | 107 | /** 108 | * Discover one SONOS speakers on network using SSDP (Simple Service Discovery 109 | * Protocol) by UID. 110 | * 111 | * @param uid Sonos Speaker UID 112 | * @return SONOS speaker 113 | * @throws IOException network exception during device discovery 114 | */ 115 | public static SonosDevice discoverByUID(String uid) throws IOException { 116 | return discoverByUID(uid, DEFAULT_SCAN_DURATION); 117 | } 118 | 119 | /** 120 | * Discover one SONOS speakers on network using SSDP (Simple Service Discovery 121 | * Protocol) by UID. 122 | * 123 | * @param uid Sonos Speaker UID 124 | * @param scanDuration The number of seconds to wait while scanning for devices. 125 | * @return SONOS speaker 126 | * @throws IOException network exception during device discovery 127 | */ 128 | public static SonosDevice discoverByUID(String uid, int scanDuration) throws IOException { 129 | UPnPDevice source = new SimpleDeviceDiscovery().discoverDevice(scanDuration, "uuid:" + uid); 130 | if (source == null) { 131 | return null; 132 | } 133 | return new SonosDevice(source); 134 | } 135 | 136 | /** 137 | * Discover one SONOS speakers on network using SSDP (Simple Service Discovery 138 | * Protocol) by name. 139 | * 140 | * @param name Sonos Speaker name. 141 | * @return Sonos speaker (or null if no speaker was found) 142 | * @throws IOException network exception during device discovery 143 | */ 144 | public static SonosDevice discoverByName(String name) throws IOException { 145 | return discoverByName(name, DEFAULT_SCAN_DURATION); 146 | } 147 | 148 | /** 149 | * Discover one SONOS speakers on network using SSDP (Simple Service Discovery 150 | * Protocol) by name. 151 | * 152 | * @param name Sonos Speaker name. 153 | * @param scanDuration The number of milliseconds to wait while scanning for 154 | * devices. 155 | * @return Sonos speaker (or null if no speaker was found) 156 | * @throws IOException network exception during device discovery 157 | */ 158 | public static SonosDevice discoverByName(String name, int scanDuration) throws IOException { 159 | List sonosDevices = SonosDiscovery.discover(scanDuration); 160 | for (SonosDevice sonosDevice : sonosDevices) { 161 | try { 162 | if (sonosDevice.getZoneName().equalsIgnoreCase(name)) { 163 | return sonosDevice; 164 | } 165 | } catch (SonosControllerException e) { 166 | /* ignored */ } 167 | } 168 | return null; 169 | } 170 | 171 | @FunctionalInterface 172 | public interface SonosDeviceFoundListener { 173 | void deviceFound(SonosDevice device); 174 | } 175 | } -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/listener/AVTTransportListener.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.listener; 2 | 3 | import java.util.List; 4 | 5 | import org.apache.commons.text.StringEscapeUtils; 6 | import org.jdom2.Element; 7 | import org.jdom2.Namespace; 8 | 9 | import com.github.kilianB.sonos.ParserHelper; 10 | import com.github.kilianB.sonos.SonosDevice; 11 | import com.github.kilianB.sonos.model.AVTransportEvent; 12 | import com.github.kilianB.sonos.model.PlayMode; 13 | import com.github.kilianB.sonos.model.PlayState; 14 | import com.github.kilianB.sonos.model.TrackInfo; 15 | import com.github.kilianB.sonos.model.TrackMetadata; 16 | import com.github.kilianB.uPnPClient.UPnPEvent; 17 | import com.github.kilianB.uPnPClient.UPnPEventAdapter; 18 | import com.github.kilianB.uPnPClient.UPnPEventAdapterVerbose; 19 | 20 | /** 21 | * Event listener used to parse UPnPEvents received from the AVTTransport service relating to transport 22 | * management, eg play, stop, seek, playlists etc 23 | * @author Kilian 24 | * 25 | */ 26 | public class AVTTransportListener extends UPnPEventAdapter { 27 | 28 | private static final Namespace upnpAVTNamespace = Namespace.getNamespace("urn:schemas-upnp-org:metadata-1-0/AVT/"); 29 | private static final Namespace upnpRinnconnectsNamespace = Namespace.getNamespace("r","urn:schemas-rinconnetworks-com:metadata-1-0/"); 30 | 31 | /** 32 | * Event listeners to be notified in case of noteworthy events 33 | */ 34 | private final List listeners; 35 | 36 | //Keep an internal state so we can notify listeners in case of changes 37 | private TrackInfo currentTrack; 38 | private PlayMode currentPlayMode; 39 | private PlayState currentPlayState; 40 | 41 | public AVTTransportListener(String servicePath, SonosDevice device) { 42 | //super(servicePath); 43 | this.listeners = device.getEventListener(); 44 | } 45 | 46 | @Override 47 | public void initialEventReceived(UPnPEvent event) { 48 | 49 | //The initial event contains ore information than follow up events. but just extract important bits 50 | for (Element e : event.getProperties()) { 51 | AVTransportEvent avtEvent = parseEvent(e); 52 | currentTrack = avtEvent.getCurrentTrack(); 53 | currentPlayMode = avtEvent.getCurrentPlayMode(); 54 | currentPlayState = avtEvent.getTransportState(); 55 | return; 56 | } 57 | 58 | } 59 | 60 | @Override 61 | public void eventReceived(UPnPEvent event) { 62 | 63 | for (Element e : event.getProperties()) { 64 | 65 | AVTransportEvent avtEvent = parseEvent(e); 66 | 67 | // This will always be true since we are creating 68 | if (!currentTrack.sameBaseTrack(avtEvent.getCurrentTrack())) { 69 | currentTrack = avtEvent.getCurrentTrack(); 70 | for (SonosEventListener listener : listeners) { 71 | listener.trackChanged(avtEvent.getCurrentTrack()); 72 | } 73 | } 74 | 75 | if (!currentPlayMode.equals(avtEvent.getCurrentPlayMode())) { 76 | currentPlayMode = avtEvent.getCurrentPlayMode(); 77 | for (SonosEventListener listener : listeners) { 78 | listener.playModeChanged(avtEvent.getCurrentPlayMode()); 79 | } 80 | } 81 | 82 | if (!currentPlayState.equals(avtEvent.getTransportState())) { 83 | currentPlayState = avtEvent.getTransportState(); 84 | for (SonosEventListener listener : listeners) { 85 | listener.playStateChanged(avtEvent.getTransportState()); 86 | } 87 | } 88 | 89 | for (SonosEventListener listener : listeners) { 90 | listener.avtTransportEvent(avtEvent); 91 | } 92 | } 93 | } 94 | 95 | private AVTransportEvent parseEvent(Element e) { 96 | Element avtEvent = ParserHelper.unwrapSonosEvent(e, upnpAVTNamespace); 97 | 98 | PlayState transportState = PlayState.valueOf(ParserHelper.extractEventValue(avtEvent, "TransportState",upnpAVTNamespace)); 99 | PlayMode currentPlayModeInfo = PlayMode.valueOf(ParserHelper.extractEventValue(avtEvent, "CurrentPlayMode",upnpAVTNamespace)); 100 | boolean crossFade = Integer.parseInt(ParserHelper.extractEventValue(avtEvent, "CurrentCrossfadeMode",upnpAVTNamespace))!= 0; 101 | int numberOfTracks = Integer.parseInt(ParserHelper.extractEventValue(avtEvent, "NumberOfTracks",upnpAVTNamespace)); 102 | int currentTrackNumber = Integer.parseInt(ParserHelper.extractEventValue(avtEvent, "CurrentTrack",upnpAVTNamespace)); 103 | int currentSection = Integer.parseInt(ParserHelper.extractEventValue(avtEvent, "CurrentSection",upnpAVTNamespace)); 104 | String currentTrackURI = StringEscapeUtils.unescapeXml(ParserHelper.extractEventValue(avtEvent, "CurrentTrackURI",upnpAVTNamespace)); 105 | String currentTrackDurationRaw = ParserHelper.extractEventValue(avtEvent, "CurrentTrackDuration",upnpAVTNamespace); 106 | 107 | int trackDurationInSeconds; 108 | try { 109 | trackDurationInSeconds = ParserHelper.formatedTimestampToSeconds(currentTrackDurationRaw); 110 | } catch (NumberFormatException nfe) { 111 | trackDurationInSeconds = 0; 112 | } 113 | 114 | 115 | 116 | TrackMetadata trackMeta = TrackMetadata 117 | .parse(StringEscapeUtils.unescapeXml(ParserHelper.extractEventValue(avtEvent, "CurrentTrackMetaData",upnpAVTNamespace))); 118 | 119 | // Care namespace!! 120 | String nextTrackURI = StringEscapeUtils.unescapeXml(ParserHelper.extractEventValue(avtEvent, "NextTrackURI",upnpRinnconnectsNamespace)); 121 | TrackMetadata nextTrackMetaData = TrackMetadata 122 | .parse(StringEscapeUtils.unescapeXml(ParserHelper.extractEventValue(avtEvent, "NextTrackMetaData",upnpRinnconnectsNamespace))); 123 | 124 | // TODO what happens if we have no next track? 125 | 126 | TrackInfo currentTrackInfo = new TrackInfo(currentTrackNumber, trackDurationInSeconds, -1, currentTrackURI, 127 | trackMeta); 128 | TrackInfo nextTrack = new TrackInfo(currentTrackNumber + 1, -1, -1, nextTrackURI, nextTrackMetaData); 129 | 130 | String enqueuedTransportURI = StringEscapeUtils 131 | .unescapeXml(ParserHelper.extractEventValue(avtEvent, "EnqueuedTransportURI",upnpRinnconnectsNamespace)); 132 | TrackMetadata enqueuedTransportURIMetaData = TrackMetadata.parse( 133 | StringEscapeUtils.unescapeXml(ParserHelper.extractEventValue(avtEvent, "EnqueuedTransportURIMetaData",upnpRinnconnectsNamespace))); 134 | 135 | return new AVTransportEvent(transportState, currentPlayModeInfo, crossFade, 136 | numberOfTracks, currentSection, currentTrackInfo, nextTrack, enqueuedTransportURI, 137 | enqueuedTransportURIMetaData); 138 | // Specialized events 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/CommandBuilder.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos; 2 | 3 | import com.github.kilianB.StringUtil; 4 | import com.github.kilianB.exception.SonosControllerException; 5 | import com.github.kilianB.exception.UPnPSonosControllerException; 6 | 7 | import okhttp3.*; 8 | import org.apache.commons.text.StringEscapeUtils; 9 | 10 | import java.io.IOException; 11 | import java.net.URL; 12 | import java.net.URLConnection; 13 | import java.util.HashMap; 14 | import java.util.Map; 15 | 16 | /** 17 | * @author vmichalak 18 | */ 19 | class CommandBuilder { 20 | private static final int SOAP_PORT = 1400; 21 | 22 | 23 | private static final String TRANSPORT_ENDPOINT = "/MediaRenderer/AVTransport/Control"; 24 | private static final String TRANSPORT_SERVICE = "urn:schemas-upnp-org:service:AVTransport:1"; 25 | private static final String RENDERING_ENDPOINT = "/MediaRenderer/RenderingControl/Control"; 26 | private static final String RENDERING_SERVICE = "urn:schemas-upnp-org:service:RenderingControl:1"; 27 | private static final String DEVICE_ENDPOINT = "/DeviceProperties/Control"; 28 | private static final String DEVICE_SERVICE = "urn:schemas-upnp-org:service:DeviceProperties:1"; 29 | private static final String CONTENT_DIRECTORY_ENDPOINT = "/MediaServer/ContentDirectory/Control"; 30 | private static final String CONTENT_DIRECTORY_SERVICE = "urn:schemas-upnp-org:service:ContentDirectory:1"; 31 | private static final String ZONE_GROUP_TOPOLOGY_ENDPOINT = "/ZoneGroupTopology/Control"; 32 | private static final String ZONE_GROUP_TOPOLOGY_SERVICE = "urn:upnp-org:serviceId:ZoneGroupTopology"; 33 | 34 | private static final HashMap ERROR_DESCRIPTION_MAP = new HashMap(); 35 | 36 | static { 37 | ERROR_DESCRIPTION_MAP.put(400, "Bad Request"); 38 | ERROR_DESCRIPTION_MAP.put(401, "Invalid Action"); 39 | ERROR_DESCRIPTION_MAP.put(402, "Invalid Args"); 40 | ERROR_DESCRIPTION_MAP.put(404, "Invalid Var"); 41 | ERROR_DESCRIPTION_MAP.put(412, "Precondition Failed"); 42 | ERROR_DESCRIPTION_MAP.put(501, "Action Failed"); 43 | ERROR_DESCRIPTION_MAP.put(600, "Argument Value Invalid"); 44 | ERROR_DESCRIPTION_MAP.put(601, "Argument Value Out of Range"); 45 | ERROR_DESCRIPTION_MAP.put(602, "Option Action Not Implemented"); 46 | ERROR_DESCRIPTION_MAP.put(603, "Out Of Memory"); 47 | ERROR_DESCRIPTION_MAP.put(604, "Human Intervention Required"); 48 | ERROR_DESCRIPTION_MAP.put(605, "String Argument Too Long"); 49 | ERROR_DESCRIPTION_MAP.put(606, "Action Not Authorized"); 50 | ERROR_DESCRIPTION_MAP.put(607, "Signature Failure"); 51 | ERROR_DESCRIPTION_MAP.put(608, "Signature Missing"); 52 | ERROR_DESCRIPTION_MAP.put(609, "Not Encrypted"); 53 | ERROR_DESCRIPTION_MAP.put(610, "Invalid Sequence"); 54 | ERROR_DESCRIPTION_MAP.put(611, "Invalid Control Url"); 55 | ERROR_DESCRIPTION_MAP.put(612, "No Such Session"); 56 | ERROR_DESCRIPTION_MAP.put(701, "Invalid transition"); 57 | ERROR_DESCRIPTION_MAP.put(702, "No content"); 58 | ERROR_DESCRIPTION_MAP.put(712, "Unsupported Play Mode"); 59 | ERROR_DESCRIPTION_MAP.put(714, "Illegal MIME-Type"); 60 | } 61 | 62 | private static OkHttpClient httpClient; 63 | 64 | private final String endpoint; 65 | private final String service; 66 | private final String action; 67 | private final HashMap bodyEntries = new HashMap(); 68 | 69 | public CommandBuilder(String endpoint, String service, String action) { 70 | this.endpoint = endpoint; 71 | this.service = service; 72 | this.action = action; 73 | } 74 | 75 | public static CommandBuilder transport(String action) { 76 | return new CommandBuilder(TRANSPORT_ENDPOINT, TRANSPORT_SERVICE, action); 77 | } 78 | 79 | public static CommandBuilder rendering(String action) { 80 | return new CommandBuilder(RENDERING_ENDPOINT, RENDERING_SERVICE, action); 81 | } 82 | 83 | public static CommandBuilder device(String action) { 84 | return new CommandBuilder(DEVICE_ENDPOINT, DEVICE_SERVICE, action); 85 | } 86 | 87 | public static CommandBuilder contentDirectory(String action) { 88 | return new CommandBuilder(CONTENT_DIRECTORY_ENDPOINT, CONTENT_DIRECTORY_SERVICE, action); 89 | } 90 | 91 | public static CommandBuilder zoneGroupTopology(String action) { 92 | return new CommandBuilder(ZONE_GROUP_TOPOLOGY_ENDPOINT, ZONE_GROUP_TOPOLOGY_SERVICE, action); 93 | } 94 | 95 | public static String download(String ip, String url) throws IOException, SonosControllerException { 96 | String uri = "http://" + ip + ":" + SOAP_PORT + "/" + url; 97 | Request request = new Request.Builder().url(uri).get().build(); 98 | String response = getHttpClient().newCall(request).execute().body().string(); 99 | handleError(ip, response); 100 | return response; 101 | } 102 | 103 | public CommandBuilder put(String key, String value) { 104 | if (!StringUtil.isEscaped(value)) { 105 | value = StringEscapeUtils.escapeXml11(value); 106 | } 107 | this.bodyEntries.put(key, value); 108 | return this; 109 | } 110 | 111 | public String executeOn(String ip) throws IOException, SonosControllerException { 112 | String uri = "http://" + ip + ":" + SOAP_PORT + this.endpoint; 113 | String content = "" + "" + this.getBody() + "" 116 | + ""; 117 | RequestBody body = RequestBody.create(MediaType.parse("application/text"), content.getBytes("UTF-8")); 118 | Request request = new Request.Builder().url(uri).addHeader("Content-Type", "text/xml") 119 | .addHeader("SOAPACTION", this.service + "#" + this.action).post(body).build(); 120 | String response = getHttpClient().newCall(request).execute().body().string(); 121 | response = unescape(response); 122 | handleError(ip, response); 123 | return response; 124 | } 125 | 126 | protected static void handleError(String ip, String response) throws SonosControllerException { 127 | if (!response.contains("errorCode")) { 128 | return; 129 | } 130 | int errorCode = Integer.parseInt(ParserHelper.findOne("([0-9]*)", response)); 131 | String desc = ERROR_DESCRIPTION_MAP.get(errorCode); 132 | throw new UPnPSonosControllerException("UPnP Error " + errorCode + " (" + desc + ") received from " + ip, 133 | errorCode, desc, response); 134 | } 135 | 136 | protected String getBody() { 137 | StringBuilder sb = new StringBuilder(); 138 | for (Map.Entry entry : bodyEntries.entrySet()) { 139 | sb.append("<").append(entry.getKey()).append(">").append(entry.getValue()).append(""); 141 | } 142 | return sb.toString(); 143 | } 144 | 145 | private static OkHttpClient getHttpClient() { 146 | if (httpClient == null) { 147 | httpClient = new OkHttpClient(); 148 | } 149 | return httpClient; 150 | } 151 | 152 | // This method correct some strange behaviour (multiple escaped string) with the 153 | // getQueue method. 154 | private static String unescape(String s) { 155 | String tmp = s; 156 | while (StringUtil.isEscaped(tmp)) { 157 | tmp = StringEscapeUtils.unescapeXml(tmp); 158 | } 159 | return tmp; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/sonos/model/SonosSpeakerInfo.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.sonos.model; 2 | 3 | /** 4 | * @author vmichalak 5 | */ 6 | public class SonosSpeakerInfo { 7 | private final String deviceName; 8 | private final String zoneIcon; 9 | private final String configuration; 10 | private final String localUID; 11 | private final String serialNumber; 12 | private final String softwareVersion; 13 | private final String softwareDate; 14 | private final String softwareScm; 15 | private final String minCompatibleVersion; 16 | private final String legacyCompatibleVersion; 17 | private final String hardwareVersion; 18 | private final String dspVersion; 19 | private final String hwFlags; 20 | private final String hwFeatures; 21 | private final String variant; 22 | private final String generalFlags; 23 | private final String ipAddress; 24 | private final String macAddress; 25 | private final String copyright; 26 | private final String extraInfo; 27 | private final String htAudioInCode; 28 | private final String idxTrk; 29 | private final String mdp2Ver; 30 | private final String mdp3Ver; 31 | private final String relBuild; 32 | private final String whitelistBuild; 33 | private final String prodUnit; 34 | private final String fuseCfg; 35 | private final String revokeFuse; 36 | private final String authFlags; 37 | private final String swFeatures; 38 | private final String regState; 39 | private final String customerID; 40 | 41 | public SonosSpeakerInfo(String deviceName, String zoneIcon, String configuration, String localUID, 42 | String serialNumber, String softwareVersion, String softwareDate, String softwareScm, 43 | String minCompatibleVersion, String legacyCompatibleVersion, String hardwareVersion, String dspVersion, 44 | String hwFlags, String hwFeatures, String variant, String generalFlags, String ipAddress, String macAddress, 45 | String copyright, String extraInfo, String htAudioInCode, String idxTrk, String mdp2Ver, String mdp3Ver, 46 | String relBuild, String whitelistBuild, String prodUnit, String fuseCfg, String revokeFuse, 47 | String authFlags, String swFeatures, String regState, String customerID) { 48 | this.deviceName = deviceName; 49 | this.zoneIcon = zoneIcon; 50 | this.configuration = configuration; 51 | this.localUID = localUID; 52 | this.serialNumber = serialNumber; 53 | this.softwareVersion = softwareVersion; 54 | this.softwareDate = softwareDate; 55 | this.softwareScm = softwareScm; 56 | this.minCompatibleVersion = minCompatibleVersion; 57 | this.legacyCompatibleVersion = legacyCompatibleVersion; 58 | this.hardwareVersion = hardwareVersion; 59 | this.dspVersion = dspVersion; 60 | this.hwFlags = hwFlags; 61 | this.hwFeatures = hwFeatures; 62 | this.variant = variant; 63 | this.generalFlags = generalFlags; 64 | this.ipAddress = ipAddress; 65 | this.macAddress = macAddress; 66 | this.copyright = copyright; 67 | this.extraInfo = extraInfo; 68 | this.htAudioInCode = htAudioInCode; 69 | this.idxTrk = idxTrk; 70 | this.mdp2Ver = mdp2Ver; 71 | this.mdp3Ver = mdp3Ver; 72 | this.relBuild = relBuild; 73 | this.whitelistBuild = whitelistBuild; 74 | this.prodUnit = prodUnit; 75 | this.fuseCfg = fuseCfg; 76 | this.revokeFuse = revokeFuse; 77 | this.authFlags = authFlags; 78 | this.swFeatures = swFeatures; 79 | this.regState = regState; 80 | this.customerID = customerID; 81 | } 82 | 83 | public String getDeviceName() { 84 | return deviceName; 85 | } 86 | 87 | public String getZoneIcon() { 88 | return zoneIcon; 89 | } 90 | 91 | public String getConfiguration() { 92 | return configuration; 93 | } 94 | 95 | public String getLocalUID() { 96 | return localUID; 97 | } 98 | 99 | public String getSerialNumber() { 100 | return serialNumber; 101 | } 102 | 103 | public String getSoftwareVersion() { 104 | return softwareVersion; 105 | } 106 | 107 | public String getSoftwareDate() { 108 | return softwareDate; 109 | } 110 | 111 | public String getSoftwareScm() { 112 | return softwareScm; 113 | } 114 | 115 | public String getMinCompatibleVersion() { 116 | return minCompatibleVersion; 117 | } 118 | 119 | public String getLegacyCompatibleVersion() { 120 | return legacyCompatibleVersion; 121 | } 122 | 123 | public String getHardwareVersion() { 124 | return hardwareVersion; 125 | } 126 | 127 | public String getDspVersion() { 128 | return dspVersion; 129 | } 130 | 131 | public String getHwFlags() { 132 | return hwFlags; 133 | } 134 | 135 | public String getHwFeatures() { 136 | return hwFeatures; 137 | } 138 | 139 | public String getVariant() { 140 | return variant; 141 | } 142 | 143 | public String getGeneralFlags() { 144 | return generalFlags; 145 | } 146 | 147 | public String getIpAddress() { 148 | return ipAddress; 149 | } 150 | 151 | public String getMacAddress() { 152 | return macAddress; 153 | } 154 | 155 | public String getCopyright() { 156 | return copyright; 157 | } 158 | 159 | public String getExtraInfo() { 160 | return extraInfo; 161 | } 162 | 163 | public String getHtAudioInCode() { 164 | return htAudioInCode; 165 | } 166 | 167 | public String getIdxTrk() { 168 | return idxTrk; 169 | } 170 | 171 | public String getMdp2Ver() { 172 | return mdp2Ver; 173 | } 174 | 175 | public String getMdp3Ver() { 176 | return mdp3Ver; 177 | } 178 | 179 | public String getRelBuild() { 180 | return relBuild; 181 | } 182 | 183 | public String getWhitelistBuild() { 184 | return whitelistBuild; 185 | } 186 | 187 | public String getProdUnit() { 188 | return prodUnit; 189 | } 190 | 191 | public String getFuseCfg() { 192 | return fuseCfg; 193 | } 194 | 195 | public String getRevokeFuse() { 196 | return revokeFuse; 197 | } 198 | 199 | public String getAuthFlags() { 200 | return authFlags; 201 | } 202 | 203 | public String getSwFeatures() { 204 | return swFeatures; 205 | } 206 | 207 | public String getRegState() { 208 | return regState; 209 | } 210 | 211 | public String getCustomerID() { 212 | return customerID; 213 | } 214 | 215 | @Override 216 | public String toString() { 217 | return "SonosSpeakerInfo{" + "deviceName='" + deviceName + '\'' + ", zoneIcon='" + zoneIcon + '\'' 218 | + ", configuration='" + configuration + '\'' + ", localUID='" + localUID + '\'' + ", serialNumber='" 219 | + serialNumber + '\'' + ", softwareVersion='" + softwareVersion + '\'' + ", softwareDate='" 220 | + softwareDate + '\'' + ", softwareScm='" + softwareScm + '\'' + ", minCompatibleVersion='" 221 | + minCompatibleVersion + '\'' + ", legacyCompatibleVersion='" + legacyCompatibleVersion + '\'' 222 | + ", hardwareVersion='" + hardwareVersion + '\'' + ", dspVersion='" + dspVersion + '\'' + ", hwFlags='" 223 | + hwFlags + '\'' + ", hwFeatures='" + hwFeatures + '\'' + ", variant='" + variant + '\'' 224 | + ", generalFlags='" + generalFlags + '\'' + ", ipAddress='" + ipAddress + '\'' + ", macAddress='" 225 | + macAddress + '\'' + ", copyright='" + copyright + '\'' + ", extraInfo='" + extraInfo + '\'' 226 | + ", htAudioInCode='" + htAudioInCode + '\'' + ", idxTrk='" + idxTrk + '\'' + ", mdp2Ver='" + mdp2Ver 227 | + '\'' + ", mdp3Ver='" + mdp3Ver + '\'' + ", relBuild='" + relBuild + '\'' + ", whitelistBuild='" 228 | + whitelistBuild + '\'' + ", prodUnit='" + prodUnit + '\'' + ", fuseCfg='" + fuseCfg + '\'' 229 | + ", revokeFuse='" + revokeFuse + '\'' + ", authFlags='" + authFlags + '\'' + ", swFeatures='" 230 | + swFeatures + '\'' + ", regState='" + regState + '\'' + ", customerID='" + customerID + '\'' + '}'; 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/uPnPClient/SimpleDeviceDiscovery.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.uPnPClient; 2 | 3 | import java.io.IOException; 4 | import java.net.DatagramPacket; 5 | import java.net.DatagramSocket; 6 | import java.net.InetSocketAddress; 7 | import java.net.SocketTimeoutException; 8 | import java.util.ArrayList; 9 | import java.util.HashMap; 10 | import java.util.List; 11 | import java.util.logging.Logger; 12 | import java.util.regex.Matcher; 13 | import java.util.regex.Pattern; 14 | 15 | /** 16 | * A basic implementation of the UPnP (Universal Plug and Play) Simple Device 17 | * Discovery Protocol allowing to discover devices via UDP 18 | * 19 | * @see UPnP-arch-DeviceArchitecture-v1.1 20 | * @author Kilian 21 | * 22 | */ 23 | public class SimpleDeviceDiscovery { 24 | 25 | public static final String USER_AGENT = "Linux UPnP/1.0 Sonos/42.2-52113 (WDCR:Microsoft Windows NT 10.0.16299)"; 26 | 27 | private static final Logger LOGGER = Logger.getLogger(SimpleDeviceDiscovery.class.getName()); 28 | 29 | private static final String UPNP_HOST = "239.255.255.250"; 30 | private static final int UPNP_PORT = 1900; 31 | 32 | private static final String DISCOVERY_REQUEST = "M-SEARCH * HTTP/1.1\r\n" + "HOST: " + UPNP_HOST + ":" + UPNP_PORT 33 | + "\r\n" // SSDP address 34 | + "MAN: \"ssdp:discover\"\r\n" // HTTP extension framework header 35 | + "MX: {mx}\r\n" // Random delay Allowed values 1 - 5 36 | + "ST: ssdp:all\r\n\r\n"; // Search type see documentation 37 | // + Optional user agent 38 | 39 | /** 40 | * Discover all devices advertising themselves via the Simple Device Discovery 41 | * Protocol 42 | * @return discovered devices 43 | * @throws IOException if an I/O error occurs. 44 | */ 45 | public static List discoverDevices() throws IOException { 46 | return discoverDevices(1, 2, null); 47 | } 48 | 49 | /** 50 | * Discover all devices advertising themselves via the Simple Device Discovery 51 | * Protocol matching the given search target 52 | * 53 | * @param searchTarget 54 | * The search target to select specific devices or services 55 | * @return discovered devices 56 | * @throws IOException if an I/O error occurs. 57 | */ 58 | public static List discoverDevices(String searchTarget) throws IOException { 59 | return discoverDevices(1, 2, searchTarget); 60 | } 61 | 62 | /** 63 | * 64 | * @param loadBalancingDelay 65 | * specifies a range, devices may delay their response to lessen 66 | * load. may be in the range of [1-5]s 67 | * @param timeout 68 | * The number of seconds waited before the search is aborted 69 | * @param searchTarget 70 | * The search target to select specific devices or services 71 | * @param callback event handler called once a device was found 72 | * @return discovered devices 73 | * @throws IOException if an I/O error occurs. 74 | */ 75 | public static List discoverDevices(int loadBalancingDelay, int timeout, String searchTarget,UPnPDeviceFoundListener callback) 76 | throws IOException { 77 | 78 | ArrayList devicesFound = new ArrayList(); 79 | 80 | timeout *= 1000; 81 | String request = prepareRequest(loadBalancingDelay, timeout, searchTarget); 82 | // Create a udp package 83 | 84 | byte[] payload = request.getBytes(); 85 | DatagramPacket discoveryRequest = new DatagramPacket(payload, payload.length, 86 | new InetSocketAddress(UPNP_HOST, UPNP_PORT)); 87 | 88 | DatagramSocket udpSocket = new DatagramSocket(); 89 | // devices 90 | udpSocket.setSoTimeout(timeout * 1000); 91 | udpSocket.send(discoveryRequest); 92 | 93 | // Work with response 94 | long startTime = System.currentTimeMillis(); 95 | 96 | // Reuse old package 97 | 98 | while (true) { 99 | byte[] response = new byte[1024]; 100 | DatagramPacket incommingPacket = new DatagramPacket(response, response.length); 101 | try { 102 | udpSocket.receive(incommingPacket); 103 | } catch (SocketTimeoutException timeouted) { 104 | break; 105 | } 106 | 107 | HashMap deviceInfo = parseUpnpNotifyAndSearchMessage(new String(incommingPacket.getData())); 108 | 109 | UPnPDevice device = new UPnPDevice(incommingPacket.getAddress(), deviceInfo); 110 | devicesFound.add(device); 111 | 112 | if(callback != null) { 113 | new Thread( ()->{ 114 | callback.upnpDeviceFound(device); 115 | }).start(); 116 | } 117 | 118 | /** 119 | * if there is an error with the search request (such as an invalid field value 120 | * in the MAN header field, a missing MX header field, or other malformed 121 | * content), the device MUST silently discard and ignore the search request; 122 | * sending of error responses is PROHIBITED due to the possibility of packet 123 | * storms if many devices send an error response to the same request. 124 | */ 125 | 126 | // time left until timeout 127 | int timeLeft = timeout - (int) (System.currentTimeMillis() - startTime); 128 | if (timeLeft <= 0) { 129 | break; 130 | } 131 | udpSocket.setSoTimeout(timeLeft); 132 | } 133 | 134 | udpSocket.close(); 135 | 136 | return devicesFound; 137 | } 138 | 139 | /** 140 | * 141 | * @param loadBalancingDelay 142 | * specifies a range, devices may delay their response to lessen 143 | * load. may be in the range of [1-5]s 144 | * @param timeout 145 | * The number of seconds waited before the search is aborted 146 | * @param searchTarget 147 | * The search target to select specific devices or services 148 | * @return discovered devices 149 | * @throws IOException if an I/O error occurs. 150 | */ 151 | public static List discoverDevices(int loadBalancingDelay, int timeout, String searchTarget) 152 | throws IOException { 153 | return discoverDevices(loadBalancingDelay,timeout,searchTarget,null); 154 | } 155 | 156 | 157 | 158 | /** 159 | * Return the first device discovered matching the supplied search target. This 160 | * method will wait a maximum of 2 seconds before aborting the search. 161 | * 162 | * @param searchTarget 163 | * The search target to select specific devices or services 164 | * @throws IOException if an I/O error occurs. 165 | * @return the UPnPDevice found or null if no device was found 166 | */ 167 | public static UPnPDevice discoverDevice(String searchTarget) throws IOException { 168 | return discoverDevice(1, 2, searchTarget); 169 | } 170 | 171 | /** 172 | * Return the first device discovered matching the supplied search target. 173 | * 174 | * @param timeout 175 | * The number of seconds waited before the search is aborted 176 | * @param searchTarget 177 | * The search target to select specific devices or services 178 | * @throws IOException if an I/O error occurs. 179 | * @return the UPnPDevice found or null if no device was found 180 | */ 181 | public UPnPDevice discoverDevice(int timeout, String searchTarget) throws IOException { 182 | return discoverDevice(1, timeout, searchTarget); 183 | } 184 | 185 | /** 186 | * Return the first device discovered matching the supplied search target. 187 | * 188 | * @param loadBalancingDelay 189 | * specifies a range, devices may delay their response to lessen 190 | * load. may be in the range of [1-5]s 191 | * @param timeout 192 | * The number of seconds waited before the search is aborted 193 | * @param searchTarget 194 | * The search target to select specific devices or services 195 | * @throws IOException if an I/O error occurs. 196 | * @return the UPnPDevice found or null if no device was found 197 | */ 198 | public static UPnPDevice discoverDevice(int loadBalancingDelay, int timeout, String searchTarget) 199 | throws IOException { 200 | timeout *= 1000; 201 | String request = prepareRequest(loadBalancingDelay, timeout, searchTarget); 202 | 203 | byte[] payload = request.getBytes(); 204 | DatagramPacket discoveryRequest = new DatagramPacket(payload, payload.length, 205 | new InetSocketAddress(UPNP_HOST, UPNP_PORT)); 206 | 207 | DatagramSocket udpSocket = new DatagramSocket(); 208 | // devices 209 | udpSocket.setSoTimeout(timeout * 1000); 210 | udpSocket.send(discoveryRequest); 211 | 212 | byte[] returnValue = new byte[1024]; 213 | try { 214 | DatagramPacket incommingPacket = new DatagramPacket(returnValue, returnValue.length); 215 | udpSocket.receive(incommingPacket); 216 | udpSocket.close(); 217 | return new UPnPDevice(incommingPacket.getAddress(), 218 | parseUpnpNotifyAndSearchMessage(new String(incommingPacket.getData()))); 219 | } catch (SocketTimeoutException timeouted) { 220 | udpSocket.close(); 221 | return null; 222 | } 223 | 224 | } 225 | 226 | /* Utility functions*/ 227 | 228 | /** 229 | * 230 | * @param loadBalancingDelay 231 | * in seconds 232 | * @param timeout 233 | * in miliseconds 234 | * @param searchTarget 235 | * @return upnp request as string 236 | */ 237 | private static String prepareRequest(int loadBalancingDelay, int timeout, String searchTarget) { 238 | if (loadBalancingDelay < 1 || loadBalancingDelay > 5) { 239 | LOGGER.warning("Load balancing delay should be within [1-5] seconds. A default of 1s is assumed"); 240 | loadBalancingDelay = 1; 241 | } 242 | 243 | if (loadBalancingDelay >= (timeout / 1000)) { 244 | LOGGER.warning( 245 | "Load balancing delay should not be higher or equal than the timeout. This will lead to some devices not being discovered!"); 246 | } 247 | 248 | String request = DISCOVERY_REQUEST; 249 | 250 | if (searchTarget != null && !searchTarget.isEmpty() && !searchTarget.equals("ssdp:all")) { 251 | request = request.replace("ssdp:all", searchTarget); 252 | } 253 | return request.replace("{mx}", Integer.toString(loadBalancingDelay)); 254 | } 255 | 256 | private static HashMap parseUpnpNotifyAndSearchMessage(String messageToParse) { 257 | final Matcher matcher = Pattern.compile("(.*?):(.*)").matcher(messageToParse); 258 | 259 | final HashMap parsedKeyValues = new HashMap(); 260 | 261 | while (matcher.find()) { 262 | parsedKeyValues.put(matcher.group(1), matcher.group(2)); 263 | } 264 | return parsedKeyValues; 265 | } 266 | 267 | public interface UPnPDeviceFoundListener{ 268 | 269 | void upnpDeviceFound(UPnPDevice device); 270 | 271 | } 272 | 273 | } 274 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/example/localFilePlayer/fileHandling/MusicFileIndexer.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.example.localFilePlayer.fileHandling; 2 | 3 | import java.io.File; 4 | import java.io.IOException; 5 | import java.nio.file.FileSystems; 6 | import java.nio.file.Files; 7 | import java.nio.file.Path; 8 | import java.nio.file.PathMatcher; 9 | import java.nio.file.attribute.FileTime; 10 | import java.sql.SQLException; 11 | import java.sql.Timestamp; 12 | import java.util.ArrayList; 13 | import java.util.HashMap; 14 | import java.util.List; 15 | import java.util.concurrent.ConcurrentHashMap; 16 | import java.util.concurrent.ConcurrentHashMap.KeySetView; 17 | import java.util.concurrent.ForkJoinPool; 18 | import java.util.concurrent.ForkJoinTask; 19 | import java.util.concurrent.RecursiveTask; 20 | import java.util.function.IntConsumer; 21 | import java.util.logging.Logger; 22 | import java.util.stream.Collectors; 23 | import java.util.stream.Stream; 24 | 25 | import org.jaudiotagger.audio.AudioFile; 26 | import org.jaudiotagger.audio.AudioFileIO; 27 | import org.jaudiotagger.audio.exceptions.CannotReadException; 28 | import org.jaudiotagger.audio.exceptions.InvalidAudioFrameException; 29 | import org.jaudiotagger.audio.exceptions.ReadOnlyFileException; 30 | import org.jaudiotagger.tag.FieldKey; 31 | import org.jaudiotagger.tag.KeyNotFoundException; 32 | import org.jaudiotagger.tag.Tag; 33 | import org.jaudiotagger.tag.TagException; 34 | 35 | /** 36 | * Retrieve local/network saved music files by building a searchable index. The 37 | * meta information is saved in an embedded database for persistent storage and 38 | * can be queried using SQL by interpret title and album. 39 | * 40 | *

Indexing music files

Necessary information are parsed by reading the 41 | * ID tags of the music file which means that: 42 | *

43 | *
    44 | *
  1. Files have to be properly ID tagged to appear in the database
  2. 45 | *
  3. First time indexing is expensive as all files have to be searched for tag 46 | * information
  4. 47 | *
48 | * 49 | *

50 | * If you want to change the indexing logic e.g. extract all necessary 51 | * information from the filename 'Interpret- Title- Album' override 52 | * the method {@link #extractAudioFileInformation(File)} 53 | *

54 | * 55 | * The crawler can detect cyclic folder structures and avoids deadlocks. 56 | * Currently artists are only differentiated by name which might raise ambiguity. 57 | * 58 | *

59 | * TODO create n:n relationship between interpret and album. Currently only one 60 | * interpret / album 61 | *

62 | *

63 | * TODO read tags for order of songs in the album.. 64 | *

65 | *

66 | * TODO Hook a file analyzing api / or simply lookup based on filename and 67 | * interpret to retrieve more meta information about files. e.g. GENRE. 68 | *

69 | * 70 | * @author Kilian 71 | * 72 | */ 73 | public class MusicFileIndexer { 74 | 75 | private DatabaseManager database; 76 | 77 | private PathMatcher extensionMatcher; 78 | 79 | /** 80 | * Be aware that calling this method the first time will result in the file 81 | * indexing which can take several minutes. Subsequent calls are greatly reduced 82 | * and just check for differences in file names. 83 | * 84 | * @param allowedFileExtensions which files shall be picked up during crawling? 85 | * only extensions without "." 86 | * @param directoriesToIndex Currently the path is case sensitive in the 87 | * sense that files will get indexed twice if the 88 | * directory changes. 89 | */ 90 | public MusicFileIndexer(String[] allowedFileExtensions, DatabaseManager database) { 91 | 92 | StringBuilder allowedFilesExtensionBuilder = new StringBuilder(); 93 | 94 | for (String extension : allowedFileExtensions) { 95 | allowedFilesExtensionBuilder.append(extension); 96 | } 97 | 98 | String fileExtensionPattern = "glob:**/*.{" + Stream.of(allowedFileExtensions).collect(Collectors.joining(",")) 99 | + "}"; 100 | 101 | extensionMatcher = FileSystems.getDefault().getPathMatcher(fileExtensionPattern); 102 | 103 | // Setup database to store music 104 | this.database = database; 105 | } 106 | 107 | /* 108 | * ----------------------------------------------------------------------------+ 109 | * ----------------------------------------------------------------------------| 110 | * --------------------------------- Crawling----------------------------------| 111 | * ----------------------------------------------------------------------------| 112 | * ----------------------------------------------------------------------------+ 113 | */ 114 | 115 | /** 116 | * Crawl the directory and add all found music files to the database. Invalid 117 | * entries will be removed 118 | * 119 | * @param baseDirectory the directory to search 120 | * @param callback executed after crawling finished 121 | */ 122 | public void crawlAsynch(Path baseDirectory, IntConsumer callback) { 123 | new Thread(() -> { 124 | int tracksIndex = crawl(false, baseDirectory); 125 | if (callback != null) { 126 | callback.accept(tracksIndex); 127 | } 128 | }).start(); 129 | } 130 | 131 | /** 132 | * Blocks until crawling is completed. 133 | * 134 | * @param reIndex If true all entries in the database will be deleted and files 135 | * are reindexed from scratch; 136 | * @param baseDirectory the directory to search 137 | */ 138 | public int crawl(boolean reIndex, Path baseDirectory) { 139 | 140 | // Delete all entries in the database 141 | if (reIndex) { 142 | // NOP. We just reindex present files... 143 | database.resetDB(); 144 | } 145 | 146 | /** 147 | * Common fork join pool. Reuse to save resources 148 | */ 149 | ForkJoinPool commonPool = ForkJoinPool.commonPool(); 150 | 151 | /** 152 | * avoid cyclic folder structure especially with symlinks 153 | */ 154 | KeySetView alreadySeen = ConcurrentHashMap.newKeySet(); 155 | 156 | try { 157 | baseDirectory = baseDirectory.toRealPath(); 158 | } catch (IOException e) { 159 | e.printStackTrace(); 160 | } 161 | 162 | System.out.println("Start crawling: " + baseDirectory); 163 | 164 | try { 165 | 166 | Timestamp startCrawlingTimestamp = new Timestamp(System.currentTimeMillis()); 167 | 168 | int directoryId = database.insertIndexedLocation(baseDirectory); 169 | alreadySeen.add(baseDirectory); 170 | int titlesIndexed = commonPool.invoke(new CrawlDirectory(baseDirectory, alreadySeen, directoryId)); 171 | 172 | /* 173 | * 174 | */ 175 | int titlesRemoved = database.deleteTracksIndexedOlder(startCrawlingTimestamp, directoryId); 176 | 177 | System.out.println("Titles indexed: " + titlesIndexed + " Titles removed: " + titlesRemoved); 178 | 179 | return titlesIndexed; 180 | } catch (SQLException e) { 181 | e.printStackTrace(); 182 | } 183 | return 0; 184 | } 185 | 186 | /** 187 | * Insert real path! 188 | * 189 | * @author Kilian 190 | * 191 | */ 192 | class CrawlDirectory extends RecursiveTask { 193 | 194 | private static final long serialVersionUID = 5401593123821239010L; 195 | 196 | private int directoryId; 197 | private Path directory; 198 | private KeySetView alreadySeen; 199 | 200 | public CrawlDirectory(Path directory, KeySetView alreadySeen, int directoryId) { 201 | this.directory = directory; 202 | this.alreadySeen = alreadySeen; 203 | this.directoryId = directoryId; 204 | } 205 | 206 | @Override 207 | protected Integer compute() { 208 | 209 | if (Files.isDirectory(directory)) { 210 | 211 | List subPaths; 212 | try { 213 | subPaths = Files.walk(directory).collect(Collectors.toList()); 214 | List subtasks = new ArrayList<>(); 215 | 216 | for (Path path : subPaths) { 217 | // Create subtasks 218 | // resolve symlinks ... 219 | Path pReal = path.toRealPath(); 220 | if (!alreadySeen.contains(pReal)) { 221 | subtasks.add(new CrawlDirectory(pReal, alreadySeen, directoryId)); 222 | alreadySeen.add(pReal); 223 | } 224 | } 225 | return ForkJoinTask.invokeAll(subtasks).stream().mapToInt(ForkJoinTask::join).sum(); 226 | } catch (IOException e) { 227 | e.printStackTrace(); 228 | } 229 | // Do we want to do the hashing? the hash does not reflect deep changes! 230 | } else { 231 | // File 232 | if (extensionMatcher.matches(directory)) { 233 | 234 | FileTime lastModified = null; 235 | try { 236 | lastModified = Files.getLastModifiedTime(directory); 237 | } catch (IOException e1) { 238 | e1.printStackTrace(); 239 | } 240 | 241 | try { 242 | 243 | int trackId = database.doesTrackExist(directory.toString()); 244 | 245 | if (trackId == -1 || database.getTrackLastModified(directory.toString()) 246 | .getTime() != lastModified.toMillis()) { 247 | 248 | File f = directory.toFile(); 249 | 250 | AudioFile audioFile = AudioFileIO.read(f); 251 | 252 | String title; 253 | String artistName; 254 | 255 | Tag audioTag = audioFile.getTag(); 256 | 257 | // We don't have much to work with. NO ID3 Tags 258 | if (audioTag == null) { 259 | title = f.getName(); 260 | artistName = "Unknown"; 261 | } else { 262 | title = audioTag.getFirst(FieldKey.TITLE); 263 | artistName = audioTag.getFirst(FieldKey.ARTIST); 264 | 265 | if (title.isEmpty()) { 266 | title = "Unknown"; 267 | } 268 | 269 | if (artistName.isEmpty()) { 270 | artistName = "Unknown"; 271 | } 272 | } 273 | 274 | int trackLength = audioFile.getAudioHeader().getTrackLength(); 275 | 276 | int interpretID = database.insertInterpret(artistName, null, null); 277 | 278 | int trackID = database.insertTrack(title, null, trackLength, directory.toString(), 279 | DatabaseManager.toSQLTimestamp(lastModified), directoryId); 280 | 281 | String albumName = "Unknown"; 282 | if (audioTag != null) { 283 | 284 | try { 285 | albumName = audioTag.getFirst(FieldKey.ALBUM); 286 | if (albumName.isEmpty()) 287 | albumName = "Unknown"; 288 | } catch (KeyNotFoundException e) { 289 | } 290 | } 291 | 292 | int albumID = database.insertAlbum(albumName, null, null, interpretID); 293 | database.insertAlbumTrack(albumID, trackID); 294 | } else { 295 | database.updateTrackIndexedTimestamp(trackId); 296 | } 297 | return 1; 298 | } catch (KeyNotFoundException | SQLException | CannotReadException | IOException | TagException 299 | | ReadOnlyFileException | InvalidAudioFrameException e) { 300 | e.printStackTrace(); 301 | } 302 | } 303 | return 0; 304 | } 305 | return 0; 306 | } 307 | 308 | } 309 | 310 | private static final Logger LOGGER = Logger.getLogger(MusicFileIndexer.class.getName()); 311 | 312 | } 313 | -------------------------------------------------------------------------------- /src/main/java/com/github/kilianB/uPnPClient/UPnPDevice.java: -------------------------------------------------------------------------------- 1 | package com.github.kilianB.uPnPClient; 2 | 3 | import java.io.BufferedReader; 4 | import java.io.IOException; 5 | import java.io.InputStream; 6 | import java.io.InputStreamReader; 7 | import java.io.OutputStream; 8 | import java.io.StringReader; 9 | import java.net.InetAddress; 10 | import java.net.ServerSocket; 11 | import java.net.Socket; 12 | import java.net.UnknownHostException; 13 | import java.text.MessageFormat; 14 | import java.util.Collections; 15 | import java.util.HashMap; 16 | import java.util.Iterator; 17 | import java.util.Map; 18 | import java.util.Map.Entry; 19 | import java.util.Optional; 20 | import java.util.concurrent.ConcurrentHashMap; 21 | import java.util.concurrent.Executors; 22 | import java.util.concurrent.ScheduledExecutorService; 23 | import java.util.concurrent.ScheduledFuture; 24 | import java.util.concurrent.ThreadFactory; 25 | import java.util.concurrent.TimeUnit; 26 | import java.util.concurrent.locks.ReentrantLock; 27 | import java.util.logging.Logger; 28 | 29 | import org.apache.commons.text.StringEscapeUtils; 30 | import org.jdom2.Document; 31 | import org.jdom2.JDOMException; 32 | import org.jdom2.input.SAXBuilder; 33 | 34 | import com.github.kilianB.DaemonThread; 35 | import com.github.kilianB.DaemonThreadFactory; 36 | import com.github.kilianB.NetworkUtil; 37 | import com.github.kilianB.StringUtil; 38 | import com.github.kilianB.sonos.ParserHelper; 39 | 40 | /** 41 | * A UPnPDevice represents a single physical device. The class is used to 42 | * interact with UPnP subscriptions. 43 | * 44 | * @author Kilian 45 | * 46 | */ 47 | public class UPnPDevice { 48 | 49 | // UPnP Event Errorcode 50 | private static final int INCOMPATIBLE_HEADER_FIELDS = 400; 51 | private static final int PRECONDITION_FAILED = 412; // Token invalid or missing 52 | 53 | private static final String ACKNOWLEDGEMENT = "HTTP/1.1 200 OK\r\n" + "Server: " + SimpleDeviceDiscovery.USER_AGENT 54 | + "\r\n" + "Connection: close\r\n\r\n"; 55 | 56 | private static final String BAD_REQUEST = "HTTP/1.1 400 Bad Request\r\n" + "Server: " 57 | + SimpleDeviceDiscovery.USER_AGENT + "\r\n" + "Connection: close\r\n\r\n"; 58 | 59 | private static final byte[] ACKNOWLEDGEMENT_MESSAGE = ACKNOWLEDGEMENT.getBytes(); 60 | private static final byte[] BAD_REQUEST_MESSAGE = BAD_REQUEST.getBytes(); 61 | 62 | private static final Logger LOGGER = Logger.getLogger(UPnPDevice.class.getName()); 63 | 64 | private InetAddress deviceAddress; 65 | /** 66 | * The device info contains all information received during device disocery 67 | */ 68 | private HashMap deviceInfo; 69 | 70 | /** 71 | * Lazily initialized event callback socket for upnp events. Reuse this socket 72 | * for all events. 73 | */ 74 | private ServerSocket eventCallbackSocket; 75 | 76 | /** 77 | * Lookup map holding all currently subscribed to event subscriptions. They are 78 | * used to gracefully shut down the subscriptions upon jvm exit or during 79 | * unsubscription of individual subscriptions. 80 | *

81 | * Sid -> subscription 82 | */ 83 | private ConcurrentHashMap subscriptions = new ConcurrentHashMap(); 84 | 85 | /** 86 | * Handle event re-subscriptions 87 | */ 88 | private ScheduledExecutorService scheduler; 89 | 90 | /** 91 | * Creates a UPnP Device based on the supplied inetAddress. 92 | * 93 | * @param inetAddress The address of the UPnP device 94 | * @param deviceInfo Information retrieved durin Simple Device Discovery 95 | */ 96 | public UPnPDevice(InetAddress inetAddress, HashMap deviceInfo) { 97 | this.deviceAddress = inetAddress; 98 | this.deviceInfo = deviceInfo; 99 | 100 | // Register shutdown hook to gracefully close socket connections and unsubscribe 101 | // from UPnP Events 102 | Runtime.getRuntime().addShutdownHook(handleShutdown); 103 | 104 | } 105 | 106 | /** 107 | * Get the InetAddress of the UPnPDevice 108 | * 109 | * @return the address of the device 110 | */ 111 | public InetAddress getIP() { 112 | return deviceAddress; 113 | } 114 | 115 | /** 116 | * get the location information of the device supplied during Simple Device 117 | * Discovery step 118 | * 119 | * @return Field value contains a URL to the UPnP description of the root 120 | * device. Normally the host portion contains a literal IP address 121 | * rather than a domain name in unmanaged networks 122 | * @see UPnP-arch-DeviceArchitecture-v1.1 124 | */ 125 | public String getLocation() { 126 | return deviceInfo.get("LOCATION"); 127 | } 128 | 129 | /** 130 | * Get the server property of this UPnP Device. Specified by UPnP vendor. 131 | * String. Field value MUST begin with the following �product tokens� (defined 132 | * by HTTP/1.1). The first product token identifies the operating system in the 133 | * form OS name/OS version, the second token represents the UPnP version and 134 | * MUST be UPnP/1.1, and the third token identifies the product using the form 135 | * product name/product version. For example, �SERVER: unix/5.1 UPnP/1.1 136 | * MyProduct/1.0�. Control points MUST be prepared to accept a higher minor 137 | * version number of the UPnP version than the control point itself implements. 138 | * For example, control points implementing UDA version 1.0 will be able to 139 | * interoperate with devices implementing UDA version 1.1. 140 | * 141 | * @see UPnP-arch-DeviceArchitecture-v1.1 143 | * @return The server property 144 | */ 145 | public String getServer() { 146 | return deviceInfo.get("SERVER"); 147 | } 148 | 149 | /** 150 | * Get the ST property of this UPnP Device. Field value contains Search Target. 151 | * Single URI. The response sent by the device depends on the field value of the 152 | * ST header field that was sent in the request. In some cases, the device MUST 153 | * send multiple response messages as follows. 154 | * 155 | * @see UPnP-arch-DeviceArchitecture-v1.1 157 | * @return The st field value 158 | */ 159 | public String getSearchTarget() { 160 | return deviceInfo.get("ST"); 161 | } 162 | 163 | /** 164 | * Get the Unique Service Name property of this UPnP Device. 165 | * 166 | * @see UPnP-arch-DeviceArchitecture-v1.1 168 | * @return The usn field value 169 | */ 170 | public String getUniqueServiceName() { 171 | return deviceInfo.get("USN"); 172 | } 173 | 174 | /** 175 | * Return the content of a field retrieved during SimpleDeviceDiscovery 176 | * 177 | * @param fieldID The id of the header field 178 | * @return the content of the field 179 | */ 180 | public String getField(String fieldID) { 181 | return deviceInfo.get(fieldID); 182 | } 183 | 184 | /** 185 | * @return an unmodifiable map of the device info key value pairs received 186 | * during discovery 187 | */ 188 | public Map getFields() { 189 | return Collections.unmodifiableMap(deviceInfo); 190 | } 191 | 192 | /** 193 | * Subscribe to a upnp event with a default resubscription interval of 1 hour 194 | * 195 | * @param eventHandler The event handler being called once the device sends an 196 | * event 197 | * @param servicePath The service path of the event 198 | * @return the service identifier used to uniquely identify the subscription 199 | * @throws IOException IOException thrown during subscription 200 | */ 201 | public String subscribe(UPnPEventListener eventHandler, String servicePath) throws IOException { 202 | return subscribe(eventHandler, servicePath, 3600); 203 | } 204 | 205 | // Subscribe to events 206 | 207 | /** 208 | * Subscribe to an UPnP Event 209 | * 210 | * @param eventHandler The event handler being called once the device sends an 211 | * event 212 | * @param servicePath The service path of the event 213 | * @param renewalPeriod Renewal period in seconds. If {@literal >} 0 a renewal 214 | * request will be send before the subscription expires. 215 | * Usual periods are around 1 hour. Has to be {@literal >} 216 | * 60 secs Else the subscription will timeout. 217 | * @return the service identifier used to uniquely identify the subscription or 218 | * null if invalid arguments were supplied 219 | * @throws IOException Exception thrown during subscription 220 | */ 221 | public String subscribe(UPnPEventListener eventHandler, String servicePath, int renewalPeriod) throws IOException { 222 | 223 | Subscription subscription = new Subscription(eventHandler, servicePath, renewalPeriod); 224 | 225 | LOGGER.fine(MessageFormat.format("Subscribe to {0}", servicePath)); 226 | 227 | /* 228 | * SUBSCRIBE publisher path HTTP/1.1 HOST: publisher host:publisher port 229 | * USER-AGENT: OS/version UPnP/1.1 product/version CALLBACK: NT: 230 | * * upnp:event 231 | */ 232 | 233 | // Create a sever socket and listen to incoming connections if it's not already 234 | // present 235 | initSubscription(); 236 | 237 | String callbackAddress = "http://" + eventCallbackSocket.getInetAddress().getHostAddress() + ":" 238 | + eventCallbackSocket.getLocalPort(); 239 | 240 | /* Create the search request */ 241 | StringBuilder eventSubscription = new StringBuilder("SUBSCRIBE ").append(servicePath).append(" HTTP/1.1\r\n") 242 | .append("HOST: ").append(deviceAddress.getHostAddress() + ":1400").append("\r\n").append("USER-AGENT: ") 243 | .append(SimpleDeviceDiscovery.USER_AGENT).append("\r\n") // TODO 244 | // 1.1 245 | .append("CALLBACK: <").append(callbackAddress).append(">\r\n").append("NT: upnp:event\r\n") 246 | .append("TIMEOUT: Second-").append(renewalPeriod).append("\r\n\r\n"); 247 | 248 | // Send the subscription event to the device 249 | 250 | try (Socket socket = new Socket(deviceAddress, 1400)) { 251 | 252 | InputStream is = socket.getInputStream(); 253 | 254 | // Send message 255 | OutputStream os = socket.getOutputStream(); 256 | os.write(eventSubscription.toString().getBytes()); 257 | os.flush(); 258 | 259 | // 30 the device has to respond within 30 secs according to specs 260 | socket.setSoTimeout(30000); 261 | 262 | // Wait for the subscription identifier 263 | BufferedReader br = new BufferedReader(new InputStreamReader(is)); 264 | String response = NetworkUtil.dumpReader(br); 265 | String token = ParserHelper.findOne("SID: (.*)", response); 266 | String timeout = ParserHelper.findOne("TIMEOOT:(.*)", response); 267 | LOGGER.fine(MessageFormat.format("Token: {0}", token)); 268 | LOGGER.fine("Actual timeout: " + timeout); 269 | 270 | subscription.setToken(token); 271 | subscriptions.put(token, subscription); 272 | 273 | if (renewalPeriod > 0) { 274 | 275 | // The renewal period has to be shorter than the timeout. 276 | // Renew 1 minutes before the subscription expires 277 | // Device has to respond within 30 seconds according to specification 278 | 279 | int timeoutPeriod = renewalPeriod - 60; 280 | 281 | LOGGER.fine("Schedule renewal interval" + timeoutPeriod); 282 | 283 | if (timeoutPeriod < 0) { 284 | LOGGER.severe( 285 | "Invalid renewal period specified. UPnP Subscription timeout has to be in the range of (60,]"); 286 | return null; 287 | } 288 | 289 | // Do some quick checks for sane input values 290 | if (timeoutPeriod < 60) { 291 | LOGGER.warning("Short renewal periods are discouraged."); 292 | } 293 | 294 | // TODO what happens if we have an error in this thread? Will the scheduler 295 | // still work? 296 | ScheduledFuture eventResubscription = scheduler.scheduleAtFixedRate(new Runnable() { 297 | @Override 298 | public void run() { 299 | try { 300 | renewSubscription(subscription); 301 | } catch (IOException e) { 302 | e.printStackTrace(); 303 | // Cancle itself 304 | subscription.getRenewalFuture().cancel(false); 305 | eventHandler.renewalFailed(e); 306 | subscriptions.remove(subscription.getToken()); 307 | } 308 | } 309 | }, timeoutPeriod, timeoutPeriod, TimeUnit.SECONDS); 310 | 311 | subscription.setRenewalFuture(eventResubscription); 312 | } 313 | return token; 314 | } 315 | // TODO timeout in case subscription failed 316 | 317 | } 318 | 319 | /** 320 | * Initialize the server socket to be able to receive UPNP Event callbacks. Only 321 | * need to be called once 322 | * 323 | * @throws IOException during socket creation. 324 | */ 325 | private void initSubscription() throws IOException { 326 | if (eventCallbackSocket == null) { 327 | InetAddress host = NetworkUtil.resolveSiteLocalAddress(); 328 | eventCallbackSocket = new ServerSocket(0, 50, host); 329 | // Start listening to events 330 | uPnPEventSocketListener.start(); 331 | scheduler = Executors.newScheduledThreadPool(1); 332 | 333 | } 334 | } 335 | 336 | /** 337 | * Issue a renewal request 338 | * 339 | * @param subscription Subscription which will be renewed 340 | * @throws IOException IOException thrown when device can not be reached 341 | */ 342 | private void renewSubscription(Subscription subscription) throws IOException { 343 | 344 | System.out.println("Renew subscription"); 345 | /* Create the search request */ 346 | StringBuilder eventRenewalMessage = new StringBuilder("SUBSCRIBE ").append(subscription.getServicePath()) 347 | .append(" HTTP/1.1\r\n").append("HOST: ").append(deviceAddress.getHostAddress() + ":1400") 348 | .append("\r\n") // 1.1 349 | .append("SID: ").append(subscription.getToken()).append("r\n") 350 | .append("TIMEOUT: Second-" + subscription.getRenewalInterval()).append("\r\n\r\n"); 351 | 352 | try (Socket socket = new Socket(deviceAddress, 1400)) { 353 | InputStream is = socket.getInputStream(); 354 | 355 | // Send message 356 | OutputStream os = socket.getOutputStream(); 357 | os.write(eventRenewalMessage.toString().getBytes()); 358 | os.flush(); 359 | 360 | // Wait for the subscription identifier 361 | BufferedReader br = new BufferedReader(new InputStreamReader(is)); 362 | String response = NetworkUtil.dumpReader(br); 363 | 364 | if (response.contains("200 OK")) { 365 | String resend = ParserHelper.findOne("SID: (.*)", response); 366 | System.out.println("Resubscription: " + response); 367 | LOGGER.fine(MessageFormat.format("Token: {0}", resend)); 368 | System.out.println("Token:" + resend); 369 | } else if (response.contains("412 Precondition Failed")) { 370 | 371 | UPnPEventListener eventHandler = subscription.getEventListener(); 372 | eventHandler.renewalFailed(new Exception("412 Precondition Failed")); 373 | subscription.getRenewalFuture().cancel(true); 374 | // TODO Does it directly expire if we fail to resubscribe? 375 | eventHandler.eventSubscriptionExpired(); 376 | } else { 377 | // TODO 400 incompatible header fields 378 | // TODO 5xx unable to accept. device internal error 379 | LOGGER.severe("Unspecified error during renew subscription"); 380 | } 381 | 382 | } 383 | } 384 | 385 | public boolean unsubscribeFromToken(String sid) { 386 | if (subscriptions.containsKey(sid)) { 387 | return unsubscribe(subscriptions.get(sid)); 388 | } else { 389 | LOGGER.warning(MessageFormat.format( 390 | "Could not unsubscribe from {0} because no subscription was found fitting this criteria.", sid)); 391 | return false; 392 | } 393 | } 394 | 395 | public boolean unsubscribeFromSerice(String servicePath) { 396 | Optional> subscriptionEntry = subscriptions.entrySet().parallelStream() 397 | .filter(entry -> entry.getValue().getServicePath().equals(servicePath)).findFirst(); 398 | 399 | if (subscriptionEntry.isPresent()) { 400 | return unsubscribe(subscriptionEntry.get().getValue()); 401 | } else { 402 | LOGGER.warning(MessageFormat.format( 403 | "Could not unsubscribe from {0} because no subscription was found fitting this criteria.", 404 | servicePath)); 405 | return false; 406 | } 407 | } 408 | 409 | public boolean unsubscribe(Subscription subscription) { 410 | StringBuilder eventCancelation = new StringBuilder("UNSUBSCRIBE ").append(subscription.getServicePath()) 411 | .append(" HTTP/1.1\r\n").append("HOST: ").append(deviceAddress.getHostAddress() + ":1400") 412 | .append("\r\n").append("SID: ").append(subscription.getToken()).append("\r\n\r\n"); 413 | 414 | try (Socket socket = new Socket(deviceAddress, 1400)) { 415 | InputStream is = socket.getInputStream(); 416 | 417 | // Send message 418 | OutputStream os = socket.getOutputStream(); 419 | os.write(eventCancelation.toString().getBytes()); 420 | os.flush(); 421 | 422 | // Wait for confirmation 423 | BufferedReader br = new BufferedReader(new InputStreamReader(is)); 424 | String response = NetworkUtil.dumpReader(br); 425 | 426 | if (response.contains("200 OK")) { 427 | subscription.getRenewalFuture().cancel(false); 428 | subscriptions.remove(subscription.getToken()); 429 | subscription.getEventListener().unsubscribed(); 430 | return true; 431 | } else { 432 | // TODO failed to unsubscribe. 433 | LOGGER.severe(MessageFormat.format("failed to unsubscribe: {0}", response)); 434 | 435 | } 436 | } catch (IOException io) { 437 | LOGGER.severe("failed to unsubscribe"); 438 | LOGGER.severe(io.toString()); 439 | } 440 | return false; 441 | // Response with 200 ok 442 | } 443 | 444 | /** 445 | * UPnP Events create a new connection to the supplied callback socket every 446 | * time an event occurs. Therefore, the socket is only valid for one event and 447 | * can be safely discarded afterwards 448 | */ 449 | private Thread uPnPEventSocketListener = new DaemonThread(() -> { 450 | 451 | while (!Thread.interrupted() && !eventCallbackSocket.isClosed()) { 452 | // Await new events 453 | try { 454 | Socket eventSocket = eventCallbackSocket.accept(); 455 | new Thread(() -> { 456 | parseUPnPEvent(eventSocket); 457 | }).start(); 458 | }catch (IOException e) { 459 | 460 | if(e instanceof java.net.SocketException && e.getMessage().contains("closed")) { 461 | //Disregard. socket closed 462 | LOGGER.info("UPnPEvent socket closed"); 463 | }else { 464 | e.printStackTrace(); 465 | } 466 | } 467 | catch (Exception exception) { 468 | // Bad practice but catch all issues like null pointer exceptions 469 | // which can unexpacetly happen during parsing if udp sends a bad request. 470 | // Prevent the entire 471 | // callback thread to break. 472 | LOGGER.severe(MessageFormat 473 | .format("An error occured during upnp event callback. Trying to recover: {0}", exception)); 474 | } 475 | } 476 | 477 | }, "UPnP Event Socket Listener"); 478 | 479 | private void parseUPnPEvent(Socket socket) { 480 | 481 | try { 482 | socket.setSoTimeout(300); 483 | 484 | String event = NetworkUtil.collectSocketWithTimeout(socket, 200); 485 | 486 | // Send aknowledgment 487 | OutputStream output = socket.getOutputStream(); 488 | // Unescape xml 489 | if (StringUtil.isEscaped(event)) { 490 | event = StringEscapeUtils.unescapeXml(event); 491 | } 492 | 493 | LOGGER.fine("Event: " + event); 494 | 495 | if(event.length() > 0) { 496 | int indexXMLStart = event.indexOf(" { 584 | 585 | if (eventCallbackSocket != null && !eventCallbackSocket.isClosed()) { 586 | 587 | Iterator> keyIter = subscriptions.entrySet().iterator(); 588 | 589 | while (keyIter.hasNext()) { 590 | Subscription subscription = keyIter.next().getValue(); 591 | unsubscribe(subscription); 592 | } 593 | try { 594 | eventCallbackSocket.close(); 595 | scheduler.shutdownNow(); 596 | } catch (IOException e) { 597 | 598 | } 599 | } 600 | }, "UPnP Shutdown"); 601 | 602 | /** 603 | * Creates a dummy device pointing to the supplied ip address. This device can 604 | * be used to subscribe to events but does not contain information usually 605 | * transmitted via the SSDP advertisement. 606 | * 607 | * @param ip Ip of the fake device 608 | * @return A UPnPDevice pointing to the ip 609 | * @throws UnknownHostException If the ip is not well formated or valid 610 | */ 611 | public static UPnPDevice createDummyDevice(String ip) throws UnknownHostException { 612 | return new UPnPDevice(InetAddress.getByName(ip), null); 613 | } 614 | 615 | } 616 | 617 | --------------------------------------------------------------------------------