├── .gitignore ├── .travis.yml ├── README.md ├── build.gradle ├── doc └── screen-shot-1.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── src ├── main ├── java │ └── mqtt │ │ ├── MqttApplication.java │ │ ├── MqttBroker.java │ │ ├── MqttConsumer.java │ │ ├── api │ │ └── RestApi.java │ │ ├── config │ │ ├── MqttConfiguration.java │ │ └── PersistenceConfiguration.java │ │ ├── controller │ │ └── HomeController.java │ │ ├── domain │ │ ├── BaseEntity.java │ │ ├── Session.java │ │ ├── Topic.java │ │ └── Track.java │ │ ├── repository │ │ ├── SessionRepository.java │ │ ├── TopicRepository.java │ │ └── TrackRepository.java │ │ └── service │ │ ├── SessionService.java │ │ ├── TopicService.java │ │ └── TrackService.java └── resources │ ├── application.yml │ ├── logback.xml │ ├── static │ ├── css │ │ └── map.css │ └── js │ │ ├── app.js │ │ ├── knockstrap.js │ │ ├── knockstrap.min.js │ │ └── ko-bootstrap-select.js │ └── templates │ └── home.html └── test ├── java └── mqtt │ ├── MqttApplicationTests.java │ ├── TestSessionService.java │ ├── TestTopicService.java │ └── TestTrackService.java └── resources └── application.yml /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle Stuff 2 | .gradle 3 | build/ 4 | target/ 5 | 6 | # Ignore Gradle GUI config 7 | gradle-app.setting 8 | 9 | # Java Stuff 10 | *.class 11 | 12 | # Mobile Tools for Java (J2ME) 13 | .mtj.tmp/ 14 | 15 | # Package Files # 16 | #*.jar 17 | *.war 18 | *.ear 19 | 20 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 21 | hs_err_pid* 22 | 23 | # Windows Stuff 24 | # Windows image file caches 25 | Thumbs.db 26 | ehthumbs.db 27 | 28 | # Folder config file 29 | Desktop.ini 30 | 31 | # Recycle Bin used on file shares 32 | $RECYCLE.BIN/ 33 | 34 | # Windows Installer files 35 | *.cab 36 | *.msi 37 | *.msm 38 | *.msp 39 | 40 | # Windows shortcuts 41 | *.lnk 42 | 43 | # OSX Stuff 44 | .DS_Store 45 | .AppleDouble 46 | .LSOverride 47 | 48 | # Icon must end with two \r 49 | Icon 50 | 51 | 52 | # Thumbnails 53 | ._* 54 | 55 | # Files that might appear on external disk 56 | .Spotlight-V100 57 | .Trashes 58 | 59 | # Directories potentially created on remote AFP share 60 | .AppleDB 61 | .AppleDesktop 62 | Network Trash Folder 63 | Temporary Items 64 | .apdisk 65 | 66 | # Editor Backups 67 | *~ 68 | 69 | # Intellij Idea 70 | .idea/ 71 | *.iml 72 | *.ipr 73 | *.iws 74 | 75 | # Jrebel 76 | **/rebel.xml 77 | mqtt.db 78 | consumer-tcplocalhost* 79 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | jdk: 3 | - oraclejdk8 4 | before_install: 5 | - chmod +x gradlew -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/frensley/springboot-mqtt-demo.svg?branch=master)](https://travis-ci.org/frensley/springboot-mqtt-demo) 2 | 3 | ### [MQTT:](http://mqtt.org/faq) 4 | > MQTT stands for MQ Telemetry Transport. It is a publish/subscribe, extremely simple and lightweight messaging protocol, designed for constrained devices and low-bandwidth, high-latency or unreliable networks. The design principles are to minimise network bandwidth and device resource requirements whilst also attempting to ensure reliability and some degree of assurance of delivery. These principles also turn out to make the protocol ideal of the emerging “machine-to-machine” (M2M) or “Internet of Things” world of connected devices, and for mobile applications where bandwidth and battery power are at a premium. 5 | 6 | ### [OwnTracks:](http://owntracks.org/) 7 | > OwnTracks allows you to keep track of your own location. You can build your private location diary or share it with your family and friends. OwnTracks is open-source and uses open protocols for communication so you can be sure your data stays secure and private. 8 | 9 | This project demonstrates the use of MQTT as a lightweight message protocol to track gps information and visualize it on a Google map. 10 | 11 | 12 | ![screen-shot-1](../master/doc/screen-shot-1.png) 13 | 14 | #### Build Instructions 15 | 1. ``git clone https://github.com/frensley/springboot-mqtt-demo.git`` 16 | 1. ``cd springboot-mqtt-demo`` 17 | 1. ``./gradlew build`` 18 | 1. ``./gradlew bootRun`` 19 | 1. Use your browser to open http://localhost:8080 20 | 21 | #### Owntracks Client Instructions 22 | 1. Download [OwnTracks](http://owntracks.org/) for your mobile device 23 | 1. Access settings menu 24 | 1. Deactivate TLS 25 | 1. Deactivate Auth 26 | 1. Enter the IP address or Host name of the machine in the Host field 27 | 1. Enter a unique name in the DeviceID field 28 | 29 | To publish Owntracks location use Location Monitoring Mode Menu (second icon from left on top of OwnTracks tab). 30 | Location publish can be done using the "Publish Now" or "Move Mode" selections. 31 | 32 | #### To-do: 33 | - More unit testing 34 | - Documentation 35 | - Better UI experience 36 | - ~~Better broker/messaging solution ([ActiveMQ](http://activemq.apache.org/mqtt.html))~~ 37 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | springBootVersion = '1.2.2.RELEASE' 4 | } 5 | repositories { 6 | mavenCentral() 7 | } 8 | dependencies { 9 | classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") 10 | } 11 | } 12 | 13 | apply plugin: 'java' 14 | apply plugin: 'idea' 15 | apply plugin: 'spring-boot' 16 | 17 | jar { 18 | baseName = 'mqtt-web' 19 | version = '0.0.1-SNAPSHOT' 20 | } 21 | sourceCompatibility = 1.8 22 | targetCompatibility = 1.8 23 | 24 | repositories { 25 | mavenCentral() 26 | maven { 27 | url 'http://dl.bintray.com/andsel/maven' 28 | } 29 | maven { 30 | url 'https://repo.eclipse.org/content/repositories/paho-releases' 31 | } 32 | maven { 33 | url 'https://repository.jboss.org/nexus/content/groups/public-jboss' 34 | } 35 | maven { 36 | url 'http://download.osgeo.org/webdav/geotools/' 37 | } 38 | } 39 | 40 | 41 | dependencies { 42 | //compile("org.springframework.boot:spring-boot-starter-data-jpa") 43 | //compile("org.springframework.boot:spring-boot-starter-jdbc") 44 | //compile("org.springframework.boot:spring-boot-starter-security") 45 | //compile("org.springframework.boot:spring-boot-starter-web") 46 | //compile("org.springframework.boot:spring-boot-starter-websocket") 47 | 48 | //ui 49 | compile("org.webjars:jquery:2.1.3") 50 | compile("org.webjars:bootstrap:3.3.2-2") 51 | compile("org.webjars:bootstrap-glyphicons:bdd2cbfba0") 52 | compile("org.webjars:bootstrap-select:1.6.3") 53 | compile("org.webjars:knockout:3.3.0") 54 | 55 | 56 | //spatial persistence 57 | compile("org.springframework.data:spring-data-neo4j") 58 | compile("org.neo4j:neo4j-spatial:0.13-neo4j-2.1.4") { 59 | exclude(group: 'javax.media', module: 'jai_core') 60 | } 61 | compile("org.hibernate:hibernate-validator") 62 | compile("javax.el:javax.el-api:2.2.4") 63 | 64 | 65 | 66 | //platform 67 | compile("org.springframework:spring-jms"); 68 | compile("org.springframework.boot:spring-boot-starter-thymeleaf") 69 | compile("net.sourceforge.nekohtml:nekohtml:1.9.21") 70 | compile("org.springframework.boot:spring-boot-starter") 71 | compile("org.projectlombok:lombok:1.16.2") 72 | 73 | //activemq 74 | compile("org.apache.activemq:activemq-mqtt:5.11.1") 75 | compile("org.apache.activemq:activemq-pool:5.11.1") 76 | 77 | //test 78 | testCompile("org.springframework.boot:spring-boot-starter-test") 79 | } 80 | 81 | task wrapper(type: Wrapper) { 82 | gradleVersion = '2.3' 83 | } 84 | -------------------------------------------------------------------------------- /doc/screen-shot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frensley/springboot-mqtt-demo/447bf505b5bb195aa802b4a2ae2682c8fbaa1013/doc/screen-shot-1.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frensley/springboot-mqtt-demo/447bf505b5bb195aa802b4a2ae2682c8fbaa1013/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Mar 10 19:20:30 CDT 2015 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-2.3-bin.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # For Cygwin, ensure paths are in UNIX format before anything is touched. 46 | if $cygwin ; then 47 | [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 48 | fi 49 | 50 | # Attempt to set APP_HOME 51 | # Resolve links: $0 may be a link 52 | PRG="$0" 53 | # Need this for relative symlinks. 54 | while [ -h "$PRG" ] ; do 55 | ls=`ls -ld "$PRG"` 56 | link=`expr "$ls" : '.*-> \(.*\)$'` 57 | if expr "$link" : '/.*' > /dev/null; then 58 | PRG="$link" 59 | else 60 | PRG=`dirname "$PRG"`"/$link" 61 | fi 62 | done 63 | SAVED="`pwd`" 64 | cd "`dirname \"$PRG\"`/" >&- 65 | APP_HOME="`pwd -P`" 66 | cd "$SAVED" >&- 67 | 68 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 69 | 70 | # Determine the Java command to use to start the JVM. 71 | if [ -n "$JAVA_HOME" ] ; then 72 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 73 | # IBM's JDK on AIX uses strange locations for the executables 74 | JAVACMD="$JAVA_HOME/jre/sh/java" 75 | else 76 | JAVACMD="$JAVA_HOME/bin/java" 77 | fi 78 | if [ ! -x "$JAVACMD" ] ; then 79 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 80 | 81 | Please set the JAVA_HOME variable in your environment to match the 82 | location of your Java installation." 83 | fi 84 | else 85 | JAVACMD="java" 86 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 87 | 88 | Please set the JAVA_HOME variable in your environment to match the 89 | location of your Java installation." 90 | fi 91 | 92 | # Increase the maximum file descriptors if we can. 93 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 94 | MAX_FD_LIMIT=`ulimit -H -n` 95 | if [ $? -eq 0 ] ; then 96 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 97 | MAX_FD="$MAX_FD_LIMIT" 98 | fi 99 | ulimit -n $MAX_FD 100 | if [ $? -ne 0 ] ; then 101 | warn "Could not set maximum file descriptor limit: $MAX_FD" 102 | fi 103 | else 104 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 105 | fi 106 | fi 107 | 108 | # For Darwin, add options to specify how the application appears in the dock 109 | if $darwin; then 110 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 111 | fi 112 | 113 | # For Cygwin, switch paths to Windows format before running java 114 | if $cygwin ; then 115 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 116 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 158 | function splitJvmOpts() { 159 | JVM_OPTS=("$@") 160 | } 161 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 162 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 163 | 164 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 165 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /src/main/java/mqtt/MqttApplication.java: -------------------------------------------------------------------------------- 1 | package mqtt; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 6 | 7 | @SpringBootApplication 8 | @EnableConfigurationProperties 9 | public class MqttApplication { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(MqttApplication.class, args); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/mqtt/MqttBroker.java: -------------------------------------------------------------------------------- 1 | package mqtt; 2 | 3 | import lombok.Data; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.apache.activemq.broker.BrokerFactory; 6 | import org.apache.activemq.broker.BrokerService; 7 | 8 | import javax.annotation.PostConstruct; 9 | import javax.annotation.PreDestroy; 10 | 11 | /** 12 | * Created by sfrensley on 3/29/15. 13 | */ 14 | @Data 15 | @Slf4j 16 | public class MqttBroker { 17 | 18 | private BrokerService brokerService; 19 | String host = "0.0.0.0"; 20 | Integer port = 8883; 21 | Boolean persistent = false; 22 | Boolean jmx = false; 23 | 24 | 25 | @PostConstruct 26 | public void start() { 27 | String uri = new StringBuilder() 28 | .append("broker:(") 29 | .append("vm://localhost,") 30 | // + "stomp://localhost:%d," 31 | .append(String.format("mqtt+nio://%s:%d", host, port)) 32 | .append(")?") 33 | .append(String.format("persistent=%s&useJmx=%s", persistent, jmx)) 34 | .toString(); 35 | //Authentication 36 | // final SimpleAuthenticationPlugin authenticationPlugin = new SimpleAuthenticationPlugin(); 37 | // authenticationPlugin.setAnonymousAccessAllowed(false); 38 | // authenticationPlugin.setUsers(Arrays.asList(new AuthenticationUser(properties.getUsername(), properties.getPassword(), ""))); 39 | // rv.setPlugins(new BrokerPlugin[]{authenticationPlugin}); 40 | log.info("Creating broker service with uri: {}",uri); 41 | try { 42 | brokerService = BrokerFactory.createBroker(uri); 43 | brokerService.autoStart(); 44 | } catch (Exception ex) { 45 | throw new RuntimeException(ex); 46 | } 47 | } 48 | 49 | @PreDestroy 50 | public void stop() { 51 | try { 52 | brokerService.stop(); 53 | } catch (Exception ex) { 54 | throw new RuntimeException(); 55 | } 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/mqtt/MqttConsumer.java: -------------------------------------------------------------------------------- 1 | package mqtt; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import lombok.Data; 5 | import lombok.extern.slf4j.Slf4j; 6 | import mqtt.domain.*; 7 | import mqtt.service.SessionService; 8 | import mqtt.service.TrackService; 9 | import org.apache.activemq.ActiveMQConnectionFactory; 10 | import org.apache.activemq.command.ActiveMQTopic; 11 | import org.springframework.jms.listener.SimpleMessageListenerContainer; 12 | import org.springframework.util.StringUtils; 13 | 14 | import javax.jms.*; 15 | import java.io.IOException; 16 | 17 | /** 18 | * Created by sfrensley on 3/29/15. 19 | */ 20 | @Data 21 | @Slf4j 22 | public class MqttConsumer extends SimpleMessageListenerContainer { 23 | 24 | private String host = "vm://localhost"; 25 | private String subscriptions = "owntracks.user.*"; 26 | private Long timeout = 5000L; 27 | //Activity window before a new session is established 28 | private Long sessionWindowSeconds = 5 * 60L; 29 | //How close before this is considered a new point (see withinDistance for Neo4j) 30 | private Double proximityWindow = .01; 31 | 32 | private ObjectMapper mapper = new ObjectMapper(); 33 | private TrackService trackService; 34 | private SessionService sessionService; 35 | 36 | private static final String TOPIC_PREFIX = "topic://"; 37 | 38 | public MqttConsumer(TrackService trackService, 39 | SessionService sessionService) { 40 | 41 | this.trackService = trackService; 42 | this.sessionService = sessionService; 43 | } 44 | 45 | @Override 46 | public void afterPropertiesSet() { 47 | this.setupMessageListener(new Listener()); 48 | this.setConnectionFactory(new ActiveMQConnectionFactory(host)); 49 | this.setPubSubDomain(true); 50 | this.setDestinationName(subscriptions); 51 | super.afterPropertiesSet(); 52 | } 53 | 54 | private class Listener implements MessageListener { 55 | @Override 56 | public void onMessage(Message message) { 57 | String text = null; 58 | Destination destination = null; 59 | try { 60 | destination = message.getJMSDestination(); 61 | if (message instanceof TextMessage) { 62 | text = ((TextMessage) message).getText(); 63 | } else if (message instanceof BytesMessage) { 64 | final BytesMessage bytesMessage = (BytesMessage) message; 65 | byte[] bytes = new byte[(int) bytesMessage.getBodyLength()]; 66 | bytesMessage.readBytes(bytes); 67 | text = new String(bytes); 68 | } 69 | 70 | } catch (JMSException ex) { 71 | log.warn("Error: ", ex); 72 | } 73 | 74 | if (text == null) { 75 | return; 76 | } 77 | if (destination instanceof ActiveMQTopic) { 78 | //name without topic:// 79 | String topic = ((ActiveMQTopic) destination).getPhysicalName(); 80 | //topic names have periods in them. We use slashes. 81 | topic = StringUtils.replace(topic,".","/"); 82 | log.info("Track received for: {} destination: {}", text, topic); 83 | try { 84 | persistMessage(topic, mapper.readValue(text, Track.class)); 85 | } catch (IOException e) { 86 | log.error("Error processing message: ", e); 87 | } 88 | } 89 | } 90 | 91 | private void persistMessage(String topic, Track msg) { 92 | try { 93 | log.info("Entity: {}", msg); 94 | try { 95 | if (trackService.exists(topic,msg)) { 96 | log.info("Message exists."); 97 | } 98 | 99 | mqtt.domain.Session session = sessionService.findOrCreateSession(topic,sessionWindowSeconds * 1000); 100 | if (session == null) { 101 | log.error("Unable to process message because track session is null."); 102 | return; 103 | } 104 | //Don't merge another point if it's inside our point tolerance radius for this session because it clutters the map 105 | //This will be a problem for circular routes as the final route point will not appear on the map 106 | //perhaps calculate distance and time (or just time) 107 | if (trackService.isWithinDistanceForSession(msg, proximityWindow,session)) { 108 | log.info("Message inside radius"); 109 | } else { 110 | //Update session date to keep alive 111 | session.setDate(System.currentTimeMillis()); 112 | session = sessionService.save(session); 113 | msg.setSession(session); 114 | msg = trackService.save(msg); 115 | log.info("Saved Entity: {} Session: {}", msg,session); 116 | } 117 | } catch (Exception e) { 118 | log.error("Saved Exception:", e); 119 | } 120 | } catch (Exception e) { 121 | //must catch everything - client will exit if and exception is thrown. 122 | log.error("Something bad happened.",e); 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/main/java/mqtt/api/RestApi.java: -------------------------------------------------------------------------------- 1 | package mqtt.api; 2 | 3 | import mqtt.domain.Session; 4 | import mqtt.domain.Topic; 5 | import mqtt.domain.Track; 6 | import mqtt.service.SessionService; 7 | import mqtt.service.TopicService; 8 | import mqtt.service.TrackService; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.web.bind.annotation.PathVariable; 11 | import org.springframework.web.bind.annotation.RequestMapping; 12 | import org.springframework.web.bind.annotation.RestController; 13 | 14 | import java.util.List; 15 | 16 | /** 17 | * Created by sfrensley on 3/14/15. 18 | */ 19 | @SuppressWarnings("SpringJavaAutowiringInspection") 20 | @RestController 21 | @RequestMapping("/api/") 22 | public class RestApi { 23 | 24 | /** 25 | * TODO: Convert everything to page requests that need it 26 | */ 27 | 28 | @Autowired TrackService trackService; 29 | @Autowired TopicService topicService; 30 | @Autowired SessionService sessionService; 31 | 32 | /** 33 | * Returns List of @Track for given sessionId 34 | * @param sessionId 35 | * @return 36 | */ 37 | @RequestMapping("/tracks/{sessionId}") 38 | public List points(@PathVariable("sessionId") Long sessionId) { 39 | return trackService.findAllForSession(sessionId); 40 | } 41 | 42 | /** 43 | * Returns List of @Session for given topicId 44 | * @param topicId 45 | * @return 46 | */ 47 | @RequestMapping("/sessions/{topicId}") 48 | public List sessions(@PathVariable("topicId") Long topicId) { 49 | return sessionService.findSessionsByTopicId(topicId); 50 | } 51 | 52 | /** 53 | * delete a particular 54 | * @param sessionId 55 | */ 56 | @RequestMapping("/sessions/delete/{sessionId}") 57 | public void deleteSession(@PathVariable("sessionId") Long sessionId) { 58 | sessionService.deleteSession(sessionId); 59 | } 60 | 61 | /** 62 | * Returns List of @Topic for entire system 63 | * @return 64 | */ 65 | @RequestMapping("/topics") 66 | public List topics() { 67 | return topicService.findAllSortByNameAsc(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/java/mqtt/config/MqttConfiguration.java: -------------------------------------------------------------------------------- 1 | package mqtt.config; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import mqtt.MqttBroker; 5 | import mqtt.MqttConsumer; 6 | import mqtt.service.SessionService; 7 | import mqtt.service.TrackService; 8 | import org.springframework.boot.context.properties.ConfigurationProperties; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | import org.springframework.context.annotation.DependsOn; 12 | import org.springframework.context.annotation.Profile; 13 | import org.springframework.jms.listener.SimpleMessageListenerContainer; 14 | 15 | /** 16 | * Created by sfrensley on 3/11/15. 17 | */ 18 | @Configuration 19 | @Profile("!test") 20 | @Slf4j 21 | public class MqttConfiguration { 22 | 23 | @Bean 24 | @DependsOn({"brokerService"}) 25 | @ConfigurationProperties("mqtt.consumer") 26 | public SimpleMessageListenerContainer consumerService ( 27 | TrackService trackService, 28 | SessionService sessionService) throws Exception { 29 | return new MqttConsumer(trackService, sessionService); 30 | } 31 | 32 | @Bean 33 | @DependsOn({"graphDatabaseService"}) 34 | @ConfigurationProperties("mqtt.broker") 35 | public MqttBroker brokerService() { 36 | return new MqttBroker(); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/mqtt/config/PersistenceConfiguration.java: -------------------------------------------------------------------------------- 1 | package mqtt.config; 2 | 3 | import org.neo4j.graphdb.GraphDatabaseService; 4 | import org.neo4j.graphdb.factory.GraphDatabaseFactory; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.data.neo4j.config.EnableNeo4jRepositories; 8 | import org.springframework.data.neo4j.config.Neo4jConfiguration; 9 | import org.springframework.transaction.annotation.EnableTransactionManagement; 10 | 11 | /** 12 | * Created by sfrensley on 3/11/15. 13 | */ 14 | @Configuration 15 | @EnableNeo4jRepositories(basePackages = "mqtt.repository") 16 | @EnableTransactionManagement 17 | public class PersistenceConfiguration extends Neo4jConfiguration { 18 | 19 | public PersistenceConfiguration() { 20 | setBasePackage("mqtt.domain"); 21 | } 22 | 23 | /** 24 | * Build graph database in current working directory. 25 | * @return 26 | */ 27 | @Bean 28 | public GraphDatabaseService graphDatabaseService() { 29 | return new GraphDatabaseFactory() 30 | .newEmbeddedDatabaseBuilder("./mqtt.db") 31 | .newGraphDatabase(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/mqtt/controller/HomeController.java: -------------------------------------------------------------------------------- 1 | package mqtt.controller; 2 | 3 | import org.springframework.stereotype.Controller; 4 | import org.springframework.web.bind.annotation.RequestMapping; 5 | 6 | /** 7 | * Created by sfrensley on 3/14/15. 8 | */ 9 | @Controller 10 | public class HomeController { 11 | 12 | @RequestMapping({"/","home"}) 13 | public String home() { 14 | return "home"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/mqtt/domain/BaseEntity.java: -------------------------------------------------------------------------------- 1 | package mqtt.domain; 2 | 3 | import lombok.Data; 4 | import org.springframework.data.neo4j.annotation.GraphId; 5 | 6 | /** 7 | * Created by sfrensley on 3/13/15. 8 | * Base entity abstraction used to hold identifier, 9 | * create/update times etc. 10 | */ 11 | 12 | @Data 13 | public abstract class BaseEntity { 14 | @GraphId 15 | private Long id; 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/mqtt/domain/Session.java: -------------------------------------------------------------------------------- 1 | package mqtt.domain; 2 | 3 | import lombok.Data; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.ToString; 6 | import org.springframework.data.neo4j.annotation.NodeEntity; 7 | 8 | /** 9 | * Created by sfrensley on 3/15/15. 10 | * Domain model used to to segment groups of messages (points) 11 | * by logical time. 12 | */ 13 | @NodeEntity 14 | @Data 15 | @EqualsAndHashCode(callSuper = true) 16 | @ToString(callSuper = true) 17 | public class Session extends BaseEntity { 18 | Long date; 19 | Topic topic; 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/mqtt/domain/Topic.java: -------------------------------------------------------------------------------- 1 | package mqtt.domain; 2 | 3 | import lombok.Data; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.ToString; 6 | import org.springframework.data.neo4j.annotation.NodeEntity; 7 | 8 | /** 9 | * Created by sfrensley on 3/15/15. 10 | * @Topic is domain model used to represent the MQTT queue name 11 | */ 12 | @NodeEntity 13 | @Data 14 | @EqualsAndHashCode(callSuper = true, exclude = {"name"}) //use only id for equality 15 | @ToString(callSuper = true) 16 | public class Topic extends BaseEntity { 17 | /** 18 | * This is the name of the MQTT queue 19 | */ 20 | String name; 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/mqtt/domain/Track.java: -------------------------------------------------------------------------------- 1 | package mqtt.domain; 2 | 3 | import lombok.Data; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.ToString; 6 | import org.springframework.data.neo4j.annotation.Indexed; 7 | import org.springframework.data.neo4j.annotation.NodeEntity; 8 | import org.springframework.data.neo4j.support.index.IndexType; 9 | 10 | /** 11 | * Created by sfrensley on 3/12/15. 12 | * @Track is the domain model used to represent the MQTT message 13 | * from OwnTracks 14 | */ 15 | @NodeEntity 16 | @Data 17 | @EqualsAndHashCode(callSuper = true) 18 | @ToString(callSuper = true) 19 | public class Track extends BaseEntity { 20 | 21 | //Name of "layer" index for neo4j spatial indexes 22 | public static final String wktIndexName = "MessageLocation"; 23 | 24 | Number cog; 25 | Number lon; 26 | Number acc; 27 | Number vel; 28 | String _type; 29 | Number batt; 30 | Number vac; 31 | Number lat; 32 | String t; 33 | Number tst; 34 | Number alt; 35 | String tid; 36 | Session session; 37 | 38 | //WKT format - Well Known Text 39 | @Indexed(indexType = IndexType.POINT, indexName=wktIndexName) 40 | String wkt; 41 | 42 | public void setLon(Number lon) { 43 | this.lon = lon; 44 | setLocation(getLon(),getLat()); 45 | } 46 | 47 | public void setLat(Number lat) { 48 | this.lat = lat; 49 | setLocation(getLon(),getLat()); 50 | } 51 | 52 | public void setLocation(Number lon, Number lat) { 53 | this.lon = lon; 54 | this.lat = lat; 55 | if (lon != null && lat != null) { 56 | this.wkt = String.format("POINT( %.4f %.4f )", lon.doubleValue(), lat.doubleValue()); 57 | } 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/mqtt/repository/SessionRepository.java: -------------------------------------------------------------------------------- 1 | package mqtt.repository; 2 | 3 | import mqtt.domain.Session; 4 | import org.springframework.data.neo4j.annotation.Query; 5 | import org.springframework.data.neo4j.conversion.Result; 6 | import org.springframework.data.neo4j.repository.GraphRepository; 7 | 8 | 9 | /** 10 | * Created by sfrensley on 3/15/15. 11 | */ 12 | public interface SessionRepository extends GraphRepository { 13 | 14 | @Query("match (t:Topic)<--(s:Session) where id(t)={0} return s order by s.date desc limit 1") 15 | public Session findLatestSession(Long topicId); 16 | 17 | @Query("match (t:Topic)<--(s:Session) where t.name={0} return s order by s.date desc limit 1") 18 | public Session findLatestSession(String topicName); 19 | 20 | @Query("match (t:Topic)<--(s:Session) where id(t)={0} return s order by s.date desc") 21 | public Result findSessionsByTopicId(Long topicId); 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/mqtt/repository/TopicRepository.java: -------------------------------------------------------------------------------- 1 | package mqtt.repository; 2 | 3 | import mqtt.domain.Topic; 4 | import org.springframework.data.neo4j.repository.GraphRepository; 5 | 6 | /** 7 | * Created by sfrensley on 3/15/15. 8 | */ 9 | public interface TopicRepository extends GraphRepository { 10 | 11 | public Topic findByName(String name); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/mqtt/repository/TrackRepository.java: -------------------------------------------------------------------------------- 1 | package mqtt.repository; 2 | 3 | import mqtt.domain.Track; 4 | import org.springframework.data.neo4j.annotation.Query; 5 | import org.springframework.data.neo4j.conversion.Result; 6 | import org.springframework.data.neo4j.repository.GraphRepository; 7 | import org.springframework.data.neo4j.repository.SpatialRepository; 8 | 9 | 10 | /** 11 | * Created by sfrensley on 3/12/15. 12 | */ 13 | public interface TrackRepository extends GraphRepository,SpatialRepository { 14 | 15 | @Query("match (t:Topic)<--(s:Session)<--(tr:Track) where t.name = {0} and tr.tst = {1} return tr") 16 | public Track findByTopicAndTimestamp(String topic, Long timestamp); 17 | 18 | @Query("match (t:Topic)<--(s:Session)<--(tr:Track) where id(t)={0} return tr") 19 | public Result findAllForTopic(Long topicId); 20 | 21 | @Query("match (s:Session)<--(tr:Track) where id(s)={0} return tr order by tr.tst") 22 | public Result findAllForSession(Long sessionId); 23 | 24 | @Query("start n=node:" + Track.wktIndexName + "({0}) match (s:Session)<--(n) where id(s)={1} return s") 25 | public Result findWithinDistanceForSession(String withinDistance, Long sessionId); 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/mqtt/service/SessionService.java: -------------------------------------------------------------------------------- 1 | package mqtt.service; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import mqtt.domain.Session; 5 | import mqtt.domain.Topic; 6 | import mqtt.repository.SessionRepository; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.stereotype.Repository; 9 | import org.springframework.stereotype.Service; 10 | import org.springframework.transaction.annotation.Transactional; 11 | 12 | import java.util.ArrayList; 13 | import java.util.List; 14 | 15 | /** 16 | * Created by sfrensley on 3/15/15. 17 | */ 18 | @SuppressWarnings("SpringJavaAutowiringInspection") 19 | @Service 20 | @Transactional 21 | @Slf4j 22 | @Repository 23 | public class SessionService { 24 | 25 | private SessionRepository repository; 26 | private TopicService topicService; 27 | private TrackService trackService; 28 | 29 | @Autowired 30 | public SessionService(SessionRepository sessionRepository, TopicService topicService, TrackService trackService) { 31 | this.repository = sessionRepository; 32 | this.topicService = topicService; 33 | this.trackService = trackService; 34 | } 35 | 36 | public Session save(Session session) { 37 | return repository.save(session); 38 | } 39 | 40 | public Session findById(Long id) { 41 | return repository.findOne(id); 42 | } 43 | 44 | public List findSessionsByTopicId(Long topicId) { 45 | return repository.findSessionsByTopicId(topicId).as(ArrayList.class); 46 | } 47 | 48 | public Session findLatestSession(Long topicId) { 49 | return repository.findLatestSession(topicId); 50 | } 51 | 52 | public Session findOrCreateSession(String topicName, Long windowSeconds) { 53 | Topic topic = topicService.findOrCreateTopic(topicName); 54 | return findOrCreateSession(topic, windowSeconds); 55 | } 56 | 57 | public Session findOrCreateSession(Topic t, Long windowSeconds) { 58 | Session s = findLatestSession(t.getId()); 59 | Long cutoff = System.currentTimeMillis() - windowSeconds; 60 | if (s == null || s.getDate() < cutoff) { 61 | s = new Session(); 62 | s.setDate(System.currentTimeMillis()); 63 | s.setTopic(t); 64 | s = repository.save(s); 65 | } 66 | return s; 67 | } 68 | 69 | public void deleteSession(Long sessionId) { 70 | trackService.deleteAllForSession(sessionId); 71 | repository.delete(sessionId); 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/main/java/mqtt/service/TopicService.java: -------------------------------------------------------------------------------- 1 | package mqtt.service; 2 | 3 | import mqtt.domain.Topic; 4 | import mqtt.repository.TopicRepository; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.data.domain.Sort; 7 | import org.springframework.stereotype.Service; 8 | import org.springframework.transaction.annotation.Transactional; 9 | 10 | import java.util.ArrayList; 11 | import java.util.List; 12 | 13 | /** 14 | * Created by sfrensley on 3/15/15. 15 | */ 16 | @Service 17 | @Transactional 18 | public class TopicService { 19 | 20 | @SuppressWarnings("SpringJavaAutowiringInspection") 21 | @Autowired 22 | private TopicRepository repository; 23 | 24 | private static final Sort SORT_BY_NAME_ASC = new Sort(Sort.Direction.ASC, "name"); 25 | 26 | public Topic findOrCreateTopic(String name) { 27 | Topic t = repository.findByName(name); 28 | if (t == null) { 29 | t = new Topic(); 30 | t.setName(name); 31 | t = repository.save(t); 32 | } 33 | return t; 34 | } 35 | 36 | public Topic save(Topic topic) { 37 | return repository.save(topic); 38 | } 39 | 40 | public Topic findById(Long id) { 41 | return repository.findOne(id); 42 | } 43 | 44 | public Topic findByName(String name) { 45 | return repository.findByName(name); 46 | } 47 | 48 | public List findAllSortByNameAsc() { 49 | return repository.findAll(SORT_BY_NAME_ASC).as(ArrayList.class); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/mqtt/service/TrackService.java: -------------------------------------------------------------------------------- 1 | package mqtt.service; 2 | 3 | import mqtt.domain.Session; 4 | import mqtt.domain.Track; 5 | import mqtt.repository.TrackRepository; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.data.neo4j.conversion.Result; 8 | import org.springframework.data.neo4j.support.Neo4jTemplate; 9 | import org.springframework.stereotype.Service; 10 | import org.springframework.transaction.annotation.Transactional; 11 | 12 | import java.util.ArrayList; 13 | import java.util.HashMap; 14 | import java.util.List; 15 | import java.util.Map; 16 | 17 | /** 18 | * Created by sfrensley on 3/13/15. 19 | * Mid-tier service to access repository and execute business logic 20 | */ 21 | @Service 22 | @Transactional 23 | public class TrackService { 24 | 25 | TopicService topicService; 26 | TrackRepository repository; 27 | Neo4jTemplate template; 28 | 29 | @SuppressWarnings("SpringJavaAutowiringInspection") 30 | @Autowired 31 | public TrackService(TrackRepository repository, Neo4jTemplate template) { 32 | this.repository = repository; 33 | this.template = template; 34 | } 35 | 36 | /** 37 | * Find @Track by it's id 38 | * @param id 39 | * @return 40 | */ 41 | public Track findById(Long id) { 42 | return repository.findOne(id); 43 | } 44 | 45 | /** 46 | * Return all tracks 47 | * @return 48 | */ 49 | public List findAll() { 50 | return repository.findAll().as(ArrayList.class); 51 | } 52 | 53 | /** 54 | * Merge @Track to graph 55 | * @param entity 56 | * @return 57 | */ 58 | public Track save(Track entity) { 59 | return repository.save(entity); 60 | } 61 | 62 | /** 63 | * Are there any @Track within distance. 64 | * @param track 65 | * @param distance 66 | * @return 67 | */ 68 | public boolean isWithinDistance(Track track, double distance) { 69 | List p = repository.findWithinDistance(Track.wktIndexName, track.getLat().doubleValue(), track.getLon().doubleValue(), distance).as(ArrayList.class); 70 | return !p.isEmpty(); 71 | } 72 | 73 | /** 74 | * Are there any @Track within distance for this @Session only. 75 | * @param track 76 | * @param distance 77 | * @param session 78 | * @return 79 | */ 80 | public boolean isWithinDistanceForSession(Track track, double distance, Session session) { 81 | //TODO: Framework having issues parsing old spatial format for SDN. Move to extended repository? Also prime for injection attack? 82 | String withinDistance = String.format("withinDistance:[%f,%f,%f]",track.getLat().doubleValue(), track.getLon().doubleValue(), distance); 83 | 84 | //TODO: This is so broken. There has to be a better way to execute legacy index queries. 85 | String query = "start n=node:" + Track.wktIndexName + "('" + withinDistance + "') match (s:Session)<--(n) where id(s)={id} return s"; 86 | Map params = new HashMap<>(); 87 | params.put("lat",track.getLat()); 88 | params.put("lon",track.getLon()); 89 | params.put("distance",distance); 90 | params.put("id",session.getId()); 91 | 92 | Result p = template.query(query, params); 93 | return p.iterator().hasNext(); 94 | } 95 | 96 | /** 97 | * Find @Track with @Topic and timestamp 98 | * @param topic 99 | * @param timestamp 100 | * @return 101 | */ 102 | public Track findByTopicAndTimestamp(String topic, Long timestamp) { 103 | return repository.findByTopicAndTimestamp(topic, timestamp); 104 | } 105 | 106 | /** 107 | * Check for existance of @Track with @Topic and timestamp 108 | * @param topicName 109 | * @param track 110 | * @return 111 | */ 112 | public boolean exists(String topicName, Track track) { 113 | Track t = repository.findByTopicAndTimestamp(topicName, track.getTst().longValue()); 114 | return t != null; 115 | } 116 | 117 | /** 118 | * Find all @Track for @Topic id 119 | * @param topicId 120 | * @return 121 | */ 122 | public List findAllForTopic(Long topicId) { 123 | return repository.findAllForTopic(topicId).as(ArrayList.class); 124 | } 125 | 126 | /** 127 | * Find all @Track for @Session id 128 | * @param sessionId 129 | * @return 130 | */ 131 | public List findAllForSession(Long sessionId) { 132 | return repository.findAllForSession(sessionId).as(ArrayList.class); 133 | } 134 | 135 | /** 136 | * Delete all @Track for specific session 137 | * @param sessionId 138 | */ 139 | public void deleteAllForSession(Long sessionId) { 140 | repository.delete(findAllForSession(sessionId)); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | mqtt: 2 | broker: 3 | port: 8883 4 | #websocket_port: 8080 5 | host: 0.0.0.0 6 | jmx: false 7 | consumer: 8 | host: vm://localhost 9 | subscriptions: owntracks.user.* 10 | timeout: 5000 11 | session-window-seconds: 300 12 | 13 | spring: 14 | thymeleaf: 15 | cache: false 16 | mode: LEGACYHTML5 17 | 18 | server: 19 | port: 8080 20 | 21 | --- 22 | spring: 23 | profiles: aws 24 | 25 | server: 26 | port: 80 -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main/resources/static/css/map.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #f5f5f5; 3 | } 4 | 5 | #main-content { 6 | padding: 5px 5px 5px 5px; 7 | /*margin: 0 auto 20px;*/ 8 | background-color: #fff; 9 | border: 1px solid #e5e5e5; 10 | -webkit-border-radius: 5px; 11 | -moz-border-radius: 5px; 12 | border-radius: 5px; 13 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05); 14 | -moz-box-shadow: 0 1px 2px rgba(0, 0, 0, .05); 15 | box-shadow: 0 1px 2px rgba(0, 0, 0, .05); 16 | } 17 | 18 | .container-fluid .row { 19 | margin-left: 0px; 20 | } 21 | 22 | .alert.alert-warning h5 { 23 | margin-top: 0px; 24 | } 25 | 26 | #map-canvas{ 27 | height: 650px; 28 | } 29 | 30 | .map_marker_label { 31 | color: red; 32 | background-color: white; 33 | font-family: "Lucida Grande", "Arial", sans-serif; 34 | font-size: 10px; 35 | font-weight: bold; 36 | text-align: center; 37 | width: 40px; 38 | border: 2px solid black; 39 | white-space: nowrap; 40 | } 41 | 42 | .menu-select { 43 | margin-bottom: 15px; 44 | width: 80% !important; 45 | } 46 | 47 | .menu-select + button { 48 | margin: 0 0 15px 5px; 49 | } 50 | 51 | .menu-chart { 52 | width: 100%; 53 | height: 250px; 54 | } -------------------------------------------------------------------------------- /src/main/resources/static/js/app.js: -------------------------------------------------------------------------------- 1 | function ApplicationModel(map, cfg) { 2 | var self = this, 3 | //poly line reference 4 | trackLine = undefined, 5 | //marker array on poly line 6 | trackLineMarkers = [], 7 | //marker that is highlighted on map track 8 | trackLineHighlightMarker = null, 9 | //holds reference to current bounds of markers for zoom to extents 10 | bounds, 11 | //holds reference to speed/altitude chart 12 | speedChart; 13 | 14 | 15 | //public variables 16 | self.username = ""; //not used yet 17 | self.topicItems = ko.observableArray([]); 18 | self.topicIds = ko.observable(-1); 19 | self.sessionItems = ko.observableArray([]); 20 | self.sessionIds = ko.observable(-1); 21 | self.currentTrackId = -1; 22 | 23 | //observable rate limit - only notify when this has stopped updating for at least 500ms 24 | self.trackData = ko.observableArray([]).extend({ rateLimit: { timeout: 500, method: "notifyWhenChangesStop" } }); 25 | 26 | self.init = function() { 27 | //initialize chart 28 | speedChart = new google.visualization.ChartWrapper({ 29 | chartType: 'LineChart', 30 | containerId: 'speedChart', 31 | options: { 32 | vAxes: [ 33 | {title: 'Speed km/h', titleTextStyle: {color: 'black'}, maxValue: 10}, // Left axis 34 | {title: 'Alititude m', titleTextStyle: {color: 'black'}, maxValue: 20} // Right axis 35 | ], 36 | series: [ 37 | {targetAxisIndex: 0}, 38 | {targetAxisIndex: 1} 39 | ], 40 | hAxis: { 41 | viewWindow: { 42 | min: 0 43 | } 44 | }, 45 | tooltip: { trigger: 'selection' } 46 | } 47 | }); 48 | 49 | //wait for ready event so that we can subscript to mouse over events 50 | google.visualization.events.addListener(speedChart, 'ready', function() { 51 | 52 | google.visualization.events.addListener(speedChart.getChart(), 'select', function() { 53 | var chart = speedChart.getChart(), 54 | selection = chart.getSelection(), 55 | row = selection.length ? selection[0].row : undefined; 56 | if (typeof(row) !== "undefined") { 57 | map.panTo(trackLineMarkers[row].getPosition()); 58 | highlightMarker(trackLineMarkers[row]) 59 | } else { 60 | highlightMarker(null); 61 | } 62 | }); 63 | }); 64 | 65 | self.topicIds.subscribe(function(newvalue) { 66 | getSessions(newvalue); 67 | }); 68 | self.sessionIds.subscribe(function(newvalue) { 69 | updateTrackData(newvalue); 70 | }); 71 | //kick off dom updates 72 | self.trackData.subscribe(function(value) { 73 | drawTracks(value); 74 | drawSpeedChart(value); 75 | }); 76 | 77 | //xhr list topics 78 | getTopics(); 79 | }; // end init 80 | 81 | self.refresh = function() { 82 | updateTrackData(self.currentTrackId); 83 | }; 84 | 85 | self.deleteSelectedSession = function() { 86 | console.log("I would delete session " + self.sessionIds()); 87 | //deleteSession(self.sessionIds()); 88 | }; 89 | 90 | function deleteSession(sessionId) { 91 | $.ajax({ 92 | url: '/api/sessions/delete/'+sessionId, 93 | dataType: 'json' 94 | }) 95 | .done(function() { 96 | getTopics(); 97 | }) 98 | .fail(function() { 99 | alert("failed.") 100 | }); 101 | } 102 | 103 | function getSessions(topicId) { 104 | self.sessionItems.removeAll(); 105 | $.ajax({ 106 | url: '/api/sessions/'+topicId, 107 | dataType: 'json' 108 | }) 109 | .done(function(data) { 110 | $.each(data, function(i, item) { 111 | self.sessionItems.push({ 112 | text: new Date(item.date), 113 | id: item.id 114 | }); 115 | }); 116 | }) 117 | .fail(function() { 118 | alert("failed.") 119 | }); 120 | } 121 | 122 | function getTopics() { 123 | self.topicItems.removeAll(); 124 | $.ajax({ 125 | url: '/api/topics', 126 | dataType: 'json' 127 | }) 128 | .done(function(data) { 129 | $.each(data, function(i, item) { 130 | self.topicItems.push({ 131 | text: item.name, 132 | id: item.id 133 | }); 134 | }); 135 | }) 136 | .fail(function() { 137 | alert("failed.") 138 | }); 139 | } 140 | 141 | function updateTrackData(trackId) { 142 | $.ajax({ 143 | url: '/api/tracks/' + trackId, 144 | dataType: 'json' 145 | }) 146 | .done(function(data) { 147 | self.currentTrackId = trackId; 148 | self.trackData.removeAll(); 149 | $.each(data, function(i, item) { 150 | self.trackData.push(item); 151 | }); 152 | }) 153 | .fail(function() { 154 | alert("failed.") 155 | }); 156 | } 157 | 158 | //xhr to get paths for trackline, draw direction arrows, fit to bounds 159 | function drawTracks(tracks) { 160 | var path = []; 161 | bounds = new google.maps.LatLngBounds(); 162 | removeTrackLine(trackLine); 163 | $.each(tracks, function(i, item) { 164 | var latLng = new google.maps.LatLng(item.lat,item.lon), 165 | marker = new MarkerWithLabel({ 166 | position: latLng, 167 | icon: { 168 | path: google.maps.SymbolPath.FORWARD_CLOSED_ARROW, 169 | rotation: item.cog, 170 | scale: 2, 171 | strokeColor: 'green', 172 | fillColor: 'green', 173 | fillOpacity: 1 174 | }, 175 | _index: i 176 | }); 177 | google.maps.event.addListener(marker, 'mouseover', function() { 178 | //index is row number of dataTable for chart 179 | speedChart.getChart().setSelection([{row: this._index, column:1}]); 180 | highlightMarker(this); 181 | }); 182 | google.maps.event.addListener(marker, 'mouseout', function() { 183 | speedChart.getChart().setSelection(null); 184 | highlightMarker(null); 185 | }); 186 | //keep reference to each marker 187 | trackLineMarkers.push(marker); 188 | //keep array of latlngs for draw line 189 | path.push(latLng); 190 | //push bounds for each latlng to ensure map view port contains marker 191 | bounds.extend(latLng); 192 | }); 193 | //draw line 194 | drawTrackLine(path); 195 | //ensure map contains all markers 196 | map.fitBounds(bounds); 197 | } 198 | 199 | //draw highlight around marker 200 | function highlightMarker(marker) { 201 | function doHighLight(m) { 202 | if (m) { 203 | var icon = m.getIcon(); 204 | icon.scale = icon.scale == 2 ? 4 : 2; 205 | icon.strokeColor = icon.strokeColor == 'green' ? 'red' : 'green' 206 | m.setIcon(icon); 207 | } 208 | return m; 209 | } 210 | doHighLight(trackLineHighlightMarker); 211 | trackLineHighlightMarker = doHighLight(marker); 212 | } 213 | 214 | //draw trackline on map 215 | function drawTrackLine(path) { 216 | trackLine = new google.maps.Polyline({ 217 | path: path, 218 | strokeColor: 'green', 219 | fillColor: 'green', 220 | fillOpacity: .7, 221 | strokeWeight: 3, 222 | strokeOpacity:.65 223 | }); 224 | $.each(trackLineMarkers, function(i,item) { 225 | item.setMap(map); 226 | }); 227 | trackLine.setMap(map); 228 | } 229 | 230 | //remove track line from map 231 | function removeTrackLine(path) { 232 | if (trackLine) { 233 | trackLine.setMap(null); 234 | } 235 | $.each(trackLineMarkers, function(i,item) { 236 | item.setMap(null); 237 | }); 238 | trackLineMarkers = []; 239 | } 240 | 241 | /** 242 | * Draw a chart plotting speed, time, altitude 243 | * @param el 244 | * @param trackId 245 | */ 246 | 247 | function drawSpeedChart(trackData) { 248 | var dataTable = new google.visualization.DataTable(), 249 | monthYearFormatter, 250 | startTime = 0; 251 | //TODO: fix this dependency 252 | //WARNING: dont forget to change columns indexes in hover over if you much wiht columns 253 | //tooltip must follow applicable column 254 | dataTable.addColumn('number', 'Date'); 255 | dataTable.addColumn('number', 'Speed'); 256 | dataTable.addColumn({type: 'string', role: 'tooltip'}); 257 | dataTable.addColumn('number', 'Altitude'); 258 | dataTable.addColumn({type: 'string', role: 'tooltip'}); 259 | //add lat/lon for hover over event markers on map 260 | dataTable.addColumn('number', 'lat'); 261 | dataTable.addColumn('number', 'lon'); 262 | 263 | 264 | $.each(trackData, function (i, item) { 265 | if (i == 0) startTime = item.tst; 266 | var minutes = (item.tst - startTime) / 60; 267 | dataTable.addRow([ minutes , item.vel, getSpeedChartToolTip(item, {minutes: minutes}), item.alt, getSpeedChartToolTip(item, {minutes: minutes}), item.lat, item.lon]) 268 | 269 | }); 270 | 271 | speedChart.setDataTable(dataTable); 272 | speedChart.setView({ 273 | columns: [0,1,2,3,4] 274 | }); 275 | speedChart.draw(); 276 | } 277 | 278 | //TODO: replace with HTML tooltip 279 | function getSpeedChartToolTip(item,extra) { 280 | return "Date: " + new Date(item.tst*1000) + "\n" + 281 | "Time: " + Math.floor(extra.minutes) + "\n" + 282 | "Velocity: " + item.vel + "km/h (" + Math.floor(item.vel * .621371) + " mph)\n" + 283 | "Altitude: " + item.alt + "m (" + Math.floor(item.alt * 3.28084) + "ft)"; 284 | } 285 | 286 | } -------------------------------------------------------------------------------- /src/main/resources/static/js/knockstrap.js: -------------------------------------------------------------------------------- 1 | /*! knockstrap 1.2.1 | (c) 2014 Artem Stepanyuk | http://www.opensource.org/licenses/mit-license */ 2 | 3 | (function (moduleName, factory) { 4 | 'use strict'; 5 | 6 | if (typeof require === 'function' && typeof exports === 'object' && typeof module === 'object') { 7 | // CommonJS/Node.js 8 | factory(require('knockout'), require('jquery')); 9 | } else if (typeof define === 'function' && define.amd) { 10 | // AMD 11 | define(moduleName, ['knockout', 'jquery'], factory); 12 | } else { 13 | factory(ko, $); 14 | } 15 | 16 | })('knockstrap', function (ko, $) { 17 | 'use strict'; 18 | 19 | ko.utils.uniqueId = (function () { 20 | 21 | var prefixesCounts = { 22 | 'ks-unique-': 0 23 | }; 24 | 25 | return function (prefix) { 26 | prefix = prefix || 'ks-unique-'; 27 | 28 | if (!prefixesCounts[prefix]) { 29 | prefixesCounts[prefix] = 0; 30 | } 31 | 32 | return prefix + prefixesCounts[prefix]++; 33 | }; 34 | })(); 35 | ko.utils.unwrapProperties = function (wrappedProperies) { 36 | 37 | if (wrappedProperies === null || typeof wrappedProperies !== 'object') { 38 | return wrappedProperies; 39 | } 40 | 41 | var options = {}; 42 | 43 | ko.utils.objectForEach(wrappedProperies, function (propertyName, propertyValue) { 44 | options[propertyName] = ko.unwrap(propertyValue); 45 | }); 46 | 47 | return options; 48 | }; 49 | 50 | // inspired by http://www.knockmeout.net/2011/10/ko-13-preview-part-3-template-sources.html 51 | (function () { 52 | // storage of string templates for all instances of stringTemplateEngine 53 | var templates = {}; 54 | 55 | templates.alert="
"; 56 | templates.alertInner="

"; 57 | templates.carousel="
"; 58 | templates.carouselContent="
"; 59 | templates.carouselControls=" "; 60 | templates.carouselIndicators="
"; 61 | templates.modal="
"; 62 | templates.modalBody="
"; 63 | templates.modalFooter=" "; 64 | templates.modalHeader="

"; 65 | templates.progress="
%
"; 66 | 67 | 68 | // create new template source to provide storing string templates in storage 69 | ko.templateSources.stringTemplate = function (template) { 70 | this.templateName = template; 71 | 72 | this.data = function (key, value) { 73 | templates.data = templates.data || {}; 74 | templates.data[this.templateName] = templates.data[this.templateName] || {}; 75 | 76 | if (arguments.length === 1) { 77 | return templates.data[this.templateName][key]; 78 | } 79 | 80 | templates.data[this.templateName][key] = value; 81 | }; 82 | 83 | this.text = function (value) { 84 | if (arguments.length === 0) { 85 | return templates[this.templateName]; 86 | } 87 | 88 | templates[this.templateName] = value; 89 | }; 90 | }; 91 | 92 | // create modified template engine, which uses new string template source 93 | ko.stringTemplateEngine = function () { 94 | this.allowTemplateRewriting = false; 95 | }; 96 | 97 | ko.stringTemplateEngine.prototype = new ko.nativeTemplateEngine(); 98 | ko.stringTemplateEngine.prototype.constructor = ko.stringTemplateEngine; 99 | 100 | ko.stringTemplateEngine.prototype.makeTemplateSource = function (template) { 101 | return new ko.templateSources.stringTemplate(template); 102 | }; 103 | 104 | ko.stringTemplateEngine.prototype.getTemplate = function (name) { 105 | return templates[name]; 106 | }; 107 | 108 | ko.stringTemplateEngine.prototype.addTemplate = function (name, template) { 109 | if (arguments.length < 2) { 110 | throw new Error('template is not provided'); 111 | } 112 | 113 | templates[name] = template; 114 | }; 115 | 116 | ko.stringTemplateEngine.prototype.removeTemplate = function (name) { 117 | if (!name) { 118 | throw new Error('template name is not provided'); 119 | } 120 | 121 | delete templates[name]; 122 | }; 123 | 124 | ko.stringTemplateEngine.prototype.isTemplateExist = function (name) { 125 | return !!templates[name]; 126 | }; 127 | 128 | ko.stringTemplateEngine.instance = new ko.stringTemplateEngine(); 129 | })(); 130 | 131 | 132 | ko.bindingHandlers.alert = { 133 | init: function () { 134 | return { controlsDescendantBindings: true }; 135 | }, 136 | 137 | update: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { 138 | var $element = $(element), 139 | value = valueAccessor(), 140 | usedTemplateEngine = !value.template ? ko.stringTemplateEngine.instance : null, 141 | userTemplate = ko.unwrap(value.template) || 'alertInner', 142 | template, data; 143 | 144 | // for compatibility with ie8, use '1' and '8' values for node types 145 | if (element.nodeType === (Node.ELEMENT_NODE || 1)) { 146 | template = userTemplate; 147 | data = value.data || { message: value.message }; 148 | 149 | $element.addClass('alert fade in').addClass('alert-' + (ko.unwrap(value.type) || 'info')); 150 | } else if (element.nodeType === (Node.COMMENT_NODE || 8)) { 151 | template = 'alert'; 152 | data = { 153 | innerTemplate: { 154 | name: userTemplate , 155 | data: value.data || { message: value.message }, 156 | templateEngine: usedTemplateEngine 157 | }, 158 | type: 'alert-' + (ko.unwrap(value.type) || 'info') 159 | }; 160 | } else { 161 | throw new Error('alert binding should be used with dom elements or ko virtual elements'); 162 | } 163 | 164 | ko.renderTemplate(template, bindingContext.createChildContext(data), ko.utils.extend({ templateEngine: usedTemplateEngine }, value.templateOptions), element); 165 | } 166 | }; 167 | 168 | ko.virtualElements.allowedBindings.alert = true; 169 | ko.bindingHandlers.carousel = { 170 | 171 | defaults: { 172 | css: 'carousel slide', 173 | 174 | controlsTemplate: { 175 | name: 'carouselControls', 176 | templateEngine: ko.stringTemplateEngine.instance, 177 | dataConverter: function(value) { 178 | return { 179 | id: ko.computed(function() { 180 | return '#' + ko.unwrap(value.id); 181 | }) 182 | }; 183 | } 184 | }, 185 | 186 | indicatorsTemplate: { 187 | name: 'carouselIndicators', 188 | templateEngine: ko.stringTemplateEngine.instance, 189 | dataConverter: function(value) { 190 | return { 191 | id: ko.computed(function() { 192 | return '#' + ko.unwrap(value.id); 193 | }), 194 | 195 | items: value.content.data 196 | }; 197 | } 198 | }, 199 | 200 | itemTemplate: { 201 | name: 'carouselContent', 202 | templateEngine: ko.stringTemplateEngine.instance, 203 | 204 | converter: function (item) { 205 | return item; 206 | } 207 | } 208 | }, 209 | 210 | init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { 211 | var $element = $(element), 212 | value = valueAccessor(), 213 | defaults = ko.bindingHandlers.carousel.defaults, 214 | extendDefaults = function(defs, type) { 215 | var extended = { 216 | name: defs.name, 217 | data: (value[type] && (value[type].data || value[type].dataConverter && value[type].dataConverter(value))) || defs.dataConverter(value), 218 | }; 219 | 220 | extended = $.extend(true, {}, extended, value[type]); 221 | if (!value[type] || !value[type].name) { 222 | extended.templateEngine = defs.templateEngine; 223 | } 224 | 225 | return extended; 226 | }; 227 | 228 | if (!value.content) { 229 | throw new Error('content option is required for carousel binding'); 230 | } 231 | 232 | // get carousel id from 'id' attribute, or from binding options, or generate it 233 | if (element.id) { 234 | value.id = element.id; 235 | } else if (value.id) { 236 | element.id = ko.unwrap(value.id); 237 | } else { 238 | element.id = value.id = ko.utils.uniqueId('ks-carousel-'); 239 | } 240 | 241 | var model = { 242 | id: value.id, 243 | controlsTemplate: extendDefaults(defaults.controlsTemplate, 'controls'), 244 | indicatorsTemplate: extendDefaults(defaults.indicatorsTemplate, 'indicators'), 245 | 246 | items: value.content.data, 247 | converter: value.content.converter || defaults.itemTemplate.converter, 248 | itemTemplateName: value.content.name || defaults.itemTemplate.name, 249 | templateEngine: !value.content.name ? defaults.itemTemplate.templateEngine : null, 250 | afterRender: value.content.afterRender, 251 | afterAdd: value.content.afterAdd, 252 | beforeRemove: value.content.beforeRemove 253 | }; 254 | 255 | ko.renderTemplate('carousel', bindingContext.createChildContext(model), { templateEngine: ko.stringTemplateEngine.instance }, element); 256 | 257 | $element.addClass(defaults.css); 258 | 259 | return { controlsDescendantBindings: true }; 260 | }, 261 | 262 | update: function (element, valueAccessor) { 263 | var value = valueAccessor(), 264 | options = ko.unwrap(value.options); 265 | 266 | $(element).carousel(options); 267 | } 268 | }; 269 | // Knockout checked binding doesn't work with Bootstrap checkboxes 270 | ko.bindingHandlers.checkbox = { 271 | init: function (element, valueAccessor) { 272 | var $element = $(element), 273 | handler = function (e) { 274 | // we need to handle change event after bootsrap will handle its event 275 | // to prevent incorrect changing of checkbox state 276 | setTimeout(function() { 277 | var $checkbox = $(e.target), 278 | value = valueAccessor(), 279 | data = $checkbox.val(), 280 | isChecked = $checkbox.parent().hasClass('active'); 281 | 282 | if (ko.unwrap(value) instanceof Array) { 283 | var index = ko.unwrap(value).indexOf(data); 284 | 285 | if (isChecked && (index === -1)) { 286 | value.push(data); 287 | } else if (!isChecked && (index !== -1)) { 288 | value.splice(index, 1); 289 | } 290 | } else { 291 | value(isChecked); 292 | } 293 | }, 0); 294 | }; 295 | 296 | if ($element.attr('data-toggle') === 'buttons' && $element.find('input:checkbox').length) { 297 | 298 | if (!(ko.unwrap(valueAccessor()) instanceof Array)) { 299 | throw new Error('checkbox binding should be used only with array or observableArray values in this case'); 300 | } 301 | 302 | $element.on('change', 'input:checkbox', handler); 303 | } else if ($element.attr('type') === 'checkbox') { 304 | 305 | if (!ko.isObservable(valueAccessor())) { 306 | throw new Error('checkbox binding should be used only with observable values in this case'); 307 | } 308 | 309 | $element.on('change', handler); 310 | } else { 311 | throw new Error('checkbox binding should be used only with bootstrap checkboxes'); 312 | } 313 | }, 314 | 315 | update: function (element, valueAccessor) { 316 | var $element = $(element), 317 | value = ko.unwrap(valueAccessor()), 318 | isChecked; 319 | 320 | if (value instanceof Array) { 321 | if ($element.attr('data-toggle') === 'buttons') { 322 | $element.find('input:checkbox').each(function (index, el) { 323 | isChecked = value.indexOf(el.value) !== -1; 324 | $(el).parent().toggleClass('active', isChecked); 325 | el.checked = isChecked; 326 | }); 327 | } else { 328 | isChecked = value.indexOf($element.val()) !== -1; 329 | $element.toggleClass('active', isChecked); 330 | $element.find('input').prop('checked', isChecked); 331 | } 332 | } else { 333 | isChecked = !!value; 334 | $element.prop('checked', isChecked); 335 | $element.parent().toggleClass('active', isChecked); 336 | } 337 | } 338 | }; 339 | ko.bindingHandlers.modal = { 340 | defaults: { 341 | css: 'modal fade', 342 | dialogCss: '', 343 | attributes: { 344 | role: 'dialog' 345 | }, 346 | 347 | headerTemplate: { 348 | name: 'modalHeader', 349 | templateEngine: ko.stringTemplateEngine.instance 350 | }, 351 | 352 | bodyTemplate: { 353 | name: 'modalBody', 354 | templateEngine: ko.stringTemplateEngine.instance 355 | }, 356 | 357 | footerTemplate: { 358 | name: 'modalFooter', 359 | templateEngine: ko.stringTemplateEngine.instance, 360 | data: { 361 | closeLabel: 'Close', 362 | primaryLabel: 'Ok' 363 | } 364 | } 365 | }, 366 | 367 | init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { 368 | var $element = $(element), 369 | value = valueAccessor(), 370 | defaults = ko.bindingHandlers.modal.defaults, 371 | options = ko.utils.extend({ show: $element.data().show || false }, ko.utils.unwrapProperties(value.options)), 372 | extendDefaults = function (defs, val) { 373 | var extended = { 374 | name: defs.name, 375 | data: defs.data, 376 | }; 377 | 378 | // reassign to not overwrite default content of data property 379 | extended = $.extend(true, {}, extended, val); 380 | if (!val || !val.name) { 381 | extended.templateEngine = defs.templateEngine; 382 | } 383 | 384 | return extended; 385 | }; 386 | 387 | if (!value.header || !value.body) { 388 | throw new Error('header and body options are required for modal binding.'); 389 | } 390 | 391 | // fix for not working escape button 392 | if (options.keyboard || typeof options.keyboard === 'undefined') { 393 | $element.attr('tabindex', -1); 394 | } 395 | 396 | var model = { 397 | dialogCss: value.dialogCss || defaults.dialogCss, 398 | headerTemplate: extendDefaults(defaults.headerTemplate, ko.unwrap(value.header)), 399 | bodyTemplate: extendDefaults(defaults.bodyTemplate, ko.unwrap(value.body)), 400 | footerTemplate: value.footer ? extendDefaults(defaults.footerTemplate, ko.unwrap(value.footer)) : null 401 | }; 402 | 403 | ko.renderTemplate('modal', bindingContext.createChildContext(model), { templateEngine: ko.stringTemplateEngine.instance }, element); 404 | 405 | $element.addClass(defaults.css).attr(defaults.attributes); 406 | $element.modal(options); 407 | 408 | $element.on('shown.bs.modal', function () { 409 | if (typeof value.visible !== 'undefined' && typeof value.visible === 'function' && !ko.isComputed(value.visible)) { 410 | value.visible(true); 411 | } 412 | 413 | $(this).find("[autofocus]:first").focus(); 414 | }); 415 | 416 | if (typeof value.visible !== 'undefined' && typeof value.visible === 'function' && !ko.isComputed(value.visible)) { 417 | $element.on('hidden.bs.modal', function() { 418 | value.visible(false); 419 | }); 420 | 421 | // if we need to show modal after initialization, we need also set visible property to true 422 | if (options.show) { 423 | value.visible(true); 424 | } 425 | } 426 | 427 | return { controlsDescendantBindings: true }; 428 | }, 429 | 430 | update: function (element, valueAccessor) { 431 | var value = valueAccessor(); 432 | 433 | if (typeof value.visible !== 'undefined') { 434 | $(element).modal(!ko.unwrap(value.visible) ? 'hide' : 'show'); 435 | } 436 | } 437 | }; 438 | var popoverDomDataTemplateKey = '__popoverTemplateKey__'; 439 | 440 | ko.bindingHandlers.popover = { 441 | 442 | init: function (element) { 443 | var $element = $(element); 444 | 445 | ko.utils.domNodeDisposal.addDisposeCallback(element, function () { 446 | if ($element.data('bs.popover')) { 447 | $element.popover('destroy'); 448 | } 449 | }); 450 | }, 451 | 452 | update: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { 453 | var $element = $(element), 454 | value = ko.unwrap(valueAccessor()), 455 | options = (!value.options && !value.template ? ko.utils.unwrapProperties(value) : ko.utils.unwrapProperties(value.options)) || {}; 456 | 457 | if (value.template) { 458 | // use unwrap to track dependency from template, if it is observable 459 | ko.unwrap(value.template); 460 | 461 | var id = ko.utils.domData.get(element, popoverDomDataTemplateKey), 462 | data = ko.unwrap(value.data); 463 | 464 | var renderPopoverTemplate = function () { 465 | // use unwrap again to get correct template value instead of old value from closure 466 | // this works for observable template property 467 | ko.renderTemplate(ko.unwrap(value.template), bindingContext.createChildContext(data), value.templateOptions, document.getElementById(id)); 468 | 469 | // bootstrap's popover calculates position before template renders, 470 | // so we recalculate position, using bootstrap methods 471 | var $popover = $('#' + id).parents('.popover'), 472 | popoverMethods = $element.data('bs.popover'), 473 | offset = popoverMethods.getCalculatedOffset(options.placement || 'right', popoverMethods.getPosition(), $popover.outerWidth(), $popover.outerHeight()); 474 | 475 | popoverMethods.applyPlacement(offset, options.placement || 'right'); 476 | }; 477 | 478 | // if there is no generated id - popover executes first time for this element 479 | if (!id) { 480 | id = ko.utils.uniqueId('ks-popover-'); 481 | ko.utils.domData.set(element, popoverDomDataTemplateKey, id); 482 | 483 | // place template rendering after popover is shown, because we don't have root element for template before that 484 | $element.on('shown.bs.popover', renderPopoverTemplate); 485 | } 486 | 487 | options.content = '
'; 488 | options.html = true; 489 | 490 | // support rerendering of template, if observable changes, when popover is opened 491 | if ($('#' + id).is(':visible')) { 492 | renderPopoverTemplate(); 493 | } 494 | } 495 | 496 | var popoverData = $element.data('bs.popover'); 497 | 498 | if (!popoverData) { 499 | $element.popover(options); 500 | 501 | $element.on('shown.bs.popover', function () { 502 | (options.container ? $(options.container) : $element.parent()).one('click', '[data-dismiss="popover"]', function () { 503 | $element.popover('hide'); 504 | }); 505 | }); 506 | } else { 507 | ko.utils.extend(popoverData.options, options); 508 | } 509 | } 510 | }; 511 | ko.bindingHandlers.progress = { 512 | defaults: { 513 | css: 'progress', 514 | text: '', 515 | textHidden: true, 516 | striped: false, 517 | type: '', 518 | animated: false 519 | }, 520 | 521 | init: function (element, valueAccessor) { 522 | var $element = $(element), 523 | value = valueAccessor(), 524 | unwrappedValue = ko.unwrap(value), 525 | defs = ko.bindingHandlers.progress.defaults, 526 | model = $.extend({}, defs, unwrappedValue); 527 | 528 | if (typeof unwrappedValue === 'number') { 529 | model.value = value; 530 | 531 | model.barWidth = ko.computed(function() { 532 | return ko.unwrap(value) + '%'; 533 | }); 534 | } else if (typeof ko.unwrap(unwrappedValue.value) === 'number') { 535 | model.barWidth = ko.computed(function() { 536 | return ko.unwrap(unwrappedValue.value) + '%'; 537 | }); 538 | } else { 539 | throw new Error('progress binding can accept only numbers or objects with "value" number propertie'); 540 | } 541 | 542 | model.innerCss = ko.computed(function () { 543 | var values = ko.utils.unwrapProperties(unwrappedValue), 544 | css = ''; 545 | 546 | if (values.animated) { 547 | css += 'active '; 548 | } 549 | 550 | if (values.striped) { 551 | css += 'progress-bar-striped '; 552 | } 553 | 554 | if (values.type) { 555 | css += 'progress-bar-' + values.type; 556 | } 557 | 558 | return css; 559 | }); 560 | 561 | ko.renderTemplate('progress', model, { templateEngine: ko.stringTemplateEngine.instance }, element); 562 | 563 | $element.addClass(defs.css); 564 | 565 | return { controlsDescendantBindings: true }; 566 | }, 567 | }; 568 | 569 | // Knockout checked binding doesn't work with Bootstrap radio-buttons 570 | ko.bindingHandlers.radio = { 571 | init: function (element, valueAccessor) { 572 | 573 | if (!ko.isObservable(valueAccessor())) { 574 | throw new Error('radio binding should be used only with observable values'); 575 | } 576 | 577 | $(element).on('change', 'input:radio', function (e) { 578 | // we need to handle change event after bootsrap will handle its event 579 | // to prevent incorrect changing of radio button styles 580 | setTimeout(function() { 581 | var radio = $(e.target), 582 | value = valueAccessor(), 583 | newValue = radio.val(); 584 | 585 | value(newValue); 586 | }, 0); 587 | }); 588 | }, 589 | 590 | update: function (element, valueAccessor) { 591 | var $radioButton = $(element).find('input[value="' + ko.unwrap(valueAccessor()) + '"]'), 592 | $radioButtonWrapper; 593 | 594 | if ($radioButton.length) { 595 | $radioButtonWrapper = $radioButton.parent(); 596 | 597 | $radioButtonWrapper.siblings().removeClass('active'); 598 | $radioButtonWrapper.addClass('active'); 599 | 600 | $radioButton.prop('checked', true); 601 | } else { 602 | $radioButtonWrapper = $(element).find('.active'); 603 | $radioButtonWrapper.removeClass('active'); 604 | $radioButtonWrapper.find('input').prop('checked', false); 605 | } 606 | } 607 | }; 608 | ko.bindingHandlers.toggle = { 609 | init: function (element, valueAccessor) { 610 | var value = valueAccessor(); 611 | 612 | if (!ko.isObservable(value)) { 613 | throw new Error('toggle binding should be used only with observable values'); 614 | } 615 | 616 | $(element).on('click', function (event) { 617 | event.preventDefault(); 618 | 619 | var previousValue = ko.unwrap(value); 620 | value(!previousValue); 621 | }); 622 | }, 623 | 624 | update: function (element, valueAccessor) { 625 | ko.utils.toggleDomNodeCssClass(element, 'active', ko.unwrap(valueAccessor())); 626 | } 627 | }; 628 | 629 | ko.bindingHandlers.tooltip = { 630 | init: function (element) { 631 | var $element = $(element); 632 | 633 | ko.utils.domNodeDisposal.addDisposeCallback(element, function () { 634 | if ($element.data('bs.tooltip')) { 635 | $element.tooltip('destroy'); 636 | } 637 | }); 638 | }, 639 | 640 | update: function (element, valueAccessor) { 641 | var $element = $(element), 642 | value = ko.unwrap(valueAccessor()), 643 | options = ko.utils.unwrapProperties(value); 644 | 645 | var tooltipData = $element.data('bs.tooltip'); 646 | 647 | if (!tooltipData) { 648 | $element.tooltip(options); 649 | } else { 650 | ko.utils.extend(tooltipData.options, options); 651 | } 652 | } 653 | }; 654 | 655 | }); 656 | -------------------------------------------------------------------------------- /src/main/resources/static/js/knockstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! knockstrap 1.2.1 | (c) 2014 Artem Stepanyuk | http://www.opensource.org/licenses/mit-license */ 2 | !function(a,b){"use strict";"function"==typeof require&&"object"==typeof exports&&"object"==typeof module?b(require("knockout"),require("jquery")):"function"==typeof define&&define.amd?define(a,["knockout","jquery"],b):b(ko,$)}("knockstrap",function(a,b){"use strict";a.utils.uniqueId=function(){var a={"ks-unique-":0};return function(b){return b=b||"ks-unique-",a[b]||(a[b]=0),b+a[b]++}}(),a.utils.unwrapProperties=function(b){if(null===b||"object"!=typeof b)return b;var c={};return a.utils.objectForEach(b,function(b,d){c[b]=a.unwrap(d)}),c},function(){var b={};b.alert='
',b.alertInner='

',b.carousel=' ',b.carouselContent='
',b.carouselControls=' ',b.carouselIndicators=' ',b.modal='',b.modalBody='
',b.modalFooter=' ',b.modalHeader='

',b.progress='
%
',a.templateSources.stringTemplate=function(a){this.templateName=a,this.data=function(a,c){return b.data=b.data||{},b.data[this.templateName]=b.data[this.templateName]||{},1===arguments.length?b.data[this.templateName][a]:(b.data[this.templateName][a]=c,void 0)},this.text=function(a){return 0===arguments.length?b[this.templateName]:(b[this.templateName]=a,void 0)}},a.stringTemplateEngine=function(){this.allowTemplateRewriting=!1},a.stringTemplateEngine.prototype=new a.nativeTemplateEngine,a.stringTemplateEngine.prototype.constructor=a.stringTemplateEngine,a.stringTemplateEngine.prototype.makeTemplateSource=function(b){return new a.templateSources.stringTemplate(b)},a.stringTemplateEngine.prototype.getTemplate=function(a){return b[a]},a.stringTemplateEngine.prototype.addTemplate=function(a,c){if(arguments.length<2)throw new Error("template is not provided");b[a]=c},a.stringTemplateEngine.prototype.removeTemplate=function(a){if(!a)throw new Error("template name is not provided");delete b[a]},a.stringTemplateEngine.prototype.isTemplateExist=function(a){return!!b[a]},a.stringTemplateEngine.instance=new a.stringTemplateEngine}(),a.bindingHandlers.alert={init:function(){return{controlsDescendantBindings:!0}},update:function(c,d,e,f,g){var h,i,j=b(c),k=d(),l=k.template?null:a.stringTemplateEngine.instance,m=a.unwrap(k.template)||"alertInner";if(c.nodeType===(Node.ELEMENT_NODE||1))h=m,i=k.data||{message:k.message},j.addClass("alert fade in").addClass("alert-"+(a.unwrap(k.type)||"info"));else{if(c.nodeType!==(Node.COMMENT_NODE||8))throw new Error("alert binding should be used with dom elements or ko virtual elements");h="alert",i={innerTemplate:{name:m,data:k.data||{message:k.message},templateEngine:l},type:"alert-"+(a.unwrap(k.type)||"info")}}a.renderTemplate(h,g.createChildContext(i),a.utils.extend({templateEngine:l},k.templateOptions),c)}},a.virtualElements.allowedBindings.alert=!0,a.bindingHandlers.carousel={defaults:{css:"carousel slide",controlsTemplate:{name:"carouselControls",templateEngine:a.stringTemplateEngine.instance,dataConverter:function(b){return{id:a.computed(function(){return"#"+a.unwrap(b.id)})}}},indicatorsTemplate:{name:"carouselIndicators",templateEngine:a.stringTemplateEngine.instance,dataConverter:function(b){return{id:a.computed(function(){return"#"+a.unwrap(b.id)}),items:b.content.data}}},itemTemplate:{name:"carouselContent",templateEngine:a.stringTemplateEngine.instance,converter:function(a){return a}}},init:function(c,d,e,f,g){var h=b(c),i=d(),j=a.bindingHandlers.carousel.defaults,k=function(a,c){var d={name:a.name,data:i[c]&&(i[c].data||i[c].dataConverter&&i[c].dataConverter(i))||a.dataConverter(i)};return d=b.extend(!0,{},d,i[c]),i[c]&&i[c].name||(d.templateEngine=a.templateEngine),d};if(!i.content)throw new Error("content option is required for carousel binding");c.id?i.id=c.id:c.id=i.id?a.unwrap(i.id):i.id=a.utils.uniqueId("ks-carousel-");var l={id:i.id,controlsTemplate:k(j.controlsTemplate,"controls"),indicatorsTemplate:k(j.indicatorsTemplate,"indicators"),items:i.content.data,converter:i.content.converter||j.itemTemplate.converter,itemTemplateName:i.content.name||j.itemTemplate.name,templateEngine:i.content.name?null:j.itemTemplate.templateEngine,afterRender:i.content.afterRender,afterAdd:i.content.afterAdd,beforeRemove:i.content.beforeRemove};return a.renderTemplate("carousel",g.createChildContext(l),{templateEngine:a.stringTemplateEngine.instance},c),h.addClass(j.css),{controlsDescendantBindings:!0}},update:function(c,d){var e=d(),f=a.unwrap(e.options);b(c).carousel(f)}},a.bindingHandlers.checkbox={init:function(c,d){var e=b(c),f=function(c){setTimeout(function(){var e=b(c.target),f=d(),g=e.val(),h=e.parent().hasClass("active");if(a.unwrap(f)instanceof Array){var i=a.unwrap(f).indexOf(g);h&&-1===i?f.push(g):h||-1===i||f.splice(i,1)}else f(h)},0)};if("buttons"===e.attr("data-toggle")&&e.find("input:checkbox").length){if(!(a.unwrap(d())instanceof Array))throw new Error("checkbox binding should be used only with array or observableArray values in this case");e.on("change","input:checkbox",f)}else{if("checkbox"!==e.attr("type"))throw new Error("checkbox binding should be used only with bootstrap checkboxes");if(!a.isObservable(d()))throw new Error("checkbox binding should be used only with observable values in this case");e.on("change",f)}},update:function(c,d){var e,f=b(c),g=a.unwrap(d());g instanceof Array?"buttons"===f.attr("data-toggle")?f.find("input:checkbox").each(function(a,c){e=-1!==g.indexOf(c.value),b(c).parent().toggleClass("active",e),c.checked=e}):(e=-1!==g.indexOf(f.val()),f.toggleClass("active",e),f.find("input").prop("checked",e)):(e=!!g,f.prop("checked",e),f.parent().toggleClass("active",e))}},a.bindingHandlers.modal={defaults:{css:"modal fade",dialogCss:"",attributes:{role:"dialog"},headerTemplate:{name:"modalHeader",templateEngine:a.stringTemplateEngine.instance},bodyTemplate:{name:"modalBody",templateEngine:a.stringTemplateEngine.instance},footerTemplate:{name:"modalFooter",templateEngine:a.stringTemplateEngine.instance,data:{closeLabel:"Close",primaryLabel:"Ok"}}},init:function(c,d,e,f,g){var h=b(c),i=d(),j=a.bindingHandlers.modal.defaults,k=a.utils.extend({show:h.data().show||!1},a.utils.unwrapProperties(i.options)),l=function(a,c){var d={name:a.name,data:a.data};return d=b.extend(!0,{},d,c),c&&c.name||(d.templateEngine=a.templateEngine),d};if(!i.header||!i.body)throw new Error("header and body options are required for modal binding.");(k.keyboard||"undefined"==typeof k.keyboard)&&h.attr("tabindex",-1);var m={dialogCss:i.dialogCss||j.dialogCss,headerTemplate:l(j.headerTemplate,a.unwrap(i.header)),bodyTemplate:l(j.bodyTemplate,a.unwrap(i.body)),footerTemplate:i.footer?l(j.footerTemplate,a.unwrap(i.footer)):null};return a.renderTemplate("modal",g.createChildContext(m),{templateEngine:a.stringTemplateEngine.instance},c),h.addClass(j.css).attr(j.attributes),h.modal(k),h.on("shown.bs.modal",function(){"undefined"==typeof i.visible||"function"!=typeof i.visible||a.isComputed(i.visible)||i.visible(!0),b(this).find("[autofocus]:first").focus()}),"undefined"==typeof i.visible||"function"!=typeof i.visible||a.isComputed(i.visible)||(h.on("hidden.bs.modal",function(){i.visible(!1)}),k.show&&i.visible(!0)),{controlsDescendantBindings:!0}},update:function(c,d){var e=d();"undefined"!=typeof e.visible&&b(c).modal(a.unwrap(e.visible)?"show":"hide")}};var c="__popoverTemplateKey__";a.bindingHandlers.popover={init:function(c){var d=b(c);a.utils.domNodeDisposal.addDisposeCallback(c,function(){d.data("bs.popover")&&d.popover("destroy")})},update:function(d,e,f,g,h){var i=b(d),j=a.unwrap(e()),k=(j.options||j.template?a.utils.unwrapProperties(j.options):a.utils.unwrapProperties(j))||{};if(j.template){a.unwrap(j.template);var l=a.utils.domData.get(d,c),m=a.unwrap(j.data),n=function(){a.renderTemplate(a.unwrap(j.template),h.createChildContext(m),j.templateOptions,document.getElementById(l));var c=b("#"+l).parents(".popover"),d=i.data("bs.popover"),e=d.getCalculatedOffset(k.placement||"right",d.getPosition(),c.outerWidth(),c.outerHeight());d.applyPlacement(e,k.placement||"right")};l||(l=a.utils.uniqueId("ks-popover-"),a.utils.domData.set(d,c,l),i.on("shown.bs.popover",n)),k.content='
',k.html=!0,b("#"+l).is(":visible")&&n()}var o=i.data("bs.popover");o?a.utils.extend(o.options,k):(i.popover(k),i.on("shown.bs.popover",function(){(k.container?b(k.container):i.parent()).one("click",'[data-dismiss="popover"]',function(){i.popover("hide")})}))}},a.bindingHandlers.progress={defaults:{css:"progress",text:"",textHidden:!0,striped:!1,type:"",animated:!1},init:function(c,d){var e=b(c),f=d(),g=a.unwrap(f),h=a.bindingHandlers.progress.defaults,i=b.extend({},h,g);if("number"==typeof g)i.value=f,i.barWidth=a.computed(function(){return a.unwrap(f)+"%"});else{if("number"!=typeof a.unwrap(g.value))throw new Error('progress binding can accept only numbers or objects with "value" number propertie');i.barWidth=a.computed(function(){return a.unwrap(g.value)+"%"})}return i.innerCss=a.computed(function(){var b=a.utils.unwrapProperties(g),c="";return b.animated&&(c+="active "),b.striped&&(c+="progress-bar-striped "),b.type&&(c+="progress-bar-"+b.type),c}),a.renderTemplate("progress",i,{templateEngine:a.stringTemplateEngine.instance},c),e.addClass(h.css),{controlsDescendantBindings:!0}}},a.bindingHandlers.radio={init:function(c,d){if(!a.isObservable(d()))throw new Error("radio binding should be used only with observable values");b(c).on("change","input:radio",function(a){setTimeout(function(){var c=b(a.target),e=d(),f=c.val();e(f)},0)})},update:function(c,d){var e,f=b(c).find('input[value="'+a.unwrap(d())+'"]');f.length?(e=f.parent(),e.siblings().removeClass("active"),e.addClass("active"),f.prop("checked",!0)):(e=b(c).find(".active"),e.removeClass("active"),e.find("input").prop("checked",!1))}},a.bindingHandlers.toggle={init:function(c,d){var e=d();if(!a.isObservable(e))throw new Error("toggle binding should be used only with observable values");b(c).on("click",function(b){b.preventDefault();var c=a.unwrap(e);e(!c)})},update:function(b,c){a.utils.toggleDomNodeCssClass(b,"active",a.unwrap(c()))}},a.bindingHandlers.tooltip={init:function(c){var d=b(c);a.utils.domNodeDisposal.addDisposeCallback(c,function(){d.data("bs.tooltip")&&d.tooltip("destroy")})},update:function(c,d){var e=b(c),f=a.unwrap(d()),g=a.utils.unwrapProperties(f),h=e.data("bs.tooltip");h?a.utils.extend(h.options,g):e.tooltip(g)}}}); -------------------------------------------------------------------------------- /src/main/resources/static/js/ko-bootstrap-select.js: -------------------------------------------------------------------------------- 1 | //https://github.com/silviomoreto/bootstrap-select/issues/200 2 | ko.bindingHandlers.selectPicker = { 3 | init: function (element, valueAccessor, allBindingsAccessor) { 4 | if ($(element).is('select')) { 5 | if (ko.isObservable(valueAccessor())) { 6 | if ($(element).prop('multiple') && $.isArray(ko.utils.unwrapObservable(valueAccessor()))) { 7 | // in the case of a multiple select where the valueAccessor() is an observableArray, call the default Knockout selectedOptions binding 8 | ko.bindingHandlers.selectedOptions.init(element, valueAccessor, allBindingsAccessor); 9 | } else { 10 | // regular select and observable so call the default value binding 11 | ko.bindingHandlers.value.init(element, valueAccessor, allBindingsAccessor); 12 | } 13 | } 14 | $(element).addClass('selectpicker').selectpicker(); 15 | } 16 | }, 17 | update: function (element, valueAccessor, allBindingsAccessor) { 18 | if ($(element).is('select')) { 19 | var selectPickerOptions = allBindingsAccessor().selectPickerOptions; 20 | if (typeof selectPickerOptions !== 'undefined' && selectPickerOptions !== null) { 21 | var options = selectPickerOptions.optionsArray, 22 | optionsText = selectPickerOptions.optionsText, 23 | optionsValue = selectPickerOptions.optionsValue, 24 | optionsCaption = selectPickerOptions.optionsCaption, 25 | isDisabled = selectPickerOptions.disabledCondition || false, 26 | resetOnDisabled = selectPickerOptions.resetOnDisabled || false; 27 | if (ko.utils.unwrapObservable(options).length > 0) { 28 | // call the default Knockout options binding 29 | ko.bindingHandlers.options.update(element, options, allBindingsAccessor); 30 | } 31 | if (isDisabled && resetOnDisabled) { 32 | // the dropdown is disabled and we need to reset it to its first option 33 | $(element).selectpicker('val', $(element).children('option:first').val()); 34 | } 35 | $(element).prop('disabled', isDisabled); 36 | } 37 | if (ko.isObservable(valueAccessor())) { 38 | if ($(element).prop('multiple') && $.isArray(ko.utils.unwrapObservable(valueAccessor()))) { 39 | // in the case of a multiple select where the valueAccessor() is an observableArray, call the default Knockout selectedOptions binding 40 | ko.bindingHandlers.selectedOptions.update(element, valueAccessor); 41 | } else { 42 | // call the default Knockout value binding 43 | ko.bindingHandlers.value.update(element, valueAccessor); 44 | } 45 | } 46 | 47 | $(element).selectpicker('refresh'); 48 | } 49 | } 50 | }; -------------------------------------------------------------------------------- /src/main/resources/templates/home.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | Mqtt Demo 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 53 | 54 | 55 |
56 |
57 |
58 |   59 | 60 | 61 |
62 |

MQTT Map Demo

63 |
64 |
65 |
66 |
67 |
68 |
69 |

Topic queue () :

70 | 71 |

Session () :

72 | 73 | 74 |
75 |
76 |
77 | 78 | -------------------------------------------------------------------------------- /src/test/java/mqtt/MqttApplicationTests.java: -------------------------------------------------------------------------------- 1 | package mqtt; 2 | 3 | import org.junit.Test; 4 | import org.junit.runner.RunWith; 5 | import org.springframework.boot.test.SpringApplicationConfiguration; 6 | import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; 7 | 8 | @RunWith(SpringJUnit4ClassRunner.class) 9 | @SpringApplicationConfiguration(classes = MqttApplication.class) 10 | public class MqttApplicationTests { 11 | 12 | @Test 13 | public void contextLoads() { 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/test/java/mqtt/TestSessionService.java: -------------------------------------------------------------------------------- 1 | package mqtt; 2 | 3 | import mqtt.domain.Session; 4 | import mqtt.domain.Topic; 5 | import mqtt.domain.Track; 6 | import mqtt.service.SessionService; 7 | import mqtt.service.TopicService; 8 | import mqtt.service.TrackService; 9 | import org.junit.Before; 10 | import org.junit.Test; 11 | import org.junit.runner.RunWith; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.boot.test.SpringApplicationConfiguration; 14 | import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; 15 | import org.springframework.transaction.annotation.Transactional; 16 | 17 | import static org.junit.Assert.assertNotNull; 18 | import static org.junit.Assert.assertTrue; 19 | 20 | /** 21 | * Created by sfrensley on 3/15/15. 22 | */ 23 | @SuppressWarnings({"SpringJavaAutowiringInspection"}) 24 | @RunWith(SpringJUnit4ClassRunner.class) 25 | @SpringApplicationConfiguration(classes = MqttApplication.class) 26 | @Transactional 27 | public class TestSessionService { 28 | 29 | @Autowired private SessionService sessionService; 30 | @Autowired private TopicService topicService; 31 | @Autowired private TrackService trackService; 32 | 33 | Topic t; 34 | 35 | @Before 36 | public void before() { 37 | t = topicService.findOrCreateTopic("topic"); 38 | assertNotNull(t); 39 | } 40 | 41 | 42 | /** 43 | * Assert that we get the same session inside the tolerance window 44 | */ 45 | @Test 46 | public void testFindSameLatestSession() { 47 | Session s1 = new Session(); 48 | s1.setDate(System.currentTimeMillis()); 49 | s1.setTopic(t); 50 | s1 = sessionService.save(s1); 51 | assertNotNull(s1); 52 | assertNotNull(s1.getId()); 53 | Session s2 = sessionService.findOrCreateSession(t,5000L); 54 | assertNotNull(s2); 55 | assertTrue(s1.getId().equals(s2.getId())); 56 | } 57 | 58 | /** 59 | * Assert that we get a different @Session inside the tolerance window 60 | */ 61 | @Test 62 | public void testFindNewLatestSession() { 63 | Session s1 = new Session(); 64 | s1.setDate(System.currentTimeMillis()); 65 | s1.setTopic(t); 66 | s1 = sessionService.save(s1); 67 | assertNotNull(s1); 68 | assertNotNull(s1.getId()); 69 | Session s2 = sessionService.findOrCreateSession(t, 1L); 70 | assertNotNull(s2); 71 | assertTrue(!s1.getId().equals(s2.getId())); 72 | } 73 | 74 | @Test 75 | public void testDeleteSession() { 76 | Session s1 = new Session(); 77 | s1.setDate(System.currentTimeMillis()); 78 | s1.setTopic(t); 79 | s1 = sessionService.save(s1); 80 | Session s2 = new Session(); 81 | s2.setDate(System.currentTimeMillis()); 82 | s1.setTopic(t); 83 | s2 = sessionService.save(s2); 84 | //just to be sure 85 | assertTrue(!s1.getId().equals(s2.getId())); 86 | 87 | //create tracks to s1 88 | Track t1 = new Track(); 89 | t1.setSession(s1); 90 | t1 = trackService.save(t1); 91 | Track t2 = new Track(); 92 | t2.setSession(s1); 93 | t2 = trackService.save(t2); 94 | assertNotNull(t1); 95 | assertNotNull(t2); 96 | assertTrue(t1.getSession().getId().equals(s1.getId())); 97 | assertTrue(t2.getSession().getId().equals(s1.getId())); 98 | 99 | //create tracks to s2 100 | Track t3 = new Track(); 101 | t3.setSession(s2); 102 | t3 = trackService.save(t3); 103 | Track t4 = new Track(); 104 | t4.setSession(s2); 105 | t4 = trackService.save(t4); 106 | assertNotNull(t3); 107 | assertNotNull(t4); 108 | assertTrue(t3.getSession().getId().equals(s2.getId())); 109 | assertTrue(t4.getSession().getId().equals(s2.getId())); 110 | sessionService.deleteSession(s1.getId()); 111 | //deletes 112 | //Something is funky with SDN deletes. The actual node seems to still be around, 113 | //but the SDN _Type index has been removed; Thus it errors with: 114 | // "java.lang.IllegalStateException: No primary SDN label exists .. (i.e one starting with _) " 115 | try { 116 | trackService.findById(t1.getId()); 117 | assertTrue("Exception expected.",true); 118 | } catch (IllegalStateException e) { 119 | //ok 120 | } 121 | try { 122 | trackService.findById(t2.getId()); 123 | assertTrue("Exception expected.",true); 124 | } catch (IllegalStateException e) { 125 | //ok 126 | } 127 | try { 128 | sessionService.findById(s1.getId()); 129 | assertTrue("Exception expected.",true); 130 | } catch (IllegalStateException e) { 131 | //ok 132 | } 133 | //asure that we didnt delete other sessions 134 | Session testS2 = sessionService.findById(s2.getId()); 135 | assertNotNull(testS2); 136 | Track testT3 = trackService.findById(t3.getId()); 137 | assertNotNull(testT3); 138 | Track testT4 = trackService.findById(t4.getId()); 139 | assertNotNull(testT4); 140 | 141 | } 142 | 143 | } 144 | -------------------------------------------------------------------------------- /src/test/java/mqtt/TestTopicService.java: -------------------------------------------------------------------------------- 1 | package mqtt; 2 | 3 | import mqtt.domain.Topic; 4 | import mqtt.service.TopicService; 5 | import org.junit.Test; 6 | import org.junit.runner.RunWith; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.boot.test.SpringApplicationConfiguration; 9 | import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; 10 | import org.springframework.transaction.annotation.Transactional; 11 | 12 | import static org.junit.Assert.assertNotNull; 13 | import static org.junit.Assert.assertTrue; 14 | 15 | /** 16 | * Created by sfrensley on 3/15/15. 17 | */ 18 | @SuppressWarnings("SpringJavaAutowiringInspection") 19 | @RunWith(SpringJUnit4ClassRunner.class) 20 | @SpringApplicationConfiguration(classes = MqttApplication.class) 21 | @Transactional 22 | public class TestTopicService { 23 | 24 | @Autowired 25 | TopicService service; 26 | 27 | /** 28 | * Assert that same session is returned for same name. 29 | * Assert that different session is return for different name. 30 | */ 31 | @Test 32 | public void testFindOrCreate() { 33 | Topic t1 = service.findOrCreateTopic("foo"); 34 | 35 | assertNotNull(t1); 36 | assertNotNull(t1.getId()); 37 | 38 | Topic t2 = service.findOrCreateTopic("foo"); 39 | assertTrue(t1.getId().equals(t2.getId())); 40 | 41 | Topic t3 = service.findOrCreateTopic("bar"); 42 | assertTrue(!t3.getId().equals(t2.getId())); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/test/java/mqtt/TestTrackService.java: -------------------------------------------------------------------------------- 1 | package mqtt; 2 | 3 | import mqtt.domain.Session; 4 | import mqtt.domain.Topic; 5 | import mqtt.domain.Track; 6 | import mqtt.service.SessionService; 7 | import mqtt.service.TopicService; 8 | import mqtt.service.TrackService; 9 | import org.junit.Before; 10 | import org.junit.Test; 11 | import org.junit.runner.RunWith; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.boot.test.SpringApplicationConfiguration; 14 | import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; 15 | import org.springframework.transaction.annotation.Transactional; 16 | 17 | import static org.junit.Assert.assertNotNull; 18 | import static org.junit.Assert.assertTrue; 19 | 20 | /** 21 | * Created by sfrensley on 3/16/15. 22 | */ 23 | @SuppressWarnings("SpringJavaAutowiringInspection") 24 | @RunWith(SpringJUnit4ClassRunner.class) 25 | @SpringApplicationConfiguration(classes = MqttApplication.class) 26 | @Transactional 27 | public class TestTrackService { 28 | 29 | 30 | @Autowired TrackService trackService; 31 | @Autowired SessionService sessionService; 32 | @Autowired TopicService topicService; 33 | 34 | Topic t; 35 | 36 | @Before 37 | public void before() { 38 | t = topicService.findOrCreateTopic("topic"); 39 | assertNotNull(t); 40 | } 41 | 42 | /** 43 | * Assert that the save @Track is delivered for matching @Topic and timestamp 44 | */ 45 | @Test 46 | public void testFindByTopicAndTimestamp() { 47 | Session s1 = new Session(); 48 | s1.setDate(System.currentTimeMillis()); 49 | s1.setTopic(t); 50 | s1 = sessionService.save(s1); 51 | assertNotNull(s1.getId()); 52 | // 53 | Track track = new Track(); 54 | track.setSession(s1); 55 | track.setTst(9999L); 56 | track = trackService.save(track); 57 | assertNotNull(track.getId()); 58 | // 59 | Track track1 = trackService.findByTopicAndTimestamp(t.getName(), track.getTst().longValue()); 60 | assertNotNull(track1); 61 | assertTrue(track1.getId().equals(track.getId())); 62 | } 63 | 64 | /** 65 | * Assert that a @Track point in the same @Session is within the distance tolerance. 66 | */ 67 | @Test 68 | public void testFindWithinDistanceForSession() { 69 | Session s1 = new Session(); 70 | s1.setDate(System.currentTimeMillis()); 71 | s1.setTopic(t); 72 | s1 = sessionService.save(s1); 73 | assertNotNull(s1.getId()); 74 | // 75 | Track track = new Track(); 76 | track.setSession(s1); 77 | track.setTst(9999L); 78 | track.setLocation(-97.7477,30.2603); 79 | track = trackService.save(track); 80 | assertNotNull(track.getId()); 81 | //Is point within 20 meters? 82 | boolean s3 = trackService.isWithinDistanceForSession(track, 20.0D, s1); 83 | assertTrue(s3); 84 | //Move point more than 20 meters. 85 | track.setLocation(-90.7477,30.2603); 86 | boolean s4 = trackService.isWithinDistanceForSession(track, 20.0D, s1); 87 | assertTrue(!s4); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: 3 | active: test --------------------------------------------------------------------------------